From 44331d04d20c538f20dfae9e2b64fcf34c5f7386 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:56:39 +0800 Subject: [PATCH] fix(browser): retain launch failure diagnostics (#7826) --- src/browser/PlaywrightScenarioBrowser.test.ts | 34 ++++++++--- src/browser/PlaywrightScenarioBrowser.ts | 29 +++++++++- src/browser/ScenarioCommandDeadline.ts | 4 ++ .../browserScenarioLaunchFailure.test.ts | 57 +++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 tests/acceptance/browser/browserScenarioLaunchFailure.test.ts diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index af1ff1a15b..ba5fda6c7e 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -86,7 +86,9 @@ it("passes the selected environment to the actual browser launch boundary", asyn }, }, }), - ).rejects.toThrow("launch boundary reached"); + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + }); expect(launchedEnvironment).toEqual({ LANG: "selected-language", PATH: "/selected/bin", @@ -126,7 +128,9 @@ it.skipIf(process.platform === "win32")( }, }, ), - ).rejects.toThrow("fake launch boundary reached"); + ).rejects.toMatchObject({ + diagnostics: { startup_error: "fake launch boundary reached" }, + }); expect(launchedExecutable).toBe(selected); expect(selected).toBe(`${root}/link/../browser`); @@ -163,7 +167,9 @@ it("keeps Termux execution support when launching Chromium on Android", async () }, }, }), - ).rejects.toThrow("launch boundary reached"); + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + }); expect(launchOptions?.args).toEqual(["--no-sandbox", "--single-process"]); expect(launchOptions?.env).toEqual({ @@ -705,7 +711,9 @@ it("cancels same-target queued connection before acquiring a browser", async () }); await expect( openPlaywrightScenarioBrowser(launch, environment, { launcher }), - ).rejects.toThrow("launch boundary reached"); + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + }); expect(launchPersistentContext).toHaveBeenCalledTimes(1); if (rejectFirst === undefined) @@ -1054,7 +1062,9 @@ it("applies command_timeout_ms to launch startup", async () => { }, }, ), - ).rejects.toThrow("launch boundary reached"); + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + }); expect(timeout).toBe(4_321); }); @@ -1081,7 +1091,9 @@ it("uses the default command deadline for an omitted launch timeout", async () = }, }, ), - ).rejects.toThrow("launch boundary reached"); + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + }); expect(timeout).toBe(DEFAULT_CDP_COMMAND_TIMEOUT_MS); }); @@ -1399,7 +1411,14 @@ it.each([ }, }, ), - ).rejects.toBe(failure); + ).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + cause: failure, + diagnostics: { + executable_path: process.execPath, + startup_error: failure.message, + }, + }); expect(profilePath).toBeDefined(); await expect(access(profilePath!)).rejects.toMatchObject({ code: "ENOENT", @@ -1444,6 +1463,7 @@ it("normalizes a genuine Playwright launch timeout and releases its profile", as diagnostics: { phase: "launch", command_timeout_ms: 4_321, + executable_path: process.execPath, producer_message: "native launch deadline", }, }); diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index 59c885c182..3df1e7b87b 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -869,8 +869,10 @@ export const openPlaywrightScenarioBrowser = async ( let context: BrowserContext; const executablePath = scenario.browser.executable_path; const headless = scenario.browser.headless; + let launchStarted = false; try { await chmod(profilePath, 0o700); + launchStarted = true; context = await withScenarioCommandDeadline( () => launcher.launchPersistentContext(profilePath, { @@ -902,16 +904,39 @@ export const openPlaywrightScenarioBrowser = async ( }), commandTimeoutMs, "launch", + undefined, + executablePath, ); } catch (cause: unknown) { const cleanup = new AnalysisCleanupLease(async () => { await releaseLateScenarioCommand(cause); await rm(profilePath, { recursive: true, force: true, maxRetries: 3 }); }, ["browser_profile"]); + // Preserve Playwright's launch diagnostics before the generic provider + // boundary discards an untyped error. Keep the original cause for late + // command cleanup and leave typed timeout/cancellation failures intact. + const primary = + launchStarted && !(cause instanceof AnalysisError) + ? new ProviderAdapterError( + PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id, + OPERATION, + { + cause, + userMessage: "The selected browser could not start.", + remediationAction: + "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying.", + diagnostics: { + executable_path: executablePath, + startup_error: + cause instanceof Error ? cause.message : String(cause), + }, + }, + ) + : cause; try { - return await failBrowserScenarioOperation(cleanup, cause); + return await failBrowserScenarioOperation(cleanup, primary); } catch (failure: unknown) { - if (failure !== cause) options.retainCleanup?.(cleanup); + if (failure !== primary) options.retainCleanup?.(cleanup); throw failure; } } diff --git a/src/browser/ScenarioCommandDeadline.ts b/src/browser/ScenarioCommandDeadline.ts index 326bd6f408..0cf4e31250 100644 --- a/src/browser/ScenarioCommandDeadline.ts +++ b/src/browser/ScenarioCommandDeadline.ts @@ -113,6 +113,7 @@ export const withScenarioCommandDeadline = async ( timeoutMs: number, phase: ScenarioCommandDeadlinePhase, method?: string, + launchExecutablePath?: string, ): Promise => { let operation: Promise; try { @@ -131,6 +132,9 @@ export const withScenarioCommandDeadline = async ( diagnostics: { phase, command_timeout_ms: timeoutMs, + ...(launchExecutablePath === undefined + ? {} + : { executable_path: launchExecutablePath }), ...(method === undefined ? {} : { method }), // Discovered CDP URLs can carry transport userinfo in Playwright's // native message. Preserve that cause without projecting it here. diff --git a/tests/acceptance/browser/browserScenarioLaunchFailure.test.ts b/tests/acceptance/browser/browserScenarioLaunchFailure.test.ts new file mode 100644 index 0000000000..3e220768f7 --- /dev/null +++ b/tests/acceptance/browser/browserScenarioLaunchFailure.test.ts @@ -0,0 +1,57 @@ +import { chmod, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform === "win32")( + "retains the selected browser and its startup failure through the CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-browser-launch-"); + const executable = join(root, "selected-browser"); + const secret = "explicit-browser-startup-secret"; + await writeFile( + executable, + `#!/bin/sh\nprintf 'fixture startup failure ${secret}\\n' >&2\nexit 42\n`, + ); + await chmod(executable, 0o700); + for (const [path, marker] of [ + [executable, "fixture startup failure"], + [join(root, "missing-browser"), "doesn't exist"], + ] as const) { + const scenario = { + browser: { mode: "launch", executable_path: path }, + start_url: { url: "http://127.0.0.1:1/" }, + secrets: [ + { + secret_id: "credential", + environment_variable: "BROWSER_FIXTURE_SECRET", + }, + ], + actions: [ + { step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }, + ], + }; + const response = await cli.run({ + arguments: [ + "capture-browser-scenario", + JSON.stringify(scenario), + "--json", + ], + environment: { BROWSER_FIXTURE_SECRET: secret }, + timeoutMs: 20_000, + }); + expect(response.exitCode, response.stdout + response.stderr).toBe(1); + expect(response.stdout).not.toContain(secret); + expect(response.json).toMatchObject({ + code: expect.any(String), + details: { + diagnostics: { + executable_path: path, + startup_error: expect.stringContaining(marker), + }, + }, + }); + } + }, +);