diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts index d2cdde1c27..2e5baf7954 100644 --- a/src/application/BrowserObservationService.test.ts +++ b/src/application/BrowserObservationService.test.ts @@ -3,9 +3,13 @@ import type { BrowserObservationPort } from "./BrowserObservationPort.js"; import { listBrowserTargets } from "./BrowserObservationService.js"; import type { BrowserTargetList } from "../domain/browserObservationSchemas.js"; import { parseEvidence } from "../domain/evidence.js"; -import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js"; import { inspectWebPage } from "./BrowserObservationService.js"; import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; @@ -215,3 +219,71 @@ it("preserves a prior caller reason without admitting browser work", async () => expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe(reason); }); + +it("keeps page cancellation when the retained inspection is incomplete", async () => { + const reason = { requested: "stop during page inspection" }; + const partial = { + target: { target_id: "page" }, + network: {}, + capture_window: {}, + }; + const failure = new AnalysisCancelledError("inspect_web_page", { + cause: reason, + partialObservation: partial as never, + }); + const browser = { + ...provider(async () => ok(report)), + inspectPage: async () => err(failure), + } as unknown as BrowserObservationPort; + const pageInput = inspectWebPageInputSchema.parse({ + cdp_endpoint: input.cdp_endpoint.recorded, + allowed_origins: [], + target_id: "page", + observation_ms: 0, + include_script_sources: false, + }); + const result = await inspectWebPage(browser, pageInput); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBe(partial); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { partial_observation: partial }, + }); +}); + +it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => { + const reason = { requested: "stop during bundle analysis" }; + const partial = { observations: [], capture: { status: "partial" } }; + const cancelled = new AnalysisCancelledError("analyze_web_bundle", { + cause: reason, + partialObservation: partial as never, + }); + const timedOut = new BrowserObservationError( + "analyze_web_bundle", + "timeout", + { partialObservation: partial as never }, + ); + const selected = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: input.cdp_endpoint.recorded, + target_id: "page", + observation_ms: 0, + }); + for (const failure of [cancelled, timedOut]) { + const browser = { + ...provider(async () => ok(report)), + analyzeBundle: async () => err(failure), + } as unknown as BrowserObservationPort; + const result = await analyzeWebBundle(browser, selected); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected the owner failure"); + expect(result.error).toBe(failure); + expect(result.error.partialObservation).toBe(partial); + } + expect(projectAnalysisError(cancelled)).toMatchObject({ + code: "cancelled", + details: { partial_observation: partial }, + }); +}); diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 6251e9d4dd..189c71b36a 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -27,7 +27,6 @@ import { createBrowserEvidence } from "./BrowserEvidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { completeProviderEvidence } from "./ProviderOperationExecution.js"; import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; -import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; /** List browser targets within the endpoint and origin scope in the request. */ export const listBrowserTargets = async ( @@ -291,22 +290,7 @@ const retainBrowserObservation = ( "capture" in partial ) { const bundle = webBundleAnalysisSchema.safeParse(partial); - if (!bundle.success) { - const malformed = new AnalysisOutputError( - errorOperation, - "Browser provider retained malformed completed bundle facts.", - { cause: bundle.error }, - ); - return err( - error.cleanupIncomplete - ? analysisErrorWithCleanupFailure( - malformed, - error, - errorOperation, - ).bindCleanupDependencies([malformed, error]) - : malformed, - ); - } + if (!bundle.success) return err(error); error.partialObservation = createBrowserEvidence( "analyze_web_bundle", input, @@ -324,17 +308,7 @@ const retainBrowserObservation = ( ) return err(error); const inspection = webPageInspectionSchema.safeParse(partial); - if (!inspection.success) { - if (error instanceof AnalysisCancelledError) - return err( - new AnalysisOutputError( - errorOperation, - "Browser provider retained a malformed completed page inspection.", - { cause: inspection.error }, - ), - ); - return err(error); - } + if (!inspection.success) return err(error); const pageInput: InspectWebPageInput = "fetch_source_maps" in input ? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input) diff --git a/src/application/BrowserScenarioCaptureService.test.ts b/src/application/BrowserScenarioCaptureService.test.ts index df187682a4..f4beda1580 100644 --- a/src/application/BrowserScenarioCaptureService.test.ts +++ b/src/application/BrowserScenarioCaptureService.test.ts @@ -1,10 +1,18 @@ import { expect, it } from "vitest"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { parseEvidence } from "../domain/evidence.js"; import { browserScenarioInputSchema } from "../domain/browserScenario.js"; -import { err } from "../domain/result.js"; +import type { + BrowserScenarioCapture, + BrowserScenarioPartialObservation, +} from "../domain/browserScenarioCapture.js"; +import { err, ok } from "../domain/result.js"; import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js"; import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js"; import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js"; @@ -103,3 +111,55 @@ it.each([ expect(calls).toBe(0); }, ); + +it("keeps startup cancellation and its incomplete capture", async () => { + const reason = { requested: "stop during initial navigation" }; + const completed = scriptScenarioFixture(); + const { + completeness: _completeness, + steps: _steps, + browser, + ...retained + } = completed; + const partial: BrowserScenarioPartialObservation = { + kind: "browser-scenario-observation", + capture: { + ...retained, + browser: { ...browser, cleanup: "incomplete" }, + steps: [], + }, + }; + const failure = new AnalysisCancelledError("capture_browser_scenario", { + cause: reason, + partialObservation: partial, + }); + const provider: BrowserScenarioCapturePort = { + identity: () => ({ id: "fixture", name: "fixture", version: "1" }), + captureScenario: async () => err(failure), + }; + const result = await captureBrowserScenario(provider, scenario); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBe(partial); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { partial_observation: partial }, + }); +}); + +it("rejects a successful capture that is not a completed scenario", async () => { + const provider: BrowserScenarioCapturePort = { + identity: () => ({ id: "fixture", name: "fixture", version: "1" }), + captureScenario: async () => ok({} as BrowserScenarioCapture), + }; + const result = await captureBrowserScenario(provider, scenario); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a completed-capture refusal"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); + expect(result.error.message).toContain( + "returned a malformed completed capture", + ); + expect(result.error.partialObservation).toBeUndefined(); +}); diff --git a/src/application/BrowserScenarioCaptureService.ts b/src/application/BrowserScenarioCaptureService.ts index 0a5bc26019..d51759228c 100644 --- a/src/application/BrowserScenarioCaptureService.ts +++ b/src/application/BrowserScenarioCaptureService.ts @@ -43,6 +43,8 @@ export const captureBrowserScenario = async ( partial.kind === "browser-scenario-observation" ) { const report = browserScenarioCaptureSchema.safeParse(partial.capture); + // Only a completed capture becomes Evidence. An incomplete observation + // stays on the owner error, including typed cancellation. if ( report.success && report.data.steps.every(({ status }) => status === "completed") @@ -52,16 +54,6 @@ export const captureBrowserScenario = async ( report.data, provider.identity(), ); - } else if (!report.success) { - if (captured.error instanceof AnalysisCancelledError) - return err( - new AnalysisOutputError( - OPERATION, - "Browser scenario provider retained a malformed completed capture.", - { cause: report.error }, - ), - ); - return captured; } } return captured; diff --git a/src/application/WebRuntimeService.test.ts b/src/application/WebRuntimeService.test.ts index 7e7c651486..029f326c0a 100644 --- a/src/application/WebRuntimeService.test.ts +++ b/src/application/WebRuntimeService.test.ts @@ -6,10 +6,14 @@ import { webListenersFixture, } from "./WebRuntimeService.fixture.js"; import { err, ok } from "../domain/result.js"; -import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { parseEvidence } from "../domain/evidence.js"; -import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; it("rejects malformed input and prior cancellation without calling a provider", async () => { const provider = recordingWebRuntimePort(); @@ -161,3 +165,61 @@ it("retains completed execution Evidence on late abort and keeps invalid output }); if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); }); + +it.each([ + { + operation: "observe" as const, + partial: { + instrumentation: { cleanup: "unconfirmed" }, + window: { requested_ms: 5 }, + }, + }, + { + operation: "inspect" as const, + partial: { + selected_node: { selector: "#selected" }, + listeners: [], + }, + }, +])( + "keeps $operation cancellation when the retained observation is incomplete", + async ({ operation, partial }) => { + const reason = { requested: `stop incomplete ${operation}` }; + const failure = new AnalysisCancelledError( + operation === "observe" + ? "observe_web_execution" + : "inspect_web_event_listeners", + { cause: reason, partialObservation: partial as never }, + ); + const service = new WebRuntimeService({ + identity: () => ({ + id: "web-runtime", + name: "Runtime fixture", + version: "1", + }), + observeExecution: async () => err(failure), + inspectEventListeners: async () => err(failure), + }); + const result = + operation === "observe" + ? await service.observe({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + observation_ms: 5, + }) + : await service.inspect({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + selector: "#selected", + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBe(partial); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { partial_observation: partial }, + }); + }, +); diff --git a/src/application/WebRuntimeService.ts b/src/application/WebRuntimeService.ts index 38d2934545..af56acb546 100644 --- a/src/application/WebRuntimeService.ts +++ b/src/application/WebRuntimeService.ts @@ -56,14 +56,7 @@ export class WebRuntimeService { if (evidence.ok) result.error.partialObservation = evidence.value; else if (result.error instanceof AnalysisCancelledError) return evidence; - } else if (result.error instanceof AnalysisCancelledError) - return err( - new AnalysisOutputError( - operation, - "Provider retained a malformed completed execution observation.", - { cause: verifiedPartial.error }, - ), - ); + } } return result; } @@ -118,14 +111,7 @@ export class WebRuntimeService { if (evidence.ok) result.error.partialObservation = evidence.value; else if (result.error instanceof AnalysisCancelledError) return evidence; - } else if (result.error instanceof AnalysisCancelledError) - return err( - new AnalysisOutputError( - operation, - "Provider retained malformed completed event-listener data.", - { cause: verifiedPartial.error }, - ), - ); + } } return result; } diff --git a/src/application/javascript/ElectronObservationService.test.ts b/src/application/javascript/ElectronObservationService.test.ts index 0d055e1f09..bb4eee1233 100644 --- a/src/application/javascript/ElectronObservationService.test.ts +++ b/src/application/javascript/ElectronObservationService.test.ts @@ -5,6 +5,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { parseEvidence } from "../../domain/evidence.js"; import { @@ -244,3 +245,32 @@ it.each(["list", "inspect"] as const)( expect(providerCalls).toBe(0); }, ); + +it("keeps cancellation when the retained Electron inspection is incomplete", async () => { + const reason = { requested: "stop before page inspection completed" }; + const partial = { + kind: "electron-page-observation", + inspection: { target_id: "electron-page" }, + cleanup: "incomplete", + }; + const failure = new AnalysisCancelledError("inspect_electron_page", { + cause: reason, + partialObservation: partial as never, + }); + const provider: ElectronObservationPort = { + identity: () => identity, + listTargets: async () => + err(new AnalysisCancelledError("list_electron_targets")), + inspectPage: async () => err(failure), + }; + const result = await inspectElectronPage(provider, input); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBe(partial); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { partial_observation: partial }, + }); +}); diff --git a/src/application/javascript/ElectronObservationService.ts b/src/application/javascript/ElectronObservationService.ts index 67a0776cb4..bb748455b5 100644 --- a/src/application/javascript/ElectronObservationService.ts +++ b/src/application/javascript/ElectronObservationService.ts @@ -79,17 +79,7 @@ export const inspectElectronPage = async ( partial.kind === "electron-page-observation" ) { const report = electronPageInspectionSchema.safeParse(partial.inspection); - if (!report.success) { - if (result.error instanceof AnalysisCancelledError) - return err( - new AnalysisOutputError( - "inspect_electron_page", - "Electron provider retained a malformed completed page inspection.", - { cause: report.error }, - ), - ); - return result; - } + if (!report.success) return result; const inspection = { ...report.data, limitations: [ diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts index b9351db166..8d83f01dc9 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts @@ -5,6 +5,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { err, ok } from "../../domain/result.js"; import { parseEvidence } from "../../domain/evidence.js"; import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js"; @@ -317,3 +318,32 @@ it.each(["list", "observe"] as const)( expect(providerCalls).toBe(0); }, ); + +it("keeps cancellation when the retained runtime observation is incomplete", async () => { + const reason = { requested: "stop before runtime observation completed" }; + const partial = { runtime: "partial", scripts: [] }; + const failure = new AnalysisCancelledError("observe_javascript_runtime", { + cause: reason, + partialObservation: partial as never, + }); + const provider: JavaScriptRuntimeObservationPort = { + identity: () => ({ id: "test", name: "Test", version: "1" }), + listTargets: async () => + err(new AnalysisCancelledError("list_javascript_runtime_targets")), + observe: async () => err(failure), + }; + const result = await observeJavaScriptRuntime(provider, { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "target", + observation_ms: 0, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBe(partial); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { partial_observation: partial }, + }); +}); diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.ts b/src/application/javascript/JavaScriptRuntimeObservationService.ts index 398995f139..4d8466a120 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.ts @@ -76,14 +76,6 @@ export const observeJavaScriptRuntime = async ( !("kind" in partial) ) { const report = javascriptRuntimeObservationSchema.safeParse(partial); - if (!report.success && result.error instanceof AnalysisCancelledError) - return err( - new AnalysisOutputError( - "observe_javascript_runtime", - "Inspector retained a malformed completed runtime observation.", - { cause: report.error }, - ), - ); if (report.success) result.error.partialObservation = createJavaScriptRuntimeObservationEvidence(