diff --git a/gui/tests/sidebar-version-browser.ts b/gui/tests/sidebar-version-browser.ts index 59e4b01c433..b91caa317cb 100644 --- a/gui/tests/sidebar-version-browser.ts +++ b/gui/tests/sidebar-version-browser.ts @@ -2,7 +2,8 @@ * when Chrome/Chromium is not on PATH. No browser package or downloads required. * The fixture uses the real bundled stylesheet and the App drawer/topbar markup; * it intentionally does not connect to a user's proxy or credentials. */ -import { mkdtemp, readFile, rm, mkdir, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { readFile, rm, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve, sep } from "node:path"; @@ -21,50 +22,47 @@ const css = await readFile(cssFile, "utf8"); const logo = `data:image/png;base64,${(await readFile(join(dist, "logo.png"))).toString("base64")}`; const brand = ``; const html = `
${brand}
`; -const profile = await mkdtemp(join(tmpdir(), "ocx-sidebar-chrome-")); -const browser = Bun.spawn([chrome, "--headless", "--disable-gpu", "--disable-background-networking", - "--no-first-run", "--no-default-browser-check", "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", `--user-data-dir=${profile}`, +const profile = join(tmpdir(), `ocx-sidebar-chrome-${crypto.randomUUID()}`); +const browser = spawn(chrome, ["--headless", "--disable-gpu", "--disable-background-networking", + "--no-first-run", "--no-default-browser-check", "--remote-debugging-pipe", + `--user-data-dir=${profile}`, ...(process.env.CHROME_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), "about:blank"], -{ stdout: "ignore", stderr: "ignore" }); -let socket: WebSocket | undefined; +{ stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] }); +const pipeWrite = browser.stdio[3]; +const pipeRead = browser.stdio[4]; +if (!pipeWrite || !pipeRead) throw new Error("Chrome did not create its private debugging pipe."); +const exited = new Promise((done, fail) => { + browser.once("exit", () => done()); + browser.once("error", fail); +}); const delay = (ms: number) => new Promise(done => setTimeout(done, ms)); try { - let debugPort = ""; - const deadline = Date.now() + 10_000; - while (!debugPort && Date.now() < deadline) { - try { debugPort = (await readFile(join(profile, "DevToolsActivePort"), "utf8")).split("\n")[0]; } - catch { await delay(50); } - } - if (!/^\d+$/.test(debugPort)) throw new Error("Chrome did not expose its local debugging port within 10 seconds."); - const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT", signal: AbortSignal.timeout(5_000) }); - if (!response.ok) throw new Error(`Cannot create browser target: ${response.status}`); - const target = await response.json() as { webSocketDebuggerUrl: string }; - socket = new WebSocket(target.webSocketDebuggerUrl); - const ws = socket; - await new Promise((done, fail) => { - const timer = setTimeout(() => fail(new Error("CDP connection timed out")), 5_000); - ws.addEventListener("open", () => { clearTimeout(timer); done(); }, { once: true }); - ws.addEventListener("error", () => { clearTimeout(timer); fail(new Error("CDP connection failed")); }, { once: true }); - }); let id = 0; const pending = new Map void; reject: (reason: Error) => void }>(); - ws.addEventListener("message", event => { - const message = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } }; - if (message.id === undefined) return; - const call = pending.get(message.id); - if (!call) return; - pending.delete(message.id); - if (message.error) call.reject(new Error(message.error.message)); else call.resolve(message.result); + let buffered = Buffer.alloc(0); + pipeRead.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + for (let boundary = buffered.indexOf(0); boundary >= 0; boundary = buffered.indexOf(0)) { + const message = JSON.parse(buffered.subarray(0, boundary).toString()) as { id?: number; result?: unknown; error?: { message: string } }; + buffered = buffered.subarray(boundary + 1); + if (message.id === undefined) continue; + const call = pending.get(message.id); + if (!call) continue; + pending.delete(message.id); + if (message.error) call.reject(new Error(message.error.message)); else call.resolve(message.result); + } }); - function cdp(method: string, params: Record = {}): Promise { + function callCdp(method: string, params: Record = {}, sessionId?: string): Promise { return new Promise((done, fail) => { const next = ++id; const timer = setTimeout(() => { pending.delete(next); fail(new Error(`CDP timeout: ${method}`)); }, 5_000); pending.set(next, { resolve: value => { clearTimeout(timer); done(value as T); }, reject: error => { clearTimeout(timer); fail(error); } }); - ws.send(JSON.stringify({ id: next, method, params })); + pipeWrite.write(`${JSON.stringify({ id: next, method, params, ...(sessionId ? { sessionId } : {}) })}\0`); }); } + const { targetId } = await callCdp<{ targetId: string }>("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await callCdp<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true }); + const cdp = (method: string, params: Record = {}) => callCdp(method, params, sessionId); async function evaluate(expression: string): Promise { const result = await cdp<{ result: { value: T }; exceptionDetails?: unknown }>("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error(`Browser evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); @@ -128,13 +126,12 @@ try { } } } - const version = await cdp("Browser.getVersion"); + const version = await callCdp("Browser.getVersion"); await writeFile(join(output, "results.json"), JSON.stringify({ scope: "Real Chromium geometry with built production CSS; isolated App header markup, no live proxy", browser: version, cssPath, cssSha256: new Bun.CryptoHasher("sha256").update(css).digest("hex"), cases }, null, 2)); console.log(`PASS: ${cases.length} built-CSS browser cases; full version visible, badge bounded, no drawer-close overlap.`); } finally { - socket?.close(); browser.kill(); - await Promise.race([browser.exited, delay(2_000)]); - if (browser.exitCode === null) { browser.kill("SIGKILL"); await browser.exited; } + await Promise.race([exited, delay(2_000)]); + if (browser.exitCode === null) { browser.kill("SIGKILL"); await exited; } await rm(profile, { recursive: true, force: true }); } diff --git a/gui/tests/sidebar-version-layout.test.ts b/gui/tests/sidebar-version-layout.test.ts index 6be645179e4..10bda05bfbb 100644 --- a/gui/tests/sidebar-version-layout.test.ts +++ b/gui/tests/sidebar-version-layout.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; const css = await Bun.file(new URL("../src/styles/sidebar-brand.css", import.meta.url)).text(); const entry = await Bun.file(new URL("../src/main.tsx", import.meta.url)).text(); +const browserHarness = await Bun.file(new URL("./sidebar-version-browser.ts", import.meta.url)).text(); function block(selector: string): string { const start = css.indexOf(`${selector} {`); @@ -49,3 +50,9 @@ test("the fix stays scoped to the drawer and leaves compact topbar policies inta .map(match => match[1].trim()); expect(selectors).toEqual([".drawer-head .brand", ".drawer-head .brand .ver"]); }); + +test("the opt-in browser harness keeps DevTools on a private process pipe", () => { + expect(browserHarness).toContain('"--remote-debugging-pipe"'); + expect(browserHarness).not.toContain("--remote-debugging-port"); + expect(browserHarness).not.toContain("--remote-debugging-address"); +}); diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..075e3c33186 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..a718c1c376a 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic",