diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index f7db0af9f..b5d286984 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -167,6 +167,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { ), ); } catch (cause: unknown) { + if (options.signal?.aborted === true) + return err(new AnalysisCancelledError("analyze_web_bundle", { cause })); return err(providerError(cause, "analyze_web_bundle")); } } diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts index ba83482b9..cc7188f86 100644 --- a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -7,6 +7,8 @@ import { } from "../../../src/domain/browserObservation.js"; import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; +import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, @@ -22,6 +24,68 @@ const waitFor = async (ready: () => boolean, description: string) => { }; describe("CdpBrowserProvider: cancellation during cleanup", () => { + it.each([ + { + name: "WebMCP discovery", + operation: "discover_webmcp_tools", + invoke: ( + provider: CdpBrowserProvider, + browser: FakeCdpBrowser, + signal: AbortSignal, + ) => + provider.discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + { signal }, + ), + }, + { + name: "screenshot capture", + operation: "capture_web_screenshot", + invoke: ( + provider: CdpBrowserProvider, + browser: FakeCdpBrowser, + signal: AbortSignal, + ) => + provider.captureScreenshot( + captureWebScreenshotInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + }), + { signal }, + ), + }, + ])( + "reports cancellation during $name cleanup", + async ({ operation, invoke }) => { + const browser = await startFakeCdpBrowser({ + hangOnMethod: "Page.disable", + webMcpTools: true, + }); + trackBrowser(browser); + const controller = new AbortController(); + const pending = invoke( + new CdpBrowserProvider(), + browser, + controller.signal, + ); + await waitFor( + () => browser.commands.some(({ method }) => method === "Page.disable"), + "page cleanup did not start", + ); + controller.abort(); + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation }, + }); + }, + ); + it("reports cancellation when an interrupt arrives during page cleanup", async () => { const browser = await startFakeCdpBrowser({ hangOnMethod: "Network.disable", @@ -111,6 +175,35 @@ describe("CdpBrowserProvider: cancellation during cleanup", () => { }, }); }); + + it("classifies custom source-map abort reasons as cancellation", async () => { + const browser = await startFakeCdpBrowser({ hangSourceMap: true }); + trackBrowser(browser); + const controller = new AbortController(); + const pending = new CdpBrowserProvider().analyzeBundle( + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + fetch_source_maps: true, + }), + { signal: controller.signal }, + ); + await waitFor( + () => + browser.httpRequests.some(({ url }) => url.startsWith("/app.js.map")), + "source-map download did not start", + ); + controller.abort(new Error("stopped")); + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "analyze_web_bundle", + }, + }); + }); }); describe("CdpBrowserProvider: cancellation and session events", () => { diff --git a/tests/boundary/browser/cdpCleanupCancellation.test.ts b/tests/boundary/browser/cdpCleanupCancellation.test.ts index 0d4c17a0f..849531313 100644 --- a/tests/boundary/browser/cdpCleanupCancellation.test.ts +++ b/tests/boundary/browser/cdpCleanupCancellation.test.ts @@ -73,7 +73,13 @@ it("releases cleanup when cancellation arrives after capture completes", async ( }); controller.abort(); const result = await pending; - expect(result.ok).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "observe_web_session", + }, + }); expect(browser.commands.map(({ method }) => method)).toContain( "Target.detachFromTarget", );