diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts index 61f23afdb4..1181394140 100644 --- a/src/application/BrowserObservationService.test.ts +++ b/src/application/BrowserObservationService.test.ts @@ -8,6 +8,11 @@ import { AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + expectDroppedPartial, + expectKeptPartial, + expectOwnerError, +} from "./ownerObservationExpectation.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { err, ok } from "../domain/result.js"; import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js"; @@ -243,16 +248,7 @@ it("keeps page cancellation when the retained inspection is incomplete", async ( 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(result.error.secondaryFailures).toHaveLength(0); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - details: { partial_observation: partial }, - }); + expectKeptPartial(expectOwnerError(result, failure, reason), partial); }); it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => { @@ -278,16 +274,13 @@ it("keeps bundle cancellation and an earlier failure when retained facts are inc 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(result.error.secondaryFailures).toHaveLength(0); + expectKeptPartial( + expectOwnerError(result, failure, failure.cause), + partial, + failure === timedOut ? "provider_timeout" : "cancelled", + ); } - expect(projectAnalysisError(cancelled)).toMatchObject({ - code: "cancelled", - details: { partial_observation: partial }, - }); + expect(projectAnalysisError(cancelled).code).toBe("cancelled"); }); const pageInput = () => @@ -347,22 +340,12 @@ it.each([ operation === "inspect_web_page" ? await inspectWebPage(browserReturning(failure), pageInput()) : await analyzeWebBundle(browserReturning(failure), bundleInput()); - 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).toBeUndefined(); - expect(result.error.secondaryFailures).toMatchObject([ - { - _tag: "AnalysisOutputError", - operation, - reason: "Browser provider retained a non-JSON incomplete observation.", - }, - ]); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - }); - expect(projectAnalysisError(result.error).details).not.toHaveProperty( + const error = expectOwnerError(result, failure, reason); + expectDroppedPartial( + error, + "Browser provider retained a non-JSON incomplete observation.", + ); + expect(projectAnalysisError(error).details).not.toHaveProperty( "partial_observation", ); }, @@ -387,17 +370,16 @@ it.each([ const failure = new BrowserObservationError("inspect_web_page", "timeout", { partialObservation: partial as never, }); - const result = await inspectWebPage(browserReturning(failure), pageInput()); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("Expected timeout"); - expect(result.error).toBe(failure); - expect(result.error.partialObservation).toBeUndefined(); - expect(result.error.secondaryFailures[0]).toBeInstanceOf( - AnalysisOutputError, + const error = expectOwnerError( + await inspectWebPage(browserReturning(failure), pageInput()), + failure, + undefined, ); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "provider_timeout", - category: "timeout", - }); + expectDroppedPartial( + error, + "Browser provider retained a non-JSON incomplete observation.", + "provider_timeout", + ); + expect(projectAnalysisError(error).category).toBe("timeout"); }, ); diff --git a/src/application/BrowserScenarioCaptureService.test.ts b/src/application/BrowserScenarioCaptureService.test.ts index 77ab83e390..fb4342e3f9 100644 --- a/src/application/BrowserScenarioCaptureService.test.ts +++ b/src/application/BrowserScenarioCaptureService.test.ts @@ -4,7 +4,6 @@ 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"; @@ -16,6 +15,11 @@ 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"; +import { + expectDroppedPartial, + expectKeptPartial, + expectOwnerError, +} from "./ownerObservationExpectation.js"; const scenario = browserScenarioInputSchema.parse({ browser: { mode: "launch", executable_path: "/fixture/chrome" }, @@ -138,16 +142,7 @@ it("keeps startup cancellation and its incomplete capture", async () => { 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(result.error.secondaryFailures).toHaveLength(0); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - details: { partial_observation: partial }, - }); + expectKeptPartial(expectOwnerError(result, failure, reason), partial); }); it.each([{ steps: [undefined] }, { started_at: Number.NaN }])( @@ -167,22 +162,10 @@ it.each([{ steps: [undefined] }, { started_at: Number.NaN }])( 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).toBeUndefined(); - expect(result.error.secondaryFailures).toMatchObject([ - { - _tag: "AnalysisOutputError", - operation: "capture_browser_scenario", - reason: - "Browser scenario provider retained a non-JSON incomplete observation.", - }, - ]); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - }); + expectDroppedPartial( + expectOwnerError(result, failure, reason), + "Browser scenario provider retained a non-JSON incomplete observation.", + ); }, ); diff --git a/src/application/WebRuntimeService.test.ts b/src/application/WebRuntimeService.test.ts index c26b870581..82271332c5 100644 --- a/src/application/WebRuntimeService.test.ts +++ b/src/application/WebRuntimeService.test.ts @@ -13,6 +13,12 @@ import { } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + expectDroppedPartial, + expectKeptPartial, + expectOwnerError, +} from "./ownerObservationExpectation.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { parseEvidence } from "../domain/evidence.js"; it("rejects malformed input and prior cancellation without calling a provider", async () => { @@ -166,6 +172,32 @@ it("retains completed execution Evidence on late abort and keeps invalid output if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); }); +const callRuntime = ( + operation: "observe" | "inspect", + failure: AnalysisError, +) => { + const service = new WebRuntimeService({ + identity: () => ({ + id: "web-runtime", + name: "Runtime fixture", + version: "1", + }), + observeExecution: async () => err(failure), + inspectEventListeners: async () => err(failure), + }); + return operation === "observe" + ? service.observe({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + observation_ms: 5, + }) + : service.inspect({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + selector: "#selected", + }); +}; + it.each([ { operation: "observe" as const, @@ -191,37 +223,10 @@ it.each([ : "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(result.error.secondaryFailures).toHaveLength(0); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - details: { partial_observation: partial }, - }); + expectKeptPartial( + expectOwnerError(await callRuntime(operation, failure), failure, reason), + partial, + ); }, ); @@ -253,38 +258,10 @@ it.each([ : "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).toBeUndefined(); - expect(result.error.secondaryFailures).toMatchObject([ - { _tag: "AnalysisOutputError", reason: outputReason }, - ]); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - }); + expectDroppedPartial( + expectOwnerError(await callRuntime(operation, failure), failure, reason), + outputReason, + ); }, ); @@ -295,27 +272,15 @@ it("drops a non-JSON execution partial and keeps timeout primary", async () => { window: { requested_ms: 5 }, } 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 = await service.observe({ - cdp_endpoint: "http://127.0.0.1:9222", - target_id: "selected-page", - observation_ms: 5, - }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("Expected timeout"); - expect(result.error).toBe(failure); - expect(result.error.partialObservation).toBeUndefined(); - expect(result.error.secondaryFailures[0]).toBeInstanceOf(AnalysisOutputError); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "provider_timeout", - category: "timeout", - }); + const error = expectOwnerError( + await callRuntime("observe", failure), + failure, + undefined, + ); + expectDroppedPartial( + error, + "Provider retained a non-JSON incomplete execution observation.", + "provider_timeout", + ); + expect(projectAnalysisError(error).category).toBe("timeout"); }); diff --git a/src/application/javascript/ElectronObservationService.test.ts b/src/application/javascript/ElectronObservationService.test.ts index 8a73812df3..3f1296e62c 100644 --- a/src/application/javascript/ElectronObservationService.test.ts +++ b/src/application/javascript/ElectronObservationService.test.ts @@ -5,7 +5,6 @@ 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 { @@ -19,6 +18,11 @@ import { inspectElectronPage, listElectronTargets, } from "./ElectronObservationService.js"; +import { + expectDroppedPartial, + expectKeptPartial, + expectOwnerError, +} from "../ownerObservationExpectation.js"; const identity = { id: "electron-fixture", name: "Fixture", version: "1" }; const input = inspectElectronPageInputSchema.parse({ @@ -264,16 +268,7 @@ it("keeps cancellation when the retained Electron inspection is incomplete", asy 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(result.error.secondaryFailures).toHaveLength(0); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - details: { partial_observation: partial }, - }); + expectKeptPartial(expectOwnerError(result, failure, reason), partial); }); it.each([ @@ -300,20 +295,9 @@ it.each([ 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).toBeUndefined(); - expect(result.error.secondaryFailures).toMatchObject([ - { - _tag: "AnalysisOutputError", - operation: "inspect_electron_page", - reason: "Electron provider retained a non-JSON incomplete observation.", - }, - ]); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - }); + expectDroppedPartial( + expectOwnerError(result, failure, reason), + "Electron provider retained a non-JSON incomplete observation.", + ); }, ); diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts index b04da81ae5..0e1776e31c 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts @@ -5,7 +5,6 @@ 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"; @@ -15,6 +14,11 @@ import { listJavaScriptRuntimeTargets, observeJavaScriptRuntime, } from "./JavaScriptRuntimeObservationService.js"; +import { + expectDroppedPartial, + expectKeptPartial, + expectOwnerError, +} from "../ownerObservationExpectation.js"; it("preserves failed Inspector cleanup when cancellation occurs during observation", async () => { const controller = new AbortController(); @@ -337,16 +341,7 @@ it("keeps cancellation when the retained runtime observation is incomplete", asy 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(result.error.secondaryFailures).toHaveLength(0); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - details: { partial_observation: partial }, - }); + expectKeptPartial(expectOwnerError(result, failure, reason), partial); }); it.each([{ scripts: [undefined] }, { elapsed_ms: Number.NaN }])( @@ -368,20 +363,9 @@ it.each([{ scripts: [undefined] }, { elapsed_ms: Number.NaN }])( 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).toBeUndefined(); - expect(result.error.secondaryFailures).toMatchObject([ - { - _tag: "AnalysisOutputError", - operation: "observe_javascript_runtime", - reason: "Inspector retained a non-JSON incomplete observation.", - }, - ]); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - }); + expectDroppedPartial( + expectOwnerError(result, failure, reason), + "Inspector retained a non-JSON incomplete observation.", + ); }, ); diff --git a/src/application/ownerObservationExpectation.ts b/src/application/ownerObservationExpectation.ts new file mode 100644 index 0000000000..d466c57812 --- /dev/null +++ b/src/application/ownerObservationExpectation.ts @@ -0,0 +1,44 @@ +import { expect } from "vitest"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Result } from "../domain/result.js"; + +/** Assert the service returned the same owner error and its cause. */ +export const expectOwnerError = ( + result: Result, + failure: AnalysisError, + cause: unknown, +): AnalysisError => { + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected the owner failure"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(cause); + return result.error; +}; + +/** A JSON-safe incomplete observation stays on the owner error. */ +export const expectKeptPartial = ( + error: AnalysisError, + partial: unknown, + code: "cancelled" | "provider_timeout" = "cancelled", +): void => { + expect(error.partialObservation).toBe(partial); + expect(error.secondaryFailures).toHaveLength(0); + expect(projectAnalysisError(error)).toMatchObject({ + code, + details: { partial_observation: partial }, + }); +}; + +/** A non-JSON partial is cleared and recorded as a secondary output failure. */ +export const expectDroppedPartial = ( + error: AnalysisError, + reason: string, + code: "cancelled" | "provider_timeout" = "cancelled", +): void => { + expect(error.partialObservation).toBeUndefined(); + expect(error.secondaryFailures).toMatchObject([ + { _tag: "AnalysisOutputError", reason }, + ]); + expect(projectAnalysisError(error).code).toBe(code); +};