diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts index 6b6ed4fbc2..1cd7eb2609 100644 --- a/src/application/BrowserObservationService.test.ts +++ b/src/application/BrowserObservationService.test.ts @@ -11,6 +11,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; +import { resolvedAnalysisError } from "../domain/analysisErrorBase.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { expectDroppedPartial, @@ -150,6 +151,49 @@ it("converts a raw page inspection retained after browser cleanup failure", asyn } }); +it.each([true, false])( + "promotes cleanup primary facts once when wrapper retains them: %s", + async (wrapperRetainsFacts) => { + const inspection = passiveNetworkCapture(); + const primary = new AnalysisCancelledError("inspect_web_page", { + partialObservation: inspection, + }); + const failure = new BrowserObservationError( + "inspect_web_page", + "cleanup_failed", + { + cleanupPrimary: primary, + cleanup: { + reason: "target detach failed", + resources: ["browser target"], + }, + ...(wrapperRetainsFacts ? { partialObservation: inspection } : {}), + }, + ); + const browser = { + ...provider(async () => ok(report)), + inspectPage: async () => err(failure), + } as unknown as BrowserObservationPort; + const result = await inspectWebPage(browser, pageInput()); + expect(result.ok).toBe(false); + expect(parseEvidence(primary.partialObservation)).toMatchObject({ + operation: "inspect_web_page", + normalized_result: inspection, + }); + if (wrapperRetainsFacts) + expect(failure.partialObservation).toBe(primary.partialObservation); + failure.resolveCleanup(); + expect(resolvedAnalysisError(failure)).toBe(primary); + expect( + parseEvidence( + projectAnalysisError(resolvedAnalysisError(failure)).details?.[ + "partial_observation" + ], + ), + ).toEqual(primary.partialObservation); + }, +); + it("retains captured page Evidence when bundle analysis fails after capture", async () => { const inspection = passiveNetworkCapture(); const failure = new BrowserObservationError( diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 5508a9186e..2d61e6e649 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -288,44 +288,71 @@ const retainBrowserObservation = ( provider: BrowserObservationPort, errorOperation: "inspect_web_page" | "analyze_web_bundle", ): Result => { - const partial = error.partialObservation; + const seen = new Set(); + const promoted = new Map(); + let current: AnalysisError | undefined = error; + while (current !== undefined && !seen.has(current)) { + seen.add(current); + const partial = current.partialObservation; + if (partial !== undefined) { + const evidence = + promoted.get(partial) ?? + promoteBrowserObservation(partial, input, provider, errorOperation); + if (evidence !== undefined) { + promoted.set(partial, evidence); + current.partialObservation = evidence; + } + } + current = current.cleanupPrimary; + } + return err( + keepIncompleteObservation( + error, + errorOperation, + NON_JSON_BROWSER_OBSERVATION, + ), + ); +}; + +/** Each cleanup owner can become the selected error after cleanup succeeds. */ +const promoteBrowserObservation = ( + partial: NonNullable, + input: InspectWebPageInput | AnalyzeWebBundleInput, + provider: BrowserObservationPort, + errorOperation: "inspect_web_page" | "analyze_web_bundle", +): Evidence | undefined => { if ( errorOperation === "analyze_web_bundle" && - partial !== undefined && "kind" in partial && partial.kind === "web-bundle-acquisition" ) { const acquisition = webBundleAcquisitionPartialObservationSchema.safeParse(partial); if (acquisition.success) { - error.partialObservation = createBrowserEvidence( + return createBrowserEvidence( "analyze_web_bundle", input, acquisition.data, provider.identity(), ); - return err(error); } } if ( errorOperation === "analyze_web_bundle" && - partial !== undefined && !("evidence_id" in partial) && "observations" in partial && "capture" in partial ) { const bundle = webBundleAnalysisSchema.safeParse(partial); if (bundle.success) { - error.partialObservation = createBrowserEvidence( + return createBrowserEvidence( "analyze_web_bundle", input, bundle.data, provider.identity(), ); - return err(error); } } else if ( - partial !== undefined && !("evidence_id" in partial) && "target" in partial && "network" in partial && @@ -337,20 +364,13 @@ const retainBrowserObservation = ( "fetch_source_maps" in input ? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input) : input; - error.partialObservation = createBrowserEvidence( + return createBrowserEvidence( "inspect_web_page", pageInput, inspection.data, provider.identity(), ); - return err(error); } } - return err( - keepIncompleteObservation( - error, - errorOperation, - NON_JSON_BROWSER_OBSERVATION, - ), - ); + return undefined; };