Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ jobs:
cargo build -p bsk --locked
pnpm --filter @browser-skill/extension build
pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build
- name: Validate interaction capture on HTTP intranet pages
working-directory: apps/extension
env:
BSK_COOP_CHROME: ${{ env.BSK_VIDEO_CHROME }}
run: pnpm exec vitest run src/content/__tests__/cooperation-capture.browser.test.ts
- name: Validate task termination on tab detachment
working-directory: apps/extension
env:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// @vitest-environment node
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { build } from "esbuild";
import { describe, expect, it, vi } from "vitest";
import { collectCooperationFrames } from "@/cooperation/browser-bridge";
import { COOPERATION_ARM, COOPERATION_READ } from "@/cooperation/types";

type Send = <T = Record<string, unknown>>(
method: string,
params?: object,
sessionId?: string,
) => Promise<T>;

describe.skipIf(!process.env.BSK_COOP_CHROME)("HTTP interaction capture", () => {
it("collects top-level and child frames on secure and insecure HTTP origins", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "bsk-http-cooperation-"));
const server = createServer((request, response) => {
response.setHeader("Content-Type", "text/html");
response.end(
request.url === "/frame"
? '<!doctype html><input aria-label="Frame" value="child">'
: '<!doctype html><input aria-label="Page" value="top"><iframe src="/frame"></iframe>',
);
});
try {
const entry = path.join(directory, "content.js");
await writeFile(
entry,
`import { attachCooperationCapture } from '@/content/cooperation-capture';
chrome.runtime.onMessage.addListener((message, _sender, reply) => {
if (message.kind === 'fixture.environment') reply({
secure: isSecureContext,
randomUUID: typeof crypto.randomUUID,
getRandomValues: typeof crypto.getRandomValues,
});
});
attachCooperationCapture();`,
);
await build({
entryPoints: [entry],
outfile: path.join(directory, "content.bundle.js"),
bundle: true,
format: "iife",
alias: { "@": path.resolve("src") },
});
const matches = ["http://127.0.0.1/*", "http://intranet.test/*"];
await writeFile(
path.join(directory, "manifest.json"),
JSON.stringify({
manifest_version: 3,
name: "HTTP interaction regression",
version: "1.0",
background: { service_worker: "http-cooperation-worker.js" },
permissions: ["tabs", "webNavigation"],
host_permissions: matches,
content_scripts: [
{ matches, js: ["content.bundle.js"], run_at: "document_start", all_frames: true },
],
}),
);
await writeFile(
path.join(directory, "http-cooperation-worker.js"),
"chrome.runtime.onInstalled.addListener(() => {}); globalThis.httpCooperationReady = true;",
);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
const { withChrome } = await import(
new URL(
"../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs",
import.meta.url,
).href
);
await withChrome(
{
executable: process.env.BSK_COOP_CHROME,
extensionPath: directory,
deviceScale: 1,
zoom: 1,
// Loopback URLs are secure contexts. Map a non-localhost name to our
// server so Chrome exercises the same API restrictions as an intranet.
extraArgs: [
"--host-resolver-rules=MAP intranet.test 127.0.0.1",
"--no-proxy-server",
"--disable-features=HttpsUpgrades",
],
},
async (send: Send) => {
let worker: { targetId: string } | undefined;
await expect
.poll(
async () => {
const { targetInfos } = await send<{
targetInfos: { targetId: string; type: string; url: string }[];
}>("Target.getTargets");
worker = targetInfos.find(
(target) =>
target.type === "service_worker" &&
target.url.endsWith("/http-cooperation-worker.js"),
);
return worker;
},
{ timeout: 10000 },
)
.toBeDefined();
const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", {
targetId: worker!.targetId,
flatten: true,
});
const evaluate = async <T>(expression: string): Promise<T> => {
const reply = await send<{ result: { value: T }; exceptionDetails?: unknown }>(
"Runtime.evaluate",
{ expression, returnByValue: true, awaitPromise: true },
sessionId,
);
expect(reply.exceptionDetails).toBeUndefined();
return reply.result.value;
};
await expect
.poll(() => evaluate("globalThis.httpCooperationReady"), { timeout: 10000 })
.toBe(true);
expect(await evaluate("chrome.runtime.getManifest().name")).toBe(
"HTTP interaction regression",
);
// Keep the production collector and bridge; forward only Chrome APIs
// from this test process to the isolated extension's real worker.
const getAllFrames = ({ tabId }: { tabId: number }) =>
evaluate<chrome.webNavigation.GetAllFrameResultDetails[]>(
`chrome.webNavigation.getAllFrames({tabId:${tabId}})`,
);
const sendMessage = (tabId: number, message: unknown, options: object) =>
evaluate(
`chrome.tabs.sendMessage(${tabId},${JSON.stringify(message)},${JSON.stringify(options)})`,
);
vi.stubGlobal("chrome", { webNavigation: { getAllFrames }, tabs: { sendMessage } });
for (const host of ["127.0.0.1", "intranet.test"]) {
const url = `http://${host}:${port}/`;
const { id: tabId } = await evaluate<{ id: number }>(
`chrome.tabs.create({url:${JSON.stringify(url)}})`,
);
await expect
.poll(() => evaluate(`chrome.tabs.get(${tabId})`), { timeout: 10000 })
.toMatchObject({ url, status: "complete" });
const frames = await getAllFrames({ tabId });
expect(frames).toHaveLength(2);
for (const frame of frames) {
expect(
await sendMessage(
tabId,
{ kind: "fixture.environment" },
{
documentId: frame.documentId,
},
),
).toEqual({
secure: host === "127.0.0.1",
randomUUID: host === "127.0.0.1" ? "function" : "undefined",
getRandomValues: "function",
});
}
const states = await collectCooperationFrames(tabId, COOPERATION_READ, "lease");
expect(states.size).toBe(2);
expect(states.get(0)?.fields).toEqual([
expect.objectContaining({ label: "Page", value: "top" }),
]);
const childId = frames.find((frame) => frame.parentFrameId === 0)!.frameId;
expect(states.get(childId)?.fields).toEqual([
expect.objectContaining({ label: "Frame", value: "child" }),
]);
expect(new Set([...states.values()].map((state) => state.document)).size).toBe(2);
const armed = await collectCooperationFrames(tabId, COOPERATION_ARM, "lease", {
type: "pointerdown",
x: 10,
y: 20,
});
for (const [frameId, state] of states) {
expect(armed.get(frameId)).toMatchObject({
document: state.document,
fields: state.fields,
});
}
await evaluate(`chrome.tabs.remove(${tabId})`);
}
},
);
} finally {
vi.unstubAllGlobals();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
}
}, 60000);
});
20 changes: 20 additions & 0 deletions apps/extension/src/content/__tests__/cooperation-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ function collector(shadowRoot?: (element: HTMLElement) => ShadowRoot | null) {
};
}

it("collects and arms page interaction when crypto.randomUUID is unavailable", () => {
// Ordinary HTTP origins expose getRandomValues but not randomUUID.
vi.stubGlobal("crypto", { getRandomValues: crypto.getRandomValues.bind(crypto) });
document.body.innerHTML = '<input aria-label="City" value="Shanghai">';

const read = collector();
const initial = read()!;
expect(initial.document).toMatch(/^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/);
expect(initial.fields).toEqual([expect.objectContaining({ label: "City", value: "Shanghai" })]);
expect(read(COOPERATION_ARM, "first", { type: "pointerdown", x: 10, y: 20 })).toMatchObject({
document: initial.document,
fields: initial.fields,
});

dispose!();
dispose = undefined;
const restarted = collector();
expect(restarted()!.document).not.toBe(initial.document);
});

it("retires old collection leases so delayed reads cannot restart collection after stop", () => {
const read = collector();
expect(read()).toBeDefined();
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/src/content/cooperation-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type InputExpectation,
} from "@/cooperation/types";
import { OVERLAY_HOST_SELECTOR } from "@/lib/overlay-bridge";
import { createRandomUuid } from "@/lib/random-uuid";

const MAX_CHANGES = 64;
// CDP's native clock conversion and the DOM clock round independently.
Expand All @@ -24,7 +25,7 @@ export function attachCooperationCapture() {
let lease: string | undefined;
const retired = new Set<string>();
const state: FrameState = {
document: crypto.randomUUID(),
document: createRandomUuid(),
revision: 0,
structure: 0,
human: 0,
Expand Down
Loading