diff --git a/packages/webui/src/client/features/browser/contracts/browser-port.ts b/packages/webui/src/client/features/browser/contracts/browser-port.ts index 7e33f8772..0e2214bd6 100644 --- a/packages/webui/src/client/features/browser/contracts/browser-port.ts +++ b/packages/webui/src/client/features/browser/contracts/browser-port.ts @@ -239,6 +239,15 @@ export interface BrowserPort { readonly viewport?: WebuiBrowserViewport; }, onFrame: (frame: WebuiBrowserImageFrame) => void, + /** + * The service's refusal, when the stream gave up. + * + * The frames themselves say nothing about a stream that stopped, so the + * refusal is reported here rather than left to the panel to infer: a + * panel that is waiting and a panel whose stream failed look identical + * until one of them is told. + */ + onFailure?: (error: Error) => void, ) => () => void; /** * The session's web tabs, as a snapshot, on a stream. diff --git a/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx b/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx index e4c7828de..86edc8093 100644 --- a/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx +++ b/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx @@ -76,6 +76,10 @@ const PANEL_COPY = { absent: "当前运行时没有浏览器能力,浏览器面板不可用。", notReady: "浏览器能力尚未就绪(例如扩展未连接),面板暂时无法显示页面。", unknown: "当前运行时没有报告浏览器能力,面板按未授权处理。", + // The stream gave up. The reason is said once, beside this notice, as a + // toast; this sentence says the half that is about the panel — that the + // picture is not coming, and that opening the page again is the way back. + failed: "浏览器画面流已失败,画面不会再来。重新打开页面可以重试。", } as const; export interface BrowserPanelProps { @@ -411,6 +415,19 @@ export function BrowserPanel({ ) : null} + {/* + A stream that gave up is not a page that is still loading. The empty + placeholder below looks the same in both cases, so the failure says + so — and it says it here, in the panel, rather than only in a toast + that a person can miss. It renders only when the panel failed, so the + default DOM is the one that was there before. + */} + {state?.status === "error" ? ( + + {PANEL_COPY.failed} + + ) : null} +
{frameSrc ? ( { + dispatch({ type: "failed", error: toBrowserError(error) }); + }, ); return () => { stop(); diff --git a/packages/webui/src/client/platform/transport.ts b/packages/webui/src/client/platform/transport.ts index dfa6b614b..68c494c75 100644 --- a/packages/webui/src/client/platform/transport.ts +++ b/packages/webui/src/client/platform/transport.ts @@ -325,8 +325,8 @@ export function createWebuiTransport(options: WebuiTransportOptions): Required request(SEND_BROWSER_COMMAND_OPERATION_NAME, body), - watchBrowserFrames: (body, onFrame) => - watchBrowserFrames(WATCH_BROWSER_FRAMES_OPERATION_NAME, body, onFrame), + watchBrowserFrames: (body, onFrame, onFailure) => + watchBrowserFrames(WATCH_BROWSER_FRAMES_OPERATION_NAME, body, onFrame, onFailure), watchBrowserWebTabs: (body, onSnapshot) => watchBrowserWebTabs(WATCH_BROWSER_WEB_TABS_OPERATION_NAME, body, onSnapshot), openBrowserWebTab: (body) => request(OPEN_BROWSER_WEB_TAB_OPERATION_NAME, body), diff --git a/packages/webui/src/client/platform/wire.ts b/packages/webui/src/client/platform/wire.ts index 5b40e8385..44a0f4f44 100644 --- a/packages/webui/src/client/platform/wire.ts +++ b/packages/webui/src/client/platform/wire.ts @@ -194,6 +194,7 @@ export interface WebuiWire { operation: string, requestBody: { readonly surface: WebuiBrowserSurface; readonly target: WebuiBrowserTargetId }, onFrame: (frame: WebuiBrowserImageFrame) => void, + onFailure?: (error: Error) => void, ): () => void; /** * The web-tab snapshot stream. @@ -614,6 +615,7 @@ export function createWebuiWire({ readonly viewport?: WebuiBrowserViewport; }, onFrame: (frame: WebuiBrowserImageFrame) => void, + onFailure?: (error: Error) => void, ): () => void { let stopped = false; const session = openWireSession(operation, requestBody, { @@ -621,6 +623,14 @@ export function createWebuiWire({ // A stopped watch must not deliver, and `close()` is not a promise that // the socket stops emitting: the caller's flag is what guarantees it. if (stopped) return; + // An error frame is the service's answer: it says the stream gave up + // and why. Dropping it is what leaves a panel waiting for a picture + // that is never coming — the watcher has no way to tell "still + // loading" from "the service said it stopped", so it says neither. + if (frame.kind === "error") { + onFailure?.(new WebuiWireError(frame.code, frame.message, frame.requestId)); + return; + } if (frame.kind !== "event" || !frame.body || typeof frame.body !== "object") return; onFrame(frame.body as WebuiBrowserImageFrame); }, diff --git a/packages/webui/src/runtime/browser/browser-service.ts b/packages/webui/src/runtime/browser/browser-service.ts index 5aaaab58a..ce2da17a5 100644 --- a/packages/webui/src/runtime/browser/browser-service.ts +++ b/packages/webui/src/runtime/browser/browser-service.ts @@ -33,6 +33,9 @@ // A surface that is not authorized is refused before anything is attempted, so // "you did not switch this on" is distinguishable from "the browser broke". +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { isSameWebuiBrowserTarget, WEBUI_BROWSER_RESTRICTED_COMMANDS, @@ -74,6 +77,105 @@ import { */ const WEBUI_BROWSER_FRAME_QUEUE_LIMIT = 32; +/** + * How long a stream waits for its **first** frame. + * + * The budget is a promise to the person waiting for the picture, not an + * internal detail: a panel that asked for a frame and gets none must be + * told, because a stream that never ends and a stream that is merely slow + * look identical from the panel. It has to be generous enough for a browser + * that is *busy* rather than broken, because from out here the two are the + * same thing. + * + * The number is the CDP command budget + * (`WEBUI_BROWSER_CDP_COMMAND_TIMEOUT_MS`, 60 s) because the first frame is + * the product of the same work a command waits for: the browser attaches to + * the page, paints it and starts the screencast. A budget shorter than a + * single command's would abandon work the browser is still legitimately + * doing — which is what the old fifteen-second budget did. Its own note + * says it: on a loaded runner "the browser answers a page open in about + * half a minute", the fifteen-second budget "turned that slowness into a + * failure: the open was abandoned halfway, so the panel never received a + * frame and the person saw an empty browser instead of a slow one". + * + * The measurement agrees. On the macOS CI runner the browser path costs + * 19–22 s where the same case costs 5.3 s on Linux (the two-pages + * integration file), and the agent-transcript cases that open the browser + * panel run 58–81 s; one run never reached a first frame within 60 s at + * all. A fifteen-second budget calls the common twenty seconds a failure. + * Sixty seconds covers the common case with room to spare and still ends + * the stream, so a browser that is genuinely wedged is reported rather than + * waited on. + * + * The two budgets are sequential rather than competing. The provider's + * start-up budget (30 s) covers Chrome booting until it publishes a + * DevTools endpoint; this clock starts after the provider exists and the + * screencast has been asked for, so the worst case from "switch on" to + * "picture or error" is the sum of the two, each bounded and each reported + * with its own code. + * + * The clock stops at the first frame. A stream that is running is not a + * stream that is stuck, however long it runs. + */ +const WEBUI_BROWSER_FIRST_FRAME_TIMEOUT_MS = 60_000; + +/** + * Where the browser diagnostics are written. + * + * One directory, so the service's own record of a browser session and the + * provider's record of the same session sit beside each other: the host's + * `WEBUI_BROWSER_OUTPUT_DIR` when it names one, and otherwise a + * directory under the data dir the browser keeps its profile in. + */ +const WEBUI_BROWSER_DIAGNOSTICS_DIRECTORY = "browser-diagnostics"; + +/** + * The file this service writes its own diagnostics to. + * + * The name is fixed. The artifact is the one file a reader looks for, and a + * name with a random suffix would be a file nobody finds. + */ +const WEBUI_BROWSER_SERVICE_DIAGNOSTICS_FILE = "service-browser.json"; + +/** + * The shortest gap between two diagnostics writes. + * + * The dropped-frame counter can move on every frame, so its writes are + * throttled; every other event writes at once, because those are the events + * somebody is waiting to see. + */ +const WEBUI_BROWSER_DIAGNOSTICS_WRITE_INTERVAL_MS = 500; + +/** + * One failure the service saw, as the diagnostics record it. + * + * The code is the browser's own when the failure carries one — the same code + * the caller would have been given — and the message is what the failure + * said. A failure that names nothing is still a failure, and is recorded as + * such. + */ +interface WebuiBrowserFailureRecord { + readonly at: string; + readonly message: string; + readonly code: string | undefined; +} + +/** + * One attempt to start the isolated browser, as the diagnostics record it. + * + * The attempt carries the failure that came before it. A start that follows + * a failed start is a retry, and a retry that leaves no trace of what it + * retries is indistinguishable from a first start. + */ +interface WebuiBrowserLaunchRecord { + readonly at: string; + readonly trigger: "warmup" | "demand"; + readonly outcome: "started" | "failed"; + readonly durationMs: number; + readonly failure: WebuiBrowserFailureRecord | undefined; + readonly previousFailure: WebuiBrowserFailureRecord | undefined; +} + /** * The run whose selection the panel is showing when no run is active. * @@ -155,6 +257,15 @@ export interface WebuiBrowserServiceOptions { }; /** The Chrome executable the isolated browser should use. */ readonly chromePath?: string; + /** + * How long a stream waits for its first frame. + * + * The default is the service's own budget. It is injectable so a test can + * exercise the deadline without waiting for it — the deadline is a product + * behaviour, and a test that shortened the wait must not shorten what + * production waits for. + */ + readonly firstFrameTimeoutMs?: number; } /** @@ -203,6 +314,48 @@ export class WebuiBrowserService { >(); /** How many frames each surface dropped, per surface. */ readonly #droppedFrames = new Map(); + /** + * What this service has seen of the browser session. + * + * The record is what the diagnostics artifact is written from. It exists + * because the failures this service sees are otherwise invisible: a start + * that failed and left nothing behind is indistinguishable from a start + * that never happened, and a stream that never produced a frame is + * indistinguishable from a stream that is merely quiet. + */ + readonly #diagnostics = { + /** How many eager starts were attempted, and how many of them failed. */ + warmupAttempts: 0, + warmupFailures: 0, + /** How many starts were attempted, and how many of them failed. */ + launchAttempts: 0, + launchFailures: 0, + /** How long every start took, added up. */ + launchTotalDurationMs: 0, + /** The last few attempts, oldest first. */ + launches: [] as WebuiBrowserLaunchRecord[], + /** The last attempt, whatever its outcome. */ + lastLaunch: undefined as WebuiBrowserLaunchRecord | undefined, + /** The failure the last eager start left behind. */ + lastWarmupFailure: undefined as WebuiBrowserFailureRecord | undefined, + /** The last failure of any kind, which the next attempt carries. */ + lastFailure: undefined as WebuiBrowserFailureRecord | undefined, + /** The first frame of the last stream, and how long it took. */ + firstFrame: undefined as + | { + readonly surface: WebuiBrowserSurface; + readonly targetId: string; + readonly latencyMs: number; + readonly at: string; + } + | undefined, + /** How many streams gave up waiting for their first frame. */ + firstFrameTimeouts: 0, + }; + /** The diagnostics write that is in flight. */ + #diagnosticsWrite: Promise = Promise.resolve(); + /** The diagnostics write that is scheduled, if any. */ + #diagnosticsTimer: ReturnType | undefined; /** * The web tabs each session has open, per surface and then per run. * @@ -597,10 +750,18 @@ export class WebuiBrowserService { // Starting eagerly is what makes "turn it on and it works" true. A // failure here is not fatal: the surface stays unauthorized-but-on, and // the next list request reports the provider failure with its own code. + this.#diagnostics.warmupAttempts += 1; try { - await this.#ensureProvider(surface); - } catch { - // Reported by the call that needs the provider. + await this.#ensureProvider(surface, { trigger: "warmup" }); + } catch (error) { + // The failure is not thrown: the switch is on, and the next call + // that needs the browser starts it again. But it must not vanish + // with the catch that swallowed it either. A start that failed and + // left nothing behind is indistinguishable from a start that never + // happened, so the failure is recorded in the session's diagnostics + // and the next attempt carries it — the retry is a visible retry. + this.#recordWarmupFailure(error); + await this.#writeDiagnostics(); } } } @@ -608,6 +769,7 @@ export class WebuiBrowserService { async #ensureProvider( surface: WebuiBrowserSurface, + options: { readonly trigger?: "warmup" | "demand" } = {}, ): Promise { if (this.#closed) return undefined; const existing = this.#providers.get(surface); @@ -642,13 +804,13 @@ export class WebuiBrowserService { const provider = await pending.promise; if (pending.revoked && !this.#closed) { const authorization = await this.readAuthorization(); - if (isBrowserSurfaceUsable(authorization, surface)) return this.#ensureProvider(surface); + if (isBrowserSurfaceUsable(authorization, surface)) return this.#ensureProvider(surface, options); } return provider; } const acquisition: { revoked: boolean; promise: Promise } = { revoked: false, - promise: Promise.resolve().then(() => this.#launchIsolatedProvider(acquisition)).finally(() => { + promise: Promise.resolve().then(() => this.#launchIsolatedProvider(acquisition, options.trigger ?? "demand")).finally(() => { if (this.#acquisition === acquisition) this.#acquisition = undefined; }), }; @@ -656,16 +818,30 @@ export class WebuiBrowserService { return acquisition.promise; } - async #launchIsolatedProvider(acquisition: { revoked: boolean }): Promise { + async #launchIsolatedProvider( + acquisition: { revoked: boolean }, + trigger: "warmup" | "demand", + ): Promise { const surface = "isolated-chromium"; const create = this.#options.createIsolatedProvider ?? ((options: { readonly dataDir: string; readonly chromePath?: string }) => WebuiIsolatedChromiumProvider.launch(options)); - const provider = await create({ - dataDir: this.#options.dataDir, - ...(this.#options.chromePath ? { chromePath: this.#options.chromePath } : {}), - }); + const startedAt = Date.now(); + let provider: WebuiBrowserSurfaceProvider; + try { + provider = await create({ + dataDir: this.#options.dataDir, + ...(this.#options.chromePath ? { chromePath: this.#options.chromePath } : {}), + }); + } catch (error) { + // The browser did not start. The attempt is recorded before the failure + // travels on, so the record of what went wrong outlives the call that + // would otherwise have swallowed it. + await this.#recordLaunch(trigger, "failed", Date.now() - startedAt, error); + throw error; + } + await this.#recordLaunch(trigger, "started", Date.now() - startedAt); let authorization: WebuiBrowserAuthorization; try { authorization = await this.readAuthorization(); @@ -991,6 +1167,9 @@ export class WebuiBrowserService { // sees a picture of a page it did not ask for, not even briefly. if (!this.#frameMatchesTarget(target, frame)) { this.#droppedFrames.set(surface, (this.#droppedFrames.get(surface) ?? 0) + 1); + // The count has an outlet now: it is written into the session's + // diagnostics, where the drop can be read rather than guessed at. + this.#scheduleDiagnostics(); return; } const index = queue.findIndex( @@ -1039,22 +1218,67 @@ export class WebuiBrowserService { // page whoever looks at it. if (viewport) await provider.setViewport?.(targetKey, viewport); if (first) await provider.startImageStream(targetKey, handler); + + // The first frame's deadline. + // + // A stream that never produces a frame is a panel that waits forever, and + // the person cannot tell that apart from a slow page. The clock runs from + // the moment the screencast is asked for, and stops at the first frame + // that reaches the subscriber: a stream that is running is not a stream + // that is stuck, however long it runs. + const firstFrameBudgetMs = + typeof this.#options.firstFrameTimeoutMs === "number" && + this.#options.firstFrameTimeoutMs > 0 + ? this.#options.firstFrameTimeoutMs + : WEBUI_BROWSER_FIRST_FRAME_TIMEOUT_MS; + const firstFrameStartedAt = Date.now(); + let firstFrameSeen = false; + let firstFrameDeadlinePassed = false; + let firstFrameDeadline: ReturnType | undefined; + const firstFrameDeadlinePromise = new Promise((resolve) => { + firstFrameDeadline = setTimeout(() => { + firstFrameDeadlinePassed = true; + resolve(); + }, firstFrameBudgetMs); + }); try { while (!signal?.aborted) { while (queue.length > 0) { const frame = queue.shift(); if (!frame) continue; + if (!firstFrameSeen) { + firstFrameSeen = true; + this.#recordFirstFrame(surface, target, Date.now() - firstFrameStartedAt); + // The deadline is over: the frame arrived, so nothing is waiting + // for one any more. + if (firstFrameDeadline) clearTimeout(firstFrameDeadline); + void this.#writeDiagnostics(); + } yield frame; } + if (!firstFrameSeen && firstFrameDeadlinePassed) { + // The picture is not coming. The stream says so rather than + // waiting: the server turns this into the error frame the client + // is waiting for, with the browser's own timeout code. + this.#diagnostics.firstFrameTimeouts += 1; + await this.#writeDiagnostics(); + throw browserOperationError( + "browser_operation_timeout", + `The browser delivered no frame for "${target.targetId}" within ${firstFrameBudgetMs} ms.`, + true, + ); + } await Promise.race([ new Promise((resolve) => { notify = resolve; }), new Promise((resolve) => setTimeout(resolve, 250)), + firstFrameDeadlinePromise, ]); notify = undefined; } } finally { + if (firstFrameDeadline) clearTimeout(firstFrameDeadline); subscribers.delete(deliver); if (subscribers.size === 0) { // Cancelling the stream releases the provider-side resources it held: @@ -1132,6 +1356,169 @@ export class WebuiBrowserService { return this.#droppedFrames.get(surface) ?? 0; } + /** + * The directory the browser diagnostics are written under. + * + * The host's own directory when it names one, and otherwise a directory + * under the data dir the browser keeps its profile in. + */ + #diagnosticsDirectory(): string { + const configured = process.env.WEBUI_BROWSER_OUTPUT_DIR; + if (typeof configured === "string" && configured.trim().length > 0) return configured; + return join(this.#options.dataDir, WEBUI_BROWSER_DIAGNOSTICS_DIRECTORY); + } + + /** + * The session's diagnostics, as the artifact holds them. + * + * The shape is fixed rather than incidental: a reader who knows the file + * can find the numbers in it without knowing when it was written. + */ + #diagnosticsDocument(): Record { + const droppedFrames: Record = {}; + for (const [surface, count] of this.#droppedFrames) droppedFrames[surface] = count; + return { + schema: "webui.browser.service.diagnostics.v1", + updatedAt: new Date().toISOString(), + dataDir: this.#options.dataDir, + warmup: { + attempts: this.#diagnostics.warmupAttempts, + failures: this.#diagnostics.warmupFailures, + lastFailure: this.#diagnostics.lastWarmupFailure ?? null, + }, + launches: { + attempts: this.#diagnostics.launchAttempts, + failures: this.#diagnostics.launchFailures, + totalDurationMs: this.#diagnostics.launchTotalDurationMs, + last: this.#diagnostics.lastLaunch ?? null, + history: this.#diagnostics.launches, + }, + firstFrame: { + last: this.#diagnostics.firstFrame ?? null, + timeouts: this.#diagnostics.firstFrameTimeouts, + }, + droppedFrames, + }; + } + + /** + * Writes the session's diagnostics. + * + * The write is best-effort: a diagnostics file that cannot be written must + * not stop the browser from being used. The writes are serialized, so two + * events cannot tear the file, and each write holds the state as it was + * when it ran — the last write therefore holds the state as it is. + */ + async #writeDiagnostics(): Promise { + const write = this.#diagnosticsWrite.then( + () => this.#writeDiagnosticsFile(), + () => this.#writeDiagnosticsFile(), + ); + this.#diagnosticsWrite = write.catch(() => undefined); + await write; + } + + /** Writes the diagnostics file itself. */ + async #writeDiagnosticsFile(): Promise { + try { + const directory = this.#diagnosticsDirectory(); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, WEBUI_BROWSER_SERVICE_DIAGNOSTICS_FILE), + `${JSON.stringify(this.#diagnosticsDocument(), null, 2)}\n`, + "utf8", + ); + } catch { + // A diagnostics file that cannot be written is a missing file, not a + // broken browser. + } + } + + /** + * Schedules the diagnostics to be written. + * + * The dropped-frame counter can move on every frame, so its write is + * throttled rather than immediate. Every other event writes at once: those + * are the events somebody is waiting to see. + */ + #scheduleDiagnostics(): void { + if (this.#diagnosticsTimer) return; + const timer = setTimeout(() => { + this.#diagnosticsTimer = undefined; + void this.#writeDiagnostics(); + }, WEBUI_BROWSER_DIAGNOSTICS_WRITE_INTERVAL_MS); + this.#diagnosticsTimer = timer; + // The timer must not hold the process open: a diagnostics write is never + // worth a host that will not shut down. + (timer as { unref?: () => void }).unref?.(); + } + + /** + * Records one attempt to start the isolated browser. + * + * The attempt carries the failure that came before it: a start that + * follows a failed start is a retry, and a retry that leaves no trace of + * what it retries is a start that looks like the first one. + */ + async #recordLaunch( + trigger: "warmup" | "demand", + outcome: "started" | "failed", + durationMs: number, + error?: unknown, + ): Promise { + const failure = outcome === "failed" ? describeBrowserFailure(error) : undefined; + const record: WebuiBrowserLaunchRecord = { + at: new Date().toISOString(), + trigger, + outcome, + durationMs, + failure, + previousFailure: this.#diagnostics.lastFailure, + }; + this.#diagnostics.launchAttempts += 1; + this.#diagnostics.launchTotalDurationMs += durationMs; + if (failure) { + this.#diagnostics.launchFailures += 1; + this.#diagnostics.lastFailure = failure; + } + this.#diagnostics.lastLaunch = record; + this.#diagnostics.launches.push(record); + // The history is a tail, not a ledger: it is here to show the sequence + // of attempts, and the sequence that matters is the recent one. + if (this.#diagnostics.launches.length > 10) this.#diagnostics.launches.shift(); + await this.#writeDiagnostics(); + } + + /** + * Records that the eager start failed. + * + * The eager start is not allowed to fail the switch: the surface stays on, + * and the next call that needs the browser starts it again. But the failure + * must not disappear with the catch that swallowed it. + */ + #recordWarmupFailure(error: unknown): void { + const failure = describeBrowserFailure(error); + this.#diagnostics.warmupFailures += 1; + this.#diagnostics.lastWarmupFailure = failure; + this.#diagnostics.lastFailure = failure; + } + + /** + * Records that a stream delivered its first frame, and how long it took. + */ + #recordFirstFrame( + surface: WebuiBrowserSurface, + target: WebuiBrowserTargetId, + latencyMs: number, + ): void { + this.#diagnostics.firstFrame = { + surface, + targetId: target.targetId, + latencyMs, + at: new Date().toISOString(), + }; + } + /** * Whether a frame still describes the page behind the target. * @@ -1517,9 +1904,32 @@ export class WebuiBrowserService { for (const [surface, provider] of [...this.#providers]) { await this.#stopProvider(surface, provider); } + // The session's record is written out one last time, so the numbers the + // artifact holds are the numbers the session ended with. + if (this.#diagnosticsTimer) { + clearTimeout(this.#diagnosticsTimer); + this.#diagnosticsTimer = undefined; + } + await this.#writeDiagnostics(); } } +/** + * Describes a failure the way the diagnostics record it. + * + * The code is the browser's own when the failure carries one — the same code + * the caller would have been given — and the message is what the failure + * said. A failure that names nothing is still a failure, and is recorded as + * one. + */ +function describeBrowserFailure(error: unknown): WebuiBrowserFailureRecord { + return { + at: new Date().toISOString(), + message: error instanceof Error ? error.message : String(error), + code: error instanceof WebuiBrowserOperationError ? error.code : undefined, + }; +} + function sameTab(left: WebuiBrowserTargetId, right: WebuiBrowserTargetId): boolean { return left.surface === right.surface && left.browserId === right.browserId && left.targetId === right.targetId; } diff --git a/packages/webui/src/runtime/browser/isolated-chromium/provider.ts b/packages/webui/src/runtime/browser/isolated-chromium/provider.ts index c420458bc..030de645c 100644 --- a/packages/webui/src/runtime/browser/isolated-chromium/provider.ts +++ b/packages/webui/src/runtime/browser/isolated-chromium/provider.ts @@ -39,9 +39,10 @@ // frames as the page changes, which is what the panel needs; polling // `Page.captureScreenshot` would be a screenshot, not a live stream. -import { spawn, type ChildProcess } from "node:child_process"; -import { access, mkdir, readFile, rm } from "node:fs/promises"; -import { join } from "node:path"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { constants } from "node:fs"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { BrowserCore, @@ -71,6 +72,33 @@ import { WebuiIsolatedChromiumCoreTransport } from "./core-transport.js"; const STARTUP_TIMEOUT_MS = 30_000; /** How often the endpoint file is polled while the browser starts. */ const STARTUP_POLL_INTERVAL_MS = 100; +/** + * How much of Chrome's own error output is kept. + * + * The stream is unbounded and the end of it is the part that names the + * problem, so the tail is what is kept. + */ +const STDERR_CAPTURE_LIMIT_BYTES = 64 * 1024; +/** How much of that tail is quoted in an error message. */ +const STDERR_MESSAGE_CHARS = 400; +/** + * How long a failure waits for the browser's own error output. + * + * The words are on a stream that may still hold data when the process is + * already gone, so the failure waits for the stream to end before it quotes + * them — but not for ever. + */ +const STDERR_DRAIN_TIMEOUT_MS = 250; +/** How long the browser binary may take to answer `--version`. */ +const CHROME_VERSION_PROBE_TIMEOUT_MS = 5_000; +/** + * How long a failure waits for the version before it is reported. + * + * The version belongs in the account of the failure, but a browser that dies + * has to be reported at once: the probe is therefore given a moment and no + * more. + */ +const VERSION_PROBE_WAIT_MS = 1_000; /** How long a command is retried while the page is still being attached. */ const ATTACHMENT_RETRY_WINDOW_MS = 3_000; /** The delay between attachment retries. */ @@ -245,6 +273,23 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide readonly #core: BrowserCore; #child: ChildProcess | undefined; #connection: WebuiBrowserCdpConnection | undefined; + /** + * The facts of this startup. + * + * A launch that fails says nothing about itself: the process goes away, the + * port file never appears, and the only words the caller gets are "it did + * not start". These are the facts that make such a failure explainable. + */ + readonly #trace = new ChromeStartupTrace(); + /** + * Chrome's own error output. + * + * It is kept rather than printed: the runtime's browser paths keep no logs, + * and this text belongs in a file that says what happened. + */ + readonly #stderr = new StderrTail(); + /** Where the diagnostics of this provider are written. */ + readonly #outputDir: string; /** * The pages this provider drives, by target id. * @@ -274,6 +319,7 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide private constructor(options: WebuiIsolatedChromiumProviderOptions) { this.#options = options; this.#userDataDir = join(options.dataDir, "browser", "isolated-chromium", "profile"); + this.#outputDir = browserDiagnosticsDir(this.#userDataDir); this.browserId = `isolated-chromium-${randomSuffix()}`; this.#core = new BrowserCore({ provider: "webui-isolated-chromium", @@ -299,61 +345,173 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide } async #start(): Promise { - const chromePath = await resolveChromePath(this.#options.chromePath); - await mkdir(this.#userDataDir, { recursive: true, mode: 0o700 }); - const activePortPath = join(this.#userDataDir, "DevToolsActivePort"); - await rm(activePortPath, { force: true }); - - const viewport = this.#options.viewport ?? { width: 1280, height: 800 }; - const child = spawn( - chromePath, - [ - "--headless=new", - "--remote-debugging-port=0", - "--remote-debugging-address=127.0.0.1", - `--user-data-dir=${this.#userDataDir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - "--disable-sync", - "--disable-features=Translate,MediaRouter", - `--window-size=${viewport.width},${viewport.height}`, - "about:blank", - ], - { stdio: ["ignore", "ignore", "ignore"] }, - ); - this.#child = child; - - let endpoint: string; + const trace = this.#trace; + const stderr = this.#stderr; + let child: ChildProcess | undefined; + let versionProbe: Promise | undefined; try { - endpoint = await waitForDevToolsEndpoint(child, activePortPath); + const chromePath = await resolveChromePath(this.#options.chromePath); + trace.setChromePath(chromePath); + trace.mark("chromePathResolved"); + // The version is asked for beside the launch rather than before it: a + // browser that dies has to be reported at once, and a probe that hangs + // must not stand between the failure and the report of it. + versionProbe = probeChromeVersion(chromePath).then((version) => { + trace.setChromeVersion(version); + trace.mark("chromeVersionProbed", { version: version ?? null }); + return version; + }); + + await mkdir(this.#userDataDir, { recursive: true, mode: 0o700 }); + const activePortPath = join(this.#userDataDir, "DevToolsActivePort"); + await rm(activePortPath, { force: true }); + + const viewport = this.#options.viewport ?? { width: 1280, height: 800 }; + child = spawn( + chromePath, + [ + "--headless=new", + "--remote-debugging-port=0", + "--remote-debugging-address=127.0.0.1", + `--user-data-dir=${this.#userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-sync", + "--disable-features=Translate,MediaRouter", + `--window-size=${viewport.width},${viewport.height}`, + "about:blank", + ], + // The browser's own error output is the only thing that says *why* it + // did not start, so it is piped and kept rather than dropped. + { stdio: ["ignore", "ignore", "pipe"] }, + ); + this.#child = child; + trace.mark("spawned", { pid: child.pid ?? null }); + child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)); + // The browser's words arrive on a stream that may still hold data when + // the process is already gone, so a failure waits for the stream to end + // before it quotes them. + const stderrEnded = new Promise((resolve) => { + const stream = child?.stderr; + if (!stream) { + resolve(); + return; + } + stream.once("end", () => resolve()); + stream.once("close", () => resolve()); + stream.once("error", () => resolve()); + }); + const drainStderr = async (): Promise => { + await Promise.race([stderrEnded, delay(STDERR_DRAIN_TIMEOUT_MS)]); + }; + + // A process that cannot be started says so on the `error` event, and an + // event nobody listens for is an uncaught exception that takes the + // process with it. The failure is therefore recorded and turned into a + // browser error like any other. + let spawnError: Error | undefined; + child.on("error", (error: Error) => { + spawnError = error; + trace.mark("spawnError", { message: error.message }); + }); + child.on("exit", (code, signal) => { + trace.mark("exited", { code, signal: signal ?? null }); + }); + + let endpoint: string; + try { + endpoint = await waitForDevToolsEndpoint(child, activePortPath, { + chromePath, + stderr: () => stderr.text(), + spawnError: () => spawnError, + awaitStderr: drainStderr, + onEndpoint: () => trace.mark("devToolsActivePortRead"), + }); + } catch (error) { + child.kill("SIGKILL"); + throw error; + } + + let connection: WebuiBrowserCdpConnection; + try { + connection = await WebuiBrowserCdpConnection.connect(endpoint); + } catch (error) { + // A handshake that fails leaves a browser process that nothing owns. + // That process keeps the profile's single-instance lock, so the next + // launch would fail on the lock rather than on its own problem. + child.kill("SIGKILL"); + throw error; + } + this.#connection = connection; + this.#unsubscribe = connection.onEvent((event) => this.#onEvent(event)); + + const created = await connection.send<{ targetId: string }>("Target.createTarget", { + url: "about:blank", + }); + trace.mark("targetCreated", { targetId: created.targetId }); + this.#launchTargetId = created.targetId; + await this.#attachPage(created.targetId); + + // The panel must also be told about pages created by links using + // `target="_blank"` or `window.open`. Discover only after the launch page + // is attached, then ignore the initial inventory Chromium reports as part + // of enabling discovery. + const initialTargets = await connection.send<{ + targetInfos?: readonly { targetId: string }[]; + }>("Target.getTargets"); + for (const target of initialTargets.targetInfos ?? []) { + this.#targetsBeforeDiscovery.add(target.targetId); + } + await connection.send("Target.setDiscoverTargets", { discover: true }); + trace.mark("started"); } catch (error) { - child.kill("SIGKILL"); + // A failed launch leaves a browser process behind unless it is stopped + // here, and the process would keep the profile locked for the next one. + child?.kill("SIGKILL"); + if (versionProbe) { + await Promise.race([versionProbe, delay(VERSION_PROBE_WAIT_MS)]); + } + trace.setFailure(error); + await this.#flushDiagnostics(); throw error; } + await versionProbe; + await this.#flushDiagnostics(); + } - const connection = await WebuiBrowserCdpConnection.connect(endpoint); - this.#connection = connection; - this.#unsubscribe = connection.onEvent((event) => this.#onEvent(event)); - - const created = await connection.send<{ targetId: string }>("Target.createTarget", { - url: "about:blank", + /** + * Writes what this startup learned to disk. + * + * The runtime's browser paths keep no logs, so these files are the only + * place the facts exist. A file that cannot be written is not a browser + * failure: the person is waiting for a browser, not for a diagnostics file. + */ + async #flushDiagnostics(): Promise { + await writeBrowserDiagnostics({ + outputDir: this.#outputDir, + userDataDir: this.#userDataDir, + artifact: this.#artifact(), + stderr: this.#stderr.text(), }); - this.#launchTargetId = created.targetId; - await this.#attachPage(created.targetId); - - // The panel must also be told about pages created by links using - // `target="_blank"` or `window.open`. Discover only after the launch page - // is attached, then ignore the initial inventory Chromium reports as part - // of enabling discovery. - const initialTargets = await connection.send<{ - targetInfos?: readonly { targetId: string }[]; - }>("Target.getTargets"); - for (const target of initialTargets.targetInfos ?? []) { - this.#targetsBeforeDiscovery.add(target.targetId); - } - await connection.send("Target.setDiscoverTargets", { discover: true }); + } + + /** The structured account of this startup. */ + #artifact(): Record { + return { + kind: "isolated-chromium-startup", + browserId: this.browserId, + generatedAt: new Date().toISOString(), + userDataDir: this.#userDataDir, + outputDir: this.#outputDir, + chromePath: this.#trace.chromePath(), + chromeVersion: this.#trace.chromeVersion() ?? null, + elapsedMs: this.#trace.elapsedMs(), + marks: this.#trace.marks(), + failure: this.#trace.failure(), + stderrTail: summarizeStderr(this.#stderr.text()), + }; } /** @@ -897,6 +1055,9 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide this.#connection?.close(); this.#connection = undefined; await this.#stopBrowser(); + // Chrome's own words about the run are written when the browser is + // stopped, so the file holds everything the browser said before it went. + await this.#flushDiagnostics(); // The profile is **kept**. It is the browser's state — the sessions the // person signed into — and it belongs to this host's data directory like // any other state the host keeps. Deleting it on every close meant that a @@ -1172,12 +1333,19 @@ function randomSuffix(): string { * The platform list is the same one the repository's own headless transport * uses, so the isolated browser and the runtime's browser behave the same on * a given machine. + * + * A candidate has to be *executable*, not merely present: a file that exists + * but cannot be run is a browser that fails to start, and finding it here + * turns that into one clear refusal instead of a launch that dies silently. */ -async function resolveChromePath(explicit?: string): Promise { +export async function resolveChromePath( + explicit?: string, + candidates: readonly string[] = defaultChromeCandidates(), +): Promise { if (explicit?.trim()) return explicit.trim(); - for (const candidate of defaultChromeCandidates()) { + for (const candidate of candidates) { try { - await access(candidate); + await access(candidate, constants.X_OK); return candidate; } catch { // Keep looking. @@ -1189,7 +1357,211 @@ async function resolveChromePath(explicit?: string): Promise { ); } -function defaultChromeCandidates(): readonly string[] { +/** + * Where the startup diagnostics are written. + * + * The environment variable comes first so that a CI run can collect the + * files it produced; without it the facts stay with the profile that made + * them. + */ +export function browserDiagnosticsDir(userDataDir: string): string { + const configured = process.env.WEBUI_BROWSER_OUTPUT_DIR?.trim(); + if (configured) return configured; + return join(userDataDir, "browser-diagnostics"); +} + +/** + * Writes the account of a startup and the browser's own error output. + * + * The browser's output goes to two places: the directory the run collects + * from, and the profile directory beside the browser that produced it. The + * names are fixed, so a run that starts twice leaves two files rather than a + * growing pile. + */ +export async function writeBrowserDiagnostics(options: { + readonly outputDir: string; + readonly userDataDir: string; + readonly artifact: Record; + readonly stderr: string; +}): Promise { + const { outputDir, userDataDir, artifact, stderr } = options; + await writeTextFile( + join(outputDir, "chrome-startup.json"), + `${JSON.stringify(artifact, null, 2)}\n`, + ); + await writeTextFile(join(outputDir, "chrome-stderr.log"), stderr); + await writeTextFile(join(userDataDir, "chrome-stderr.log"), stderr); +} + +async function writeTextFile(path: string, content: string): Promise { + try { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); + } catch { + // A diagnostics file that cannot be written must not fail the browser. + } +} + +/** + * Asks the browser binary for its version. + * + * The version is one of the few facts that can be read without starting the + * browser, and it is the fact that says *which* Chrome failed — two builds + * fail differently. A probe that fails says nothing and is not itself a + * browser failure. + */ +async function probeChromeVersion(chromePath: string): Promise { + return await new Promise((resolve) => { + let settled = false; + const finish = (value: string | undefined): void => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + const child = execFile( + chromePath, + ["--version"], + { + timeout: CHROME_VERSION_PROBE_TIMEOUT_MS, + killSignal: "SIGKILL", + windowsHide: true, + maxBuffer: STDERR_CAPTURE_LIMIT_BYTES, + }, + (error, stdout) => { + if (error) { + finish(undefined); + return; + } + const text = typeof stdout === "string" ? stdout.trim() : ""; + finish(text.length > 0 ? text : undefined); + }, + ); + child.on("error", () => finish(undefined)); + } catch { + finish(undefined); + } + }); +} + +/** + * Flattens the browser's error output into one quotable line. + * + * The tail of the output is kept and the rest is dropped: what a browser + * says last is what names the problem, and an error message that carried a + * whole log would be unreadable. + */ +function summarizeStderr(text: string | undefined): string { + if (!text) return ""; + const flattened = text.replace(/\s+/gu, " ").trim(); + if (!flattened) return ""; + if (flattened.length <= STDERR_MESSAGE_CHARS) return flattened; + return `...${flattened.slice(-STDERR_MESSAGE_CHARS)}`; +} + +/** The browser's own words, quoted after a failure. */ +function stderrClause(text: string | undefined): string { + const summary = summarizeStderr(text); + return summary.length > 0 ? ` Chrome said: ${summary}` : ""; +} + +/** + * The tail of the browser's error output. + * + * Chrome says why it failed on its error stream and nowhere else, and the + * stream is unbounded, so only the end of it is kept. + */ +class StderrTail { + readonly #limit: number; + #chunks: Buffer[] = []; + #bytes = 0; + + constructor(limit: number = STDERR_CAPTURE_LIMIT_BYTES) { + this.#limit = limit; + } + + push(chunk: Buffer | string): void { + const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + this.#chunks.push(buffer); + this.#bytes += buffer.length; + while (this.#chunks.length > 1 && this.#bytes > this.#limit) { + this.#bytes -= this.#chunks[0].length; + this.#chunks.shift(); + } + const head = this.#chunks[0]; + if (head && head.length > this.#limit) { + this.#chunks[0] = head.subarray(head.length - this.#limit); + this.#bytes = this.#chunks.reduce((sum, chunk) => sum + chunk.length, 0); + } + } + + text(): string { + if (this.#chunks.length === 0) return ""; + return Buffer.concat(this.#chunks).toString("utf8"); + } +} + +/** + * The facts of one startup, collected as the browser starts. + * + * The marks are the moments a launch can be read at — the binary that was + * chosen, the process that was started, the endpoint that was published, the + * page that was created — and the elapsed time between them is what says + * where a launch went wrong. + */ +class ChromeStartupTrace { + readonly #startedAt = Date.now(); + readonly #marks: { readonly phase: string; readonly atMs: number; readonly detail?: Record }[] = []; + #chromePath: string | undefined; + #chromeVersion: string | undefined; + #failure: { readonly code: string; readonly message: string } | undefined; + + setChromePath(value: string): void { + this.#chromePath = value; + } + + setChromeVersion(value: string | undefined): void { + this.#chromeVersion = value; + } + + mark(phase: string, detail?: Record): void { + this.#marks.push({ + phase, + atMs: Date.now() - this.#startedAt, + ...(detail ? { detail } : {}), + }); + } + + setFailure(error: unknown): void { + const failure = error as { code?: unknown; message?: unknown } | null; + this.#failure = { + code: typeof failure?.code === "string" ? failure.code : "unknown", + message: typeof failure?.message === "string" ? failure.message : String(error), + }; + } + + chromePath(): string | undefined { + return this.#chromePath; + } + + chromeVersion(): string | undefined { + return this.#chromeVersion; + } + + elapsedMs(): number { + return Date.now() - this.#startedAt; + } + + marks(): readonly { phase: string; atMs: number; detail?: Record }[] { + return this.#marks; + } + + failure(): { code: string; message: string } | undefined { + return this.#failure; + } +} + +export function defaultChromeCandidates(): readonly string[] { if (process.platform === "darwin") { return [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", @@ -1216,6 +1588,20 @@ function defaultChromeCandidates(): readonly string[] { ]; } +/** + * What the wait needs to know about the browser process. + * + * A child process satisfies this, and so does anything that names the way a + * process died: the wait's decisions are about the state of the process, not + * about anything a process can do. + */ +export interface DevToolsEndpointChild { + /** The code the process exited with, or null while it still runs. */ + readonly exitCode: number | null; + /** The signal that killed the process, or null while it still runs. */ + readonly signalCode: NodeJS.Signals | null; +} + /** * Waits for the browser to publish its DevTools endpoint. * @@ -1223,35 +1609,86 @@ function defaultChromeCandidates(): readonly string[] { * rather than scraped from its output: the file is the browser's own * statement of where it is listening, and it avoids mistaking a log line for * an address. + * + * The wait ends early when the browser is already gone. A browser that was + * killed leaves a signal rather than an exit code, and a browser that cannot + * be started reports an error on the process itself; in both cases the wait + * would otherwise spend the whole timeout on a death that has already + * happened and then report a timeout that explains nothing. */ -async function waitForDevToolsEndpoint( - child: ChildProcess, +export async function waitForDevToolsEndpoint( + child: DevToolsEndpointChild, activePortPath: string, - timeoutMs = STARTUP_TIMEOUT_MS, + options: { + /** The binary that was started, for the failure message. */ + readonly chromePath?: string; + /** How long the browser may take to publish its endpoint. */ + readonly timeoutMs?: number; + /** The browser's own error output, quoted in the failure message. */ + readonly stderr?: () => string | undefined; + /** The failure of the process itself, when it could not be started. */ + readonly spawnError?: () => Error | undefined; + /** Waits for the browser's own error output to arrive. */ + readonly awaitStderr?: () => Promise; + /** Called with the endpoint as soon as it has been read. */ + readonly onEndpoint?: (endpoint: string) => void; + } = {}, ): Promise { - const deadline = Date.now() + timeoutMs; + const timeoutMs = options.timeoutMs ?? STARTUP_TIMEOUT_MS; + const startedAt = Date.now(); + const deadline = startedAt + timeoutMs; while (Date.now() < deadline) { + const spawnError = options.spawnError?.(); + if (spawnError) { + await options.awaitStderr?.(); + throw browserOperationError( + "browser_provider_failed", + `The browser could not be started: ${spawnError.message}.${stderrClause(options.stderr?.())}`, + ); + } + if (child.signalCode !== null) { + await options.awaitStderr?.(); + throw browserOperationError( + "browser_provider_failed", + `The browser was killed by signal ${child.signalCode} during startup (exit code ${child.exitCode ?? "none"}) after ${Date.now() - startedAt}ms (chrome: ${options.chromePath ?? "unknown"}).${stderrClause(options.stderr?.())}`, + ); + } if (child.exitCode !== null) { + await options.awaitStderr?.(); throw browserOperationError( "browser_provider_failed", - `The browser exited during startup with code ${child.exitCode}.`, + `The browser exited during startup with code ${child.exitCode} after ${Date.now() - startedAt}ms (chrome: ${options.chromePath ?? "unknown"}).${stderrClause(options.stderr?.())}`, ); } try { const endpoint = parseDevToolsActivePort(await readFile(activePortPath, "utf8")); - if (endpoint) return endpoint; + if (endpoint) { + options.onEndpoint?.(endpoint); + return endpoint; + } } catch { // The browser has not written the file yet. } await delay(STARTUP_POLL_INTERVAL_MS); } + await options.awaitStderr?.(); + const activePortPresent = await fileExists(activePortPath); throw browserOperationError( "browser_operation_timeout", - "The browser did not publish a DevTools endpoint in time.", + `The browser did not publish a DevTools endpoint in time (after ${Date.now() - startedAt}ms; chrome: ${options.chromePath ?? "unknown"}; DevToolsActivePort file: ${activePortPresent ? "present" : "absent"}; exit code: ${child.exitCode ?? "none"}; signal: ${child.signalCode ?? "none"}).${stderrClause(options.stderr?.())}`, true, ); } +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + function parseDevToolsActivePort(raw: string): string | undefined { const [port, path] = raw.trim().split("\n"); if (!port || !path) return undefined; diff --git a/packages/webui/test/integration/browser-panel-live-navigation.test.ts b/packages/webui/test/integration/browser-panel-live-navigation.test.ts index 345d51c6c..1fa82af34 100644 --- a/packages/webui/test/integration/browser-panel-live-navigation.test.ts +++ b/packages/webui/test/integration/browser-panel-live-navigation.test.ts @@ -19,28 +19,11 @@ import type { WebuiBrowserTargetId, WebuiBrowserWebTabSnapshot, } from "../../src/shared/contracts/browser.js"; +import { resolveChromeExecutable } from "./chrome-executable.js"; const TIMEOUT_MS = 30_000; const POLL_MS = 50; -async function resolveChromePath(): Promise { - const { access } = await import("node:fs/promises"); - for (const candidate of [ - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - ]) { - try { - await access(candidate); - return candidate; - } catch { - // Keep looking. - } - } - return undefined; -} - async function startPageServer(respond: (path: string) => string): Promise<{ server: Server; origin: string; @@ -58,7 +41,7 @@ async function startPageServer(respond: (path: string) => string): Promise<{ return { server, origin: `http://127.0.0.1:${address.port}` }; } -const chromeAvailable = (await resolveChromePath()) !== undefined; +const chromeAvailable = (await resolveChromeExecutable()) !== undefined; /** * Whether a failure is the page behind a reference moving on. diff --git a/packages/webui/test/integration/browser-use-real-input.test.ts b/packages/webui/test/integration/browser-use-real-input.test.ts index 624832078..e7a0b5292 100644 --- a/packages/webui/test/integration/browser-use-real-input.test.ts +++ b/packages/webui/test/integration/browser-use-real-input.test.ts @@ -24,6 +24,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { WebuiIsolatedChromiumProvider } from "../../src/runtime/browser/isolated-chromium/provider.js"; import type { WebuiBrowserTargetId } from "../../src/shared/contracts/browser.js"; import { beginAcceptanceSuite, observeAcceptance, restoreAcceptanceRecords } from "../browser/acceptance-record.js"; +import { chromeExecutableVersion, resolveChromeExecutable } from "./chrome-executable.js"; /** The record file this suite writes. */ const SUITE = "isolated-chromium"; @@ -46,48 +47,7 @@ const PAGE_HTML = [ "", ].join("\n"); -async function chromeIsAvailable(): Promise { - return (await resolveChromePath()) !== undefined; -} - -/** The Chrome the isolated provider drives, and its own version. */ -async function resolveChromePath(): Promise { - const { access } = await import("node:fs/promises"); - for (const candidate of [ - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - ]) { - try { - await access(candidate); - return candidate; - } catch { - // Keep looking. - } - } - return undefined; -} - -/** - * The browser's own version. - * - * Read from the binary rather than assumed: an acceptance record that says - * "Chrome" without saying which Chrome is not a record. - */ -async function chromeVersion(path: string): Promise { - const { execFile } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const run = promisify(execFile); - try { - const { stdout } = await run(path, ["--version"], { timeout: 20_000 }); - return stdout.trim(); - } catch { - return "unknown"; - } -} - -const available = await chromeIsAvailable(); +const available = (await resolveChromeExecutable()) !== undefined; describe.skipIf(!available)("the browser input path, against a real page", () => { let dataDir: string; @@ -100,8 +60,8 @@ describe.skipIf(!available)("the browser input path, against a real page", () => beginAcceptanceSuite(SUITE); dataDir = await mkdtemp(join(tmpdir(), "webui-browser-use-e2e-")); provider = await WebuiIsolatedChromiumProvider.launch({ dataDir }); - const path = await resolveChromePath(); - if (path) browserVersion = await chromeVersion(path); + const path = await resolveChromeExecutable(); + if (path) browserVersion = chromeExecutableVersion(path); }, 60_000); afterAll(async () => { diff --git a/packages/webui/test/integration/chrome-executable.ts b/packages/webui/test/integration/chrome-executable.ts new file mode 100644 index 000000000..414edbf8a --- /dev/null +++ b/packages/webui/test/integration/chrome-executable.ts @@ -0,0 +1,49 @@ +// The browser the browser suites drive. +// +// The repository already knows where a browser lives: the provider's own +// candidate table. These suites read that table instead of keeping a copy of +// it, because a copy is a second truth — and a copy that only knows the Linux +// paths skips the whole suite on a Mac, which is where the coverage quietly +// disappears rather than fails. +// +// The suites still skip themselves when there is no browser: "the browser is +// missing" is not a defect in the provider. + +import { execFileSync } from "node:child_process"; +import { constants } from "node:fs"; +import { access } from "node:fs/promises"; + +import { defaultChromeCandidates } from "../../src/runtime/browser/isolated-chromium/provider.js"; + +/** + * The browser on this machine that can actually be run. + * + * A candidate has to be **executable**, not merely present: a directory, or a + * file without the execute bit, is not a browser, and launching it fails + * later with a much less useful message. + */ +export async function resolveChromeExecutable(): Promise { + for (const candidate of defaultChromeCandidates()) { + try { + await access(candidate, constants.X_OK); + return candidate; + } catch { + // Keep looking. + } + } + return undefined; +} + +/** + * The browser's own version. + * + * Read from the binary rather than assumed: an acceptance record that says + * "Chrome" without saying which Chrome is not a record. + */ +export function chromeExecutableVersion(path: string): string { + try { + return execFileSync(path, ["--version"], { encoding: "utf8", timeout: 20_000 }).trim(); + } catch { + return "unknown"; + } +} diff --git a/packages/webui/test/integration/isolated-chromium-profile.test.ts b/packages/webui/test/integration/isolated-chromium-profile.test.ts index 064d7a4cb..e869ece46 100644 --- a/packages/webui/test/integration/isolated-chromium-profile.test.ts +++ b/packages/webui/test/integration/isolated-chromium-profile.test.ts @@ -20,6 +20,7 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { WebuiIsolatedChromiumProvider } from "../../src/runtime/browser/isolated-chromium/provider.js"; +import { resolveChromeExecutable } from "./chrome-executable.js"; /** The cookie the origin hands out. Its name says what it is for. */ const COOKIE_NAME = "mavis-session"; @@ -63,32 +64,6 @@ async function waitFor( ); } -async function chromeIsAvailable(): Promise { - const { access: canAccess } = await import("node:fs/promises"); - const candidates = - process.platform === "darwin" - ? [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - ] - : [ - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - ]; - for (const candidate of candidates) { - try { - await canAccess(candidate); - return true; - } catch { - // Keep looking. - } - } - return false; -} - /** * An origin that hands out a persistent cookie. * @@ -139,7 +114,7 @@ async function visit( ); } -const available = await chromeIsAvailable(); +const available = (await resolveChromeExecutable()) !== undefined; describe.skipIf(!available)("the isolated Chromium's profile", () => { let dataDir: string; diff --git a/packages/webui/test/integration/isolated-chromium-screencast.test.ts b/packages/webui/test/integration/isolated-chromium-screencast.test.ts index 09dc93263..eb557bcc6 100644 --- a/packages/webui/test/integration/isolated-chromium-screencast.test.ts +++ b/packages/webui/test/integration/isolated-chromium-screencast.test.ts @@ -22,6 +22,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { WebuiIsolatedChromiumProvider } from "../../src/runtime/browser/isolated-chromium/provider.js"; import { webuiBrowserPicturePoint } from "../../src/client/features/browser/application/browser-commands.js"; import type { WebuiBrowserImageFrame, WebuiBrowserTargetId } from "../../src/shared/contracts/browser.js"; +import { resolveChromeExecutable } from "./chrome-executable.js"; /** How long the page is given to tick. */ const WATCH_MS = 3_000; @@ -53,25 +54,7 @@ function buttonPageUrl(): string { return `data:text/html,${encodeURIComponent(html)}`; } -async function resolveChromePath(): Promise { - const { access } = await import("node:fs/promises"); - for (const candidate of [ - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - ]) { - try { - await access(candidate); - return candidate; - } catch { - // Keep looking. - } - } - return undefined; -} - -const available = (await resolveChromePath()) !== undefined; +const available = (await resolveChromeExecutable()) !== undefined; describe.skipIf(!available)("the isolated Chromium's screencast", () => { let dataDir: string; diff --git a/packages/webui/test/integration/isolated-chromium-two-pages.test.ts b/packages/webui/test/integration/isolated-chromium-two-pages.test.ts index a66376619..ad61db83a 100644 --- a/packages/webui/test/integration/isolated-chromium-two-pages.test.ts +++ b/packages/webui/test/integration/isolated-chromium-two-pages.test.ts @@ -50,6 +50,7 @@ import type { WebuiBrowserImageFrame, WebuiBrowserTargetId, } from "../../src/shared/contracts/browser.js"; +import { resolveChromeExecutable } from "./chrome-executable.js"; /** A page with a field and a title, so two pages can be told apart. */ function pageHtml(title: string): string { @@ -144,37 +145,7 @@ async function waitFor( ); } -async function chromeIsAvailable(): Promise { - return (await resolveChromePath()) !== undefined; -} - -async function resolveChromePath(): Promise { - const { access } = await import("node:fs/promises"); - const candidates = - process.platform === "darwin" - ? [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - ] - : [ - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - ]; - for (const candidate of candidates) { - try { - await access(candidate); - return candidate; - } catch { - // Keep looking. - } - } - return undefined; -} - -const available = await chromeIsAvailable(); +const available = (await resolveChromeExecutable()) !== undefined; describe.skipIf(!available)("the isolated Chromium, one page per web tab", () => { let dataDir: string; diff --git a/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts b/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts index 841302446..f9291fafd 100644 --- a/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts +++ b/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts @@ -16,8 +16,7 @@ // handler, the input path through the client's own binding, and the provider // itself. A case that only exercised a helper would not be the case. -import { execFileSync } from "node:child_process"; -import { access, mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -46,6 +45,7 @@ import type { WebuiBrowserAuthorization, WebuiBrowserTargetId, } from "../../src/shared/contracts/browser.js"; +import { chromeExecutableVersion, resolveChromeExecutable } from "./chrome-executable.js"; /** This programme's records. They live in its own completion directory. */ const records = acceptanceRecordsFor("workspace-browser-tab"); @@ -66,19 +66,7 @@ const SETTLE_MS = 750; */ const TIMEOUT_MS = 30_000; -async function chromeIsAvailable(): Promise { - for (const candidate of ["/usr/bin/google-chrome", "/usr/bin/chromium"]) { - try { - await access(candidate); - return true; - } catch { - // Keep looking. - } - } - return false; -} - -const available = await chromeIsAvailable(); +const available = (await resolveChromeExecutable()) !== undefined; function pageHtml(title: string): string { return [ @@ -114,13 +102,8 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", }; beforeAll(async () => { - try { - browserVersion = execFileSync("/usr/bin/google-chrome", ["--version"], { - encoding: "utf8", - }).trim(); - } catch { - browserVersion = "unknown"; - } + const chromePath = await resolveChromeExecutable(); + browserVersion = chromePath ? chromeExecutableVersion(chromePath) : "unknown"; dataDir = await mkdtemp(join(tmpdir(), "webui-tab-acceptance-")); service = new WebuiBrowserService({ authorization: { diff --git a/packages/webui/test/unit/browser-panel-view.test.tsx b/packages/webui/test/unit/browser-panel-view.test.tsx index 703b24885..45dd02413 100644 --- a/packages/webui/test/unit/browser-panel-view.test.tsx +++ b/packages/webui/test/unit/browser-panel-view.test.tsx @@ -65,6 +65,19 @@ function loadedState() { return state; } +/** + * A panel whose frame stream gave up. + * + * The state says so the way the service does: a failed event carries the + * code, and the panel shows that it failed rather than showing nothing. + */ +function failedState() { + return reduceWebuiBrowserPanelState(loadedState(), { + type: "failed", + error: { code: "browser_operation_timeout", message: "The browser delivered no frame.", retryable: true }, + }); +} + /** All-optional, like every port in this package: presence is the assertion. */ const wiredPort: BrowserPort = { getBrowserAuthorization: () => Promise.resolve(WEBUI_BROWSER_AUTHORIZATION_DEFAULT), @@ -118,6 +131,25 @@ describe("the Browser panel", () => { expect(source).not.toContain("dangerouslySetInnerHTML"); }); + it("says so when the frame stream failed, and says nothing extra", () => { + // A stream that gave up and a page that is still loading are the same + // picture on screen: an empty placeholder. The failure is what tells them + // apart, so it has to be on the panel rather than only in a toast. + const healthy = renderToStaticMarkup( + , + ); + const failed = renderToStaticMarkup( + , + ); + + expect(healthy).not.toContain("browser-panel-failed"); + expect(failed).toContain('data-testid="browser-panel-failed"'); + // The failure adds that one notice and changes nothing else — the + // picture, the empty placeholder and the rest of the panel are byte for + // byte what they were. A panel that failed must not repaint the world. + expect(failed.replace(/]*data-testid="browser-panel-failed"[^>]*>[^<]*<\/div>/u, "")).toBe(healthy); + }); + it("is the window's page and nothing beside it", () => { // A browser window is a page. A list of the browser's pages beside it is // what made this read as a media panel with a sidebar, and the strip is @@ -426,3 +458,4 @@ describe("the installation key a Settings page mounts", () => { expect(markup).not.toContain('data-testid="browser-installation-key-value"'); }); }); + diff --git a/packages/webui/test/unit/browser-service.test.ts b/packages/webui/test/unit/browser-service.test.ts index 770f07806..50a749e9a 100644 --- a/packages/webui/test/unit/browser-service.test.ts +++ b/packages/webui/test/unit/browser-service.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; import { WebuiBrowserService, @@ -183,6 +187,7 @@ function createService( initial: WebuiBrowserAuthorization, tabIds: readonly string[] = ["tab-1"], edgeProvider?: WebuiBrowserSurfaceProvider, + overrides: Partial = {}, ): { service: WebuiBrowserService; provider: RecordingProvider; @@ -203,6 +208,7 @@ function createService( ...(edgeProvider ? { createBridgeProvider: async (surface: string) => (surface === "edge" ? edgeProvider : undefined) } : {}), + ...overrides, }; return { service: new WebuiBrowserService(options), provider, state }; } @@ -279,7 +285,7 @@ describe("browser service", () => { const starting = new Promise((resolve) => { started = resolve; }); const create = vi.fn(() => { started(); return new Promise((resolve) => { release = resolve; }); }); const authorization = { ...ALL_OFF, isolatedChromium: true }; - const service = new WebuiBrowserService({ authorization: { read: async () => authorization, write: async () => authorization }, dataDir: "unused", createIsolatedProvider: create }); + const service = new WebuiBrowserService({ authorization: { read: async () => authorization, write: async () => authorization }, dataDir: "/tmp/webui-browser-unused-data-dir", createIsolatedProvider: create }); const first = service.listTargets("isolated-chromium"); const second = service.listTargets("isolated-chromium"); await starting; @@ -333,7 +339,7 @@ describe("browser service", () => { let authorization = { ...ALL_OFF, isolatedChromium: true }; const service = new WebuiBrowserService({ authorization: { read: async () => authorization, write: async (input) => (authorization = { ...authorization, ...input }) }, - dataDir: "unused-recording-provider", + dataDir: "/tmp/webui-browser-unused-recording-provider", createIsolatedProvider: create, }); const first = service.listTargets("isolated-chromium"); @@ -1513,3 +1519,203 @@ async function stop( await new Promise((resolve) => setTimeout(resolve, 20)); await service.close(); } +/** + * The failures the service sees must be visible. + * + * A start that failed and left nothing behind is indistinguishable from a + * start that never happened, and a stream that never produced a frame is + * indistinguishable from a stream that is merely quiet. The session's + * diagnostics are where both are written down. + */ +describe("the browser session's diagnostics", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + /** A directory of its own, so the artifact under test is this session's. */ + async function diagnosticsDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "webui-browser-diagnostics-")); + vi.stubEnv("WEBUI_BROWSER_OUTPUT_DIR", directory); + return directory; + } + + /** The artifact the session wrote. */ + async function readArtifact(directory: string): Promise { + const raw = await readFile(join(directory, "service-browser.json"), "utf8"); + return JSON.parse(raw) as BrowserDiagnosticsArtifact; + } + + it("gives up on a stream whose first frame never arrives", async () => { + const directory = await diagnosticsDirectory(); + const { service, provider } = createService( + { ...ALL_OFF, isolatedChromium: true }, + ["tab-1"], + undefined, + // The budget is the service's own; the test shortens the wait so the + // case runs quickly without changing what production waits for. + { firstFrameTimeoutMs: 150 }, + ); + await service.listTargets("isolated-chromium"); + await service.selectTarget(tab("tab-1"), "run-panel"); + + const startedAt = Date.now(); + // The provider never pushes a frame: the panel asked for a picture and + // the browser never sent one. + const failure = await streamFailure(service, tab("tab-1")); + const elapsed = Date.now() - startedAt; + + // The stream ended with the browser's own timeout code, which is what + // the server puts on the wire for the client. + expect(failure).toBeInstanceOf(WebuiBrowserOperationError); + expect((failure as WebuiBrowserOperationError).code).toBe("browser_operation_timeout"); + // The screencast really was running: the deadline ended a live stream, + // it did not stop one from starting. + expect(provider.startedStreams).toContain("tab-1"); + // The stream gave up rather than hanging: it ended at the deadline, and + // it ended at all. + expect(elapsed).toBeGreaterThanOrEqual(140); + expect(elapsed).toBeLessThan(5_000); + // Giving up released what the stream held: the screencast was stopped, + // not left running for a panel that has already been told it failed. + expect(provider.stoppedStreams).toContain("tab-1"); + + const artifact = await readArtifact(directory); + expect(artifact.firstFrame.timeouts).toBe(1); + await service.close(); + }); + + it("records an eager start that could not launch the browser", async () => { + const directory = await diagnosticsDirectory(); + const authorization = { ...ALL_OFF, isolatedChromium: true }; + const service = new WebuiBrowserService({ + authorization: { read: async () => authorization, write: async () => authorization }, + dataDir: "/tmp/webui-browser-warmup-test", + createIsolatedProvider: async () => { + throw new Error("the browser did not start"); + }, + }); + + // The eager start fails and the switch stays on: the failure is not the + // switch's to report, and the switch must not be torn down over it. + await expect(service.initialize()).resolves.toBeUndefined(); + + const artifact = await readArtifact(directory); + expect(artifact.warmup.attempts).toBe(1); + expect(artifact.warmup.failures).toBe(1); + expect(artifact.warmup.lastFailure?.message).toContain("the browser did not start"); + expect(artifact.launches.attempts).toBe(1); + expect(artifact.launches.failures).toBe(1); + expect(artifact.launches.history[0]?.trigger).toBe("warmup"); + expect(artifact.launches.history[0]?.outcome).toBe("failed"); + // Nothing came before the first attempt. + expect(artifact.launches.history[0]?.previousFailure).toBeUndefined(); + + // The next call that needs the browser starts it again, and the retry + // carries the failure that came before it: a retry that leaves no trace + // of what it retries is a start that looks like the first one. + await expect(service.listTargets("isolated-chromium")).rejects.toThrow("the browser did not start"); + const retried = await readArtifact(directory); + expect(retried.launches.attempts).toBe(2); + expect(retried.launches.failures).toBe(2); + expect(retried.launches.history[1]?.trigger).toBe("demand"); + expect(retried.launches.history[1]?.previousFailure?.message).toContain("the browser did not start"); + await service.close(); + }); + + it("records how long the first frame took", async () => { + const directory = await diagnosticsDirectory(); + const { service, provider } = createService({ ...ALL_OFF, isolatedChromium: true }); + await service.listTargets("isolated-chromium"); + + const stream = collectFrames(service, tab("tab-1")); + await waitUntil(() => provider.startedStreams.includes("tab-1")); + provider.pushFrame(1, "tab-1"); + await settleFrames(); + await stop(service, [stream]); + + // The first frame's cost is written down: a picture that took a second + // and a picture that took a quarter of one are not the same thing, and + // only one of them can be read from the record. + const artifact = await readArtifact(directory); + expect(artifact.firstFrame.last?.targetId).toBe("tab-1"); + expect(artifact.firstFrame.last?.surface).toBe("isolated-chromium"); + expect(artifact.firstFrame.last?.latencyMs).toBeGreaterThanOrEqual(0); + expect(artifact.firstFrame.timeouts).toBe(0); + }); + + it("writes the dropped frames into the session's diagnostics", async () => { + const directory = await diagnosticsDirectory(); + const { service, provider } = createService({ ...ALL_OFF, isolatedChromium: true }); + await service.listTargets("isolated-chromium"); + + const stream = collectFrames(service, tab("tab-1")); + await waitUntil(() => provider.startedStreams.includes("tab-1")); + // A frame that names another page is dropped rather than put on screen, + // and the drop is counted. + provider.frameHandlers.get("tab-1")?.({ + target: { ...tab("tab-1"), targetId: "tab-2" }, + data: "QlRSQVk=", + format: "jpeg", + sequence: 1, + }); + await settleFrames(); + expect(service.droppedFrameCount("isolated-chromium")).toBe(1); + await stop(service, [stream]); + + // The count is not a number nobody can reach: it is written down. + const artifact = await readArtifact(directory); + expect(artifact.droppedFrames["isolated-chromium"]).toBe(1); + }); +}); + +/** The shape the session's diagnostics artifact holds. */ +interface BrowserDiagnosticsArtifact { + readonly schema: string; + readonly warmup: { + readonly attempts: number; + readonly failures: number; + readonly lastFailure?: { readonly message: string }; + }; + readonly launches: { + readonly attempts: number; + readonly failures: number; + readonly totalDurationMs: number; + readonly history: readonly { + readonly trigger: string; + readonly outcome: string; + readonly durationMs: number; + readonly failure?: { readonly message: string }; + readonly previousFailure?: { readonly message: string }; + }[]; + }; + readonly firstFrame: { + readonly last?: { + readonly surface: string; + readonly targetId: string; + readonly latencyMs: number; + }; + readonly timeouts: number; + }; + readonly droppedFrames: Record; +} + +/** + * The error a stream ended with, if it ended with one. + * + * A stream that never produces a frame does not end by itself, so the + * failure can only be read by asking the stream for it. + */ +async function streamFailure( + service: WebuiBrowserService, + target: WebuiBrowserTargetId, +): Promise { + try { + for await (const _frame of service.watchFrames("isolated-chromium", target)) { + return undefined; + } + return undefined; + } catch (error) { + return error; + } +} + diff --git a/packages/webui/test/unit/isolated-chromium-startup-diagnostics.test.ts b/packages/webui/test/unit/isolated-chromium-startup-diagnostics.test.ts new file mode 100644 index 000000000..e5f5822d3 --- /dev/null +++ b/packages/webui/test/unit/isolated-chromium-startup-diagnostics.test.ts @@ -0,0 +1,257 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { WebuiBrowserOperationError } from "../../src/runtime/browser/errors.js"; +import { + WebuiIsolatedChromiumProvider, + browserDiagnosticsDir, + resolveChromePath, + waitForDevToolsEndpoint, + writeBrowserDiagnostics, +} from "../../src/runtime/browser/isolated-chromium/provider.js"; + +/** + * A launch that fails used to be silent. + * + * The browser's own error output was dropped, a browser that was killed was + * not noticed, and a browser that could not be started took the process with + * it. The facts below are what the browser said about itself: the message a + * failure carries has to name the browser's own words, and the files it + * leaves behind have to hold them. + * + * The browser here is Node itself: given Chrome's flags it refuses them and + * says so on its error stream, which is exactly the shape of a browser that + * dies during startup — and it behaves the same on every platform. + */ +const FAKE_BROWSER = process.execPath; + +/** The temporary directories this file made, so that they can be removed. */ +const temporaryDirectories: string[] = []; + +afterEach(async () => { + delete process.env.WEBUI_BROWSER_OUTPUT_DIR; + while (temporaryDirectories.length > 0) { + const directory = temporaryDirectories.pop(); + if (directory) await rm(directory, { recursive: true, force: true }); + } +}); + +async function temporaryDirectory(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +describe("isolated Chromium startup diagnostics", () => { + it("reports a browser that was killed by a signal at once", async () => { + const startedAt = Date.now(); + const error = await waitForDevToolsEndpoint( + { exitCode: null, signalCode: "SIGKILL" }, + join(tmpdir(), "webui-never-written-DevToolsActivePort"), + { + timeoutMs: 30_000, + chromePath: "/opt/chrome/chrome", + stderr: () => "FATAL: the profile is locked by another Chrome", + }, + ).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(WebuiBrowserOperationError); + expect((error as WebuiBrowserOperationError).code).toBe("browser_provider_failed"); + const message = (error as Error).message; + expect(message).toContain("SIGKILL"); + expect(message).toContain("exit code none"); + expect(message).toMatch(/after \d+ms/u); + // The browser's own words are quoted, not a bare timeout. + expect(message).toContain("FATAL: the profile is locked by another Chrome"); + // The signal is noticed straight away rather than after the whole + // thirty-second startup budget. + expect(Date.now() - startedAt).toBeLessThan(5_000); + }); + + it("reports a timeout with the facts of the launch", async () => { + const error = await waitForDevToolsEndpoint( + { exitCode: null, signalCode: null }, + join(tmpdir(), "webui-absent-DevToolsActivePort"), + { + timeoutMs: 200, + chromePath: "/opt/chrome/chrome", + stderr: () => "FATAL: profile lock held", + }, + ).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(WebuiBrowserOperationError); + expect((error as WebuiBrowserOperationError).code).toBe("browser_operation_timeout"); + const message = (error as Error).message; + expect(message).toContain("/opt/chrome/chrome"); + expect(message).toContain("DevToolsActivePort file: absent"); + expect(message).toContain("exit code: none"); + expect(message).toContain("signal: none"); + expect(message).toMatch(/after \d+ms/u); + expect(message).toContain("FATAL: profile lock held"); + }); + + it("reads the endpoint the browser published", async () => { + const directory = await temporaryDirectory("webui-chrome-endpoint-"); + const activePortPath = join(directory, "DevToolsActivePort"); + await writeFile(activePortPath, "43123\n/devtools/browser/8f1c-42\n"); + const seen: string[] = []; + + const endpoint = await waitForDevToolsEndpoint( + { exitCode: null, signalCode: null }, + activePortPath, + { + chromePath: "/opt/chrome/chrome", + onEndpoint: (value: string) => seen.push(value), + }, + ); + + expect(endpoint).toBe("ws://127.0.0.1:43123/devtools/browser/8f1c-42"); + expect(seen).toEqual(["ws://127.0.0.1:43123/devtools/browser/8f1c-42"]); + }); + + it("reports a browser that could not be started", async () => { + const error = await waitForDevToolsEndpoint( + { exitCode: null, signalCode: null }, + join(tmpdir(), "webui-never-written-DevToolsActivePort"), + { + timeoutMs: 30_000, + chromePath: "/opt/chrome/chrome", + spawnError: () => new Error("spawn /opt/chrome/chrome ENOENT"), + }, + ).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(WebuiBrowserOperationError); + expect((error as WebuiBrowserOperationError).code).toBe("browser_provider_failed"); + expect((error as Error).message).toContain("spawn /opt/chrome/chrome ENOENT"); + }); + + it("keeps the quoted browser output short", async () => { + const error = await waitForDevToolsEndpoint( + { exitCode: 7, signalCode: null }, + join(tmpdir(), "webui-never-written-DevToolsActivePort"), + { chromePath: "/opt/chrome/chrome", stderr: () => "x".repeat(5_000) }, + ).catch((failure: unknown) => failure); + + const message = (error as Error).message; + expect(message.length).toBeLessThan(800); + expect(message).toContain("..."); + }); + + it("skips a candidate that cannot be executed", async () => { + const directory = await temporaryDirectory("webui-chrome-candidates-"); + const notExecutable = join(directory, "chrome-without-the-executable-bit"); + const executable = join(directory, "chrome"); + await writeFile(notExecutable, "#!/bin/sh\nexit 0\n"); + await writeFile(executable, "#!/bin/sh\nexit 0\n"); + await chmod(executable, 0o755); + await chmod(notExecutable, 0o644); + + if (process.platform === "win32") { + // Windows has no executable bit, so `X_OK` behaves as `F_OK` there and + // the first candidate that exists is the one that is used. + expect(await resolveChromePath(undefined, [notExecutable, executable])).toBe(notExecutable); + return; + } + + expect(await resolveChromePath(undefined, [notExecutable, executable])).toBe(executable); + await expect(resolveChromePath(undefined, [notExecutable])).rejects.toMatchObject({ + code: "browser_provider_unavailable", + }); + }); + + it("quotes the browser's own output when the browser dies during startup", async () => { + const dataDir = await temporaryDirectory("webui-chrome-launch-"); + const outputDir = join(dataDir, "results"); + process.env.WEBUI_BROWSER_OUTPUT_DIR = outputDir; + + const startedAt = Date.now(); + const error = await WebuiIsolatedChromiumProvider.launch({ + dataDir, + chromePath: FAKE_BROWSER, + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(WebuiBrowserOperationError); + expect((error as WebuiBrowserOperationError).code).toBe("browser_provider_failed"); + const message = (error as Error).message; + // The browser's own words, not a bare timeout. + expect(message).toContain("bad option"); + expect(message).toContain(FAKE_BROWSER); + expect(message).toMatch(/after \d+ms/u); + // A browser that dies is noticed at once rather than after the whole + // thirty-second startup budget. + expect(Date.now() - startedAt).toBeLessThan(20_000); + + const artifact = JSON.parse( + await readFile(join(outputDir, "chrome-startup.json"), "utf8"), + ) as { + chromePath?: string; + chromeVersion?: string; + failure?: { code?: string; message?: string }; + stderrTail?: string; + marks?: { phase: string }[]; + }; + expect(artifact.chromePath).toBe(FAKE_BROWSER); + expect(artifact.chromeVersion).toBe(process.version); + expect(artifact.failure?.code).toBe("browser_provider_failed"); + expect(artifact.failure?.message).toContain("bad option"); + expect(artifact.stderrTail).toContain("bad option"); + const phases = (artifact.marks ?? []).map((mark) => mark.phase); + expect(phases).toContain("chromePathResolved"); + expect(phases).toContain("spawned"); + + const stderrLog = await readFile(join(outputDir, "chrome-stderr.log"), "utf8"); + expect(stderrLog).toContain("bad option"); + const profileLog = await readFile( + join(dataDir, "browser", "isolated-chromium", "profile", "chrome-stderr.log"), + "utf8", + ); + expect(profileLog).toContain("bad option"); + }); + + it("turns a browser that cannot be started into a browser error", async () => { + const dataDir = await temporaryDirectory("webui-chrome-missing-"); + process.env.WEBUI_BROWSER_OUTPUT_DIR = join(dataDir, "results"); + + const startedAt = Date.now(); + const error = await WebuiIsolatedChromiumProvider.launch({ + dataDir, + chromePath: join(dataDir, "no-such-chrome"), + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(WebuiBrowserOperationError); + expect((error as WebuiBrowserOperationError).code).toBe("browser_provider_failed"); + expect((error as Error).message).toContain("no-such-chrome"); + expect((error as Error).message).toContain("ENOENT"); + expect(Date.now() - startedAt).toBeLessThan(20_000); + }); + + it("writes the diagnostics where the run collects them", async () => { + const directory = await temporaryDirectory("webui-chrome-diagnostics-"); + const userDataDir = join(directory, "profile"); + const outputDir = join(directory, "results"); + + expect(browserDiagnosticsDir(userDataDir)).toBe(join(userDataDir, "browser-diagnostics")); + process.env.WEBUI_BROWSER_OUTPUT_DIR = outputDir; + expect(browserDiagnosticsDir(userDataDir)).toBe(outputDir); + + await writeBrowserDiagnostics({ + outputDir, + userDataDir, + artifact: { kind: "isolated-chromium-startup" }, + stderr: "FATAL: profile lock held", + }); + + expect( + JSON.parse(await readFile(join(outputDir, "chrome-startup.json"), "utf8")), + ).toEqual({ kind: "isolated-chromium-startup" }); + expect(await readFile(join(outputDir, "chrome-stderr.log"), "utf8")).toBe( + "FATAL: profile lock held", + ); + expect(await readFile(join(userDataDir, "chrome-stderr.log"), "utf8")).toBe( + "FATAL: profile lock held", + ); + }); +}); diff --git a/packages/webui/test/unit/webui-transport-wire.test.ts b/packages/webui/test/unit/webui-transport-wire.test.ts index 3e5e18fd6..ca1ff699a 100644 --- a/packages/webui/test/unit/webui-transport-wire.test.ts +++ b/packages/webui/test/unit/webui-transport-wire.test.ts @@ -654,3 +654,67 @@ describe("an inbound frame is judged before it is believed", () => { await expect(pending).resolves.toEqual({ sessions: [], hasMore: false }); }); }); + +describe("the browser frame connection", () => { + const target = { + surface: "isolated-chromium" as const, + browserId: "browser-1", + targetId: "tab-1", + generation: 1, + }; + + it("reports the service's refusal instead of dropping it", async () => { + // An error frame is the service saying the stream gave up. Dropping it + // leaves the panel waiting for a picture that is never coming, and the + // panel cannot tell "still loading" from "the service stopped" on its + // own — so the refusal has to reach it. + const transport = transportWith(ControllableSocket); + const frames: unknown[] = []; + const failures: Error[] = []; + const stop = transport.watchBrowserFrames( + { surface: "isolated-chromium", target }, + (frame) => frames.push(frame), + (error) => failures.push(error), + ); + await tick(); + const socket = ControllableSocket.latest!; + socket.deliver({ + kind: "error", + requestId: socket.request.requestId, + code: "browser_operation_timeout", + message: "The browser delivered no frame for \"tab-1\" within 60000 ms.", + }); + await tick(); + + expect(frames).toHaveLength(0); + expect(failures).toHaveLength(1); + expect(failures[0]?.message).toContain("delivered no frame"); + // The code is the branchable half; the message carries the budget and + // changes with it. + expect((failures[0] as unknown as { code?: string }).code).toBe("browser_operation_timeout"); + stop(); + }); + + it("keeps delivering frames after the watch was opened", async () => { + const transport = transportWith(ControllableSocket); + const frames: unknown[] = []; + const failures: Error[] = []; + const stop = transport.watchBrowserFrames( + { surface: "isolated-chromium", target }, + (frame) => frames.push(frame), + (error) => failures.push(error), + ); + await tick(); + const socket = ControllableSocket.latest!; + socket.deliver({ + kind: "event", + requestId: socket.request.requestId, + body: { target, data: "QUJD", format: "jpeg", sequence: 1 }, + }); + await tick(); + + expect(frames).toHaveLength(1); + expect(failures).toHaveLength(0); + stop(); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 7b3f13456..2129bfcd0 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -4832,6 +4832,7 @@ "packages/webui/test/integration/browser-panel-live-navigation.test.ts", "packages/webui/test/integration/browser-use-real-input.test.ts", "packages/webui/test/integration/capability-surface-baseline.ts", + "packages/webui/test/integration/chrome-executable.ts", "packages/webui/test/integration/cron-desktop-and-webui.test.ts", "packages/webui/test/integration/isolated-chromium-profile.test.ts", "packages/webui/test/integration/isolated-chromium-screencast.test.ts", @@ -4929,6 +4930,7 @@ "packages/webui/test/unit/home-adoption-transaction.test.ts", "packages/webui/test/unit/interaction-coordinator.test.ts", "packages/webui/test/unit/interaction-session-owners.test.ts", + "packages/webui/test/unit/isolated-chromium-startup-diagnostics.test.ts", "packages/webui/test/unit/markdown-autolink-double-anchor.test.tsx", "packages/webui/test/unit/markdown-codeblock-c1-highlight.test.tsx", "packages/webui/test/unit/markdown-codeblock-c2-line-numbers.test.tsx", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index c0c437f5c..d0e80b2dc 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -560,6 +560,7 @@ "packages/webui/test/unit/browser-web-tabs-operation.test.ts", "packages/webui/test/unit/browser-web-tab-toolbar.test.tsx", "packages/webui/test/unit/browser-frame-stream.test.ts", + "packages/webui/test/unit/isolated-chromium-startup-diagnostics.test.ts", "packages/webui/test/unit/browser-session-release.test.ts", "packages/webui/test/unit/browser-bridge-cdp.test.ts", "packages/webui/test/unit/browser-bridge-adversarial.test.ts",