From 30fef57f75db07d00b99cb9ce90c2bf61aec3d75 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:26:39 +0800 Subject: [PATCH] fix(browser): identify scenario timeouts by Playwright error type --- src/browser/PlaywrightScenarioBrowser.test.ts | 82 +++++++++++++++++++ src/browser/ScenarioCommandDeadline.ts | 12 ++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 64ec2f34a3..f2f46d8ba2 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -1322,3 +1322,85 @@ it("restores an environment mutation that completes after its deadline", async ( ); expect(browser.close).toHaveBeenCalledOnce(); }); + +it.each([ + new Error("Timeout unrelated launch diagnostic"), + Object.assign(new Error("unrelated launch failure"), { + name: "TimeoutError", + }), +])( + "preserves a launch failure without inferring a Playwright deadline: %s", + async (failure) => { + const scenario = browserScenarioSchema.parse({ + browser: { + mode: "launch", + executable_path: process.execPath, + headless: true, + }, + start_url: { url: "https://example.test" }, + command_timeout_ms: 4_321, + actions: scenarioActions, + }); + let profilePath: string | undefined; + await expect( + openPlaywrightScenarioBrowser( + scenario, + {}, + { + launcher: { + connectOverCDP: async () => { + throw new Error("Unexpected connection"); + }, + launchPersistentContext: async (profile) => { + profilePath = profile; + throw failure; + }, + }, + }, + ), + ).rejects.toBe(failure); + expect(profilePath).toBeDefined(); + await expect(access(profilePath!)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it("normalizes a genuine Playwright launch timeout and releases its profile", async () => { + const { errors } = await import("playwright-core"); + const failure = new errors.TimeoutError("native launch deadline"); + const scenario = browserScenarioSchema.parse({ + browser: { + mode: "launch", + executable_path: process.execPath, + headless: true, + }, + start_url: { url: "https://example.test" }, + command_timeout_ms: 4_321, + actions: scenarioActions, + }); + let profilePath: string | undefined; + await expect( + openPlaywrightScenarioBrowser( + scenario, + {}, + { + launcher: { + connectOverCDP: async () => { + throw new Error("Unexpected connection"); + }, + launchPersistentContext: async (profile) => { + profilePath = profile; + throw failure; + }, + }, + }, + ), + ).rejects.toMatchObject({ + reason: "timeout", + userMessage: expect.stringContaining("4321 ms command_timeout_ms"), + cause: failure, + }); + expect(profilePath).toBeDefined(); + await expect(access(profilePath!)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/src/browser/ScenarioCommandDeadline.ts b/src/browser/ScenarioCommandDeadline.ts index a2c04fb8ca..37fe3cfde8 100644 --- a/src/browser/ScenarioCommandDeadline.ts +++ b/src/browser/ScenarioCommandDeadline.ts @@ -1,3 +1,4 @@ +import { createRequire } from "node:module"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { browserDeadlineRetry, @@ -23,9 +24,14 @@ export const scenarioCommandTimeoutMs = ( scenario: Pick, ): number => selectedCdpCommandTimeoutMs(scenario.command_timeout_ms); -const playwrightTimedOut = (cause: unknown): cause is Error => - cause instanceof Error && - (cause.name === "TimeoutError" || cause.message.startsWith("Timeout ")); +const playwrightTimedOut = (cause: unknown): cause is Error => { + if (!(cause instanceof Error) || cause.name !== "TimeoutError") return false; + // Check the public producer type without loading Playwright at MCP startup. + const { errors } = createRequire(import.meta.url)( + "playwright-core", + ) as typeof import("playwright-core"); + return cause instanceof errors.TimeoutError; +}; /** * `capture-browser-scenario` accepts only scenario JSON, so recovery names