test(browser): verify page-scoped Chrome transport

This commit is contained in:
N0zoM1z0
2026-07-14 21:21:50 +00:00
parent 9be24f8de1
commit c252536494
3 changed files with 205 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Real browser verification
on:
pull_request:
paths:
- ".github/workflows/real-browser.yml"
- "scripts/verify-real-browser.mjs"
- "scripts/fixtures/browser-verifier-site.mjs"
- "scripts/fixtures/page-cdp-proxy.mjs"
- "src/browser/**"
- "src/domain/browser*.ts"
- "package.json"
- "package-lock.json"
workflow_dispatch:
permissions:
contents: read
jobs:
real-browser:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run verify:browser
env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
+111
View File
@@ -0,0 +1,111 @@
import { createServer } from "node:http";
import WebSocket, { WebSocketServer } from "ws";
/**
* Expose Chrome discovery through a page-scoped-only CDP endpoint.
*
* The proxy deliberately reports a `/devtools/page/<id>` socket from
* `/json/version`, so the verifier cannot silently fall back to the browser
* socket. WebSocket bytes are forwarded without interpreting CDP messages.
*/
export async function startPageCdpProxy(upstreamEndpoint) {
const clients = new Set();
const server = createServer(async (request, response) => {
try {
if (request.url !== "/json/version" && request.url !== "/json/list") {
response.writeHead(404).end();
return;
}
const value = await upstreamJson(upstreamEndpoint, request.url);
const rewritten =
request.url === "/json/version"
? await pageScopedVersion(upstreamEndpoint, value)
: value;
const body = JSON.stringify(rewritten);
response.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
});
response.end(body);
} catch (error) {
response.writeHead(502).end(String(error));
}
});
const webSockets = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
if (!request.url?.startsWith("/devtools/page/")) {
socket.destroy();
return;
}
webSockets.handleUpgrade(request, socket, head, (client) => {
const upstream = new WebSocket(
new URL(request.url, upstreamEndpoint).href.replace(/^http/, "ws"),
);
const pending = [];
clients.add(client);
const close = () => {
clients.delete(client);
if (client.readyState < WebSocket.CLOSING) client.close();
if (upstream.readyState < WebSocket.CLOSING) upstream.close();
};
client.on("message", (data, binary) => {
if (upstream.readyState === WebSocket.OPEN)
upstream.send(data, { binary });
else pending.push({ data, binary });
});
upstream.on("message", (data, binary) => {
if (client.readyState === WebSocket.OPEN) client.send(data, { binary });
});
upstream.on("open", () => {
for (const { data, binary } of pending) upstream.send(data, { binary });
pending.length = 0;
webSockets.emit("connection", client, request);
});
client.on("close", close);
client.on("error", close);
upstream.on("close", close);
upstream.on("error", close);
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Page CDP proxy did not bind a TCP port");
const endpoint = `http://127.0.0.1:${String(address.port)}`;
return {
endpoint,
disconnectClients() {
for (const client of clients) client.terminate();
},
async close() {
for (const client of clients) client.terminate();
webSockets.close();
await new Promise((resolve, reject) =>
server.close((error) =>
error === undefined ? resolve() : reject(error),
),
);
},
};
}
async function pageScopedVersion(upstreamEndpoint, version) {
const targets = await upstreamJson(upstreamEndpoint, "/json/list");
const page = targets.find(
(target) =>
target.type === "page" && typeof target.webSocketDebuggerUrl === "string",
);
if (page === undefined) throw new Error("Chrome exposed no page CDP socket");
return { ...version, webSocketDebuggerUrl: page.webSocketDebuggerUrl };
}
async function upstreamJson(endpoint, path) {
const response = await fetch(new URL(path, endpoint));
if (!response.ok)
throw new Error(`Upstream CDP ${path} returned ${response.status}`);
return await response.json();
}
+62
View File
@@ -18,6 +18,7 @@ import {
compareWebScreenshotsInputSchema,
} from "../dist/domain/webScreenshot.js";
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
import { startPageCdpProxy } from "./fixtures/page-cdp-proxy.mjs";
const SECRET_VALUES = [
"network-secret-value",
@@ -31,6 +32,7 @@ const executable = await browserExecutable();
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
const site = await startBrowserVerifierSite();
let browser;
let pageProxy;
try {
browser = spawn(
executable,
@@ -191,6 +193,9 @@ try {
)
throw new Error("Real Chrome same-origin SPA timeline was missing");
pageProxy = await startPageCdpProxy(endpoint);
await verifyPageScopedTransport(provider, pageProxy, site.origin);
process.stdout.write(
`${JSON.stringify({
browser: observed.value.browser.product,
@@ -205,11 +210,13 @@ try {
bundleScripts: bundle.value.capture.scripts_analyzed,
sourceMaps: bundle.value.observations.source_maps.processed,
sessionEvents: session.value.timeline.length,
pageScopedTransport: true,
screenshotBytes: screenshot.value.artifact.bytes,
verified: true,
})}\n`,
);
} finally {
if (pageProxy !== undefined) await pageProxy.close();
if (browser !== undefined) await stopProcess(browser);
await site.close();
await rm(profile, {
@@ -220,6 +227,61 @@ try {
});
}
async function verifyPageScopedTransport(provider, proxy, origin) {
const target = await pageTarget(provider, proxy.endpoint, origin);
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: proxy.endpoint,
allowed_origins: [origin],
approved: true,
target_id: target,
observation_ms: 100,
});
const observed = await provider.inspectPage(input);
if (!observed.ok) throw observed.error;
if (observed.value.dom.nodes.length < 1)
throw new Error("Page-scoped CDP transport returned no DOM nodes");
const controller = new AbortController();
const cancelledPromise = provider.inspectPage(
{ ...input, observation_ms: 5_000 },
{ signal: controller.signal },
);
await delay(100);
controller.abort();
const cancelled = await cancelledPromise;
if (
cancelled.ok ||
cancelled.error._tag !== "AnalysisCancelledError" ||
cancelled.error.operation !== "inspect_web_page"
)
throw new Error(
"Page-scoped CDP cancellation lost its operation semantics",
);
const disconnectedPromise = provider.observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: proxy.endpoint,
allowed_origins: [origin],
approved: true,
target_id: target,
observation_ms: 5_000,
}),
);
await delay(100);
proxy.disconnectClients();
const disconnected = await disconnectedPromise;
if (
!disconnected.ok ||
disconnected.value.window.end_reason !== "target_terminated" ||
!disconnected.value.timeline.some(
({ type }) => type === "target_terminated",
)
)
throw new Error(
"Page-scoped CDP disconnect was not reported as target_terminated",
);
}
async function browserExecutable() {
const candidates = [
process.env.REA_BROWSER_EXECUTABLE,