test(browser): verify page-scoped Chrome transport
This commit is contained in:
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user