From 966f7cc84b4a8bb0043641cb64d7a7aaf8715e05 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:07:58 +0800 Subject: [PATCH] fix(browser): preserve body API provenance through script export Label new captured response bytes as Playwright body API results and retain the possibility of a credentialed follow-up load. Preserve legacy representations and source limitations through export without treating body-origin uncertainty as missing event coverage. Validated source and filesystem boundary tests plus real Chrome CLI and MCP network workflows covering binary, compressed, empty, unfinished, and same-URL responses and profile cleanup. --- docs/browser-scenario-contract.md | 9 +- docs/website-script-export.md | 16 ++- scripts/lib/browser-network-assertions.mjs | 4 +- .../BrowserScenarioPartialObservation.ts | 117 +++++++++++------- .../PlaywrightBrowserScenarioProvider.test.ts | 65 ++++++++++ src/browser/assets/PublishWebScripts.ts | 20 ++- src/browser/assets/ScenarioScriptCapture.ts | 5 +- .../assets/ScriptCaptureAdapters.test.ts | 2 + .../network/PlaywrightNetworkContent.ts | 4 +- .../network/PlaywrightScenarioNetwork.test.ts | 3 + .../network/PlaywrightScenarioNetwork.ts | 2 +- src/domain/browserNetworkEvidence.test.ts | 6 + src/domain/browserNetworkEvidence.ts | 6 + src/domain/webScriptExport.ts | 10 +- .../filesystem/webScriptExport.test.ts | 93 +++++++++----- 15 files changed, 270 insertions(+), 92 deletions(-) diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 16aaa2f36..b994d2936 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -262,8 +262,13 @@ Capturing more data alone does not establish a website change. states. Captured bodies retain base64 bytes, byte count, media type, and SHA-256 of the retained bytes. Request bytes are those exposed by Playwright; a `not_exposed` request body does not prove absence or complete multipart file -coverage. Response bytes are decoded by the browser, not the compressed wire -representation. No later fetch is substituted for the original response. +coverage. Response bytes are the result of Playwright's `Response.body()` API. +Playwright may satisfy that API with a credentialed follow-up load of the +request URL when CDP has no response body. Event and transaction references +identify the associated Playwright response object, but do not verify that the +retained bytes came from the original network transaction. Successful selected +response bodies include this limitation in the capture. These bytes are not +the compressed wire representation. Headers preserve producer order and duplicates. Authorization, Proxy-Authorization, Cookie, and Set-Cookie values are null with `redacted: true`; ordinary values remain intact. Declared UTF-8 secret values diff --git a/docs/website-script-export.md b/docs/website-script-export.md index 31ffaff45..1af4ad295 100644 --- a/docs/website-script-export.md +++ b/docs/website-script-export.md @@ -48,9 +48,14 @@ its own descriptor. It does not duplicate script bytes. - Passive sources are the Debugger-exposed text encoded as UTF-8, with the verified source digest and source-map declaration retained. Unavailable or explicitly non-JavaScript sources remain unavailable. -- Scenario sources use observed requests classified as `script`, matched to - completed response bytes by transaction and event identity. URLs never select - the response. Binary and empty retained bytes are written exactly. +- Scenario sources use observed requests classified as `script`, associated + with one response event and the bytes returned by Playwright's + `Response.body()` API for its response object. URLs never select the response. + Playwright may load the request URL again with credentials when CDP has no + response body, so transaction and event references do not verify that + exported bytes came from the original network transaction. The capture and + export manifest retain this limitation. Binary and empty retained bytes are + written exactly. - Ordinary query-free `.js`, `.mjs`, and `.cjs` URL paths are arranged under a directory for each origin, preserving their relative module layout when it is unambiguous. @@ -62,8 +67,9 @@ its own descriptor. It does not duplicate script bytes. ## Interpretation and limits Export does not fetch URLs or execute JavaScript. Retained Debugger sources and -response bytes do not establish execution or a complete website. Response bytes -are browser-decoded bytes; they do not represent transfer encoding. Declared +response bytes do not establish execution or a complete website. Scenario +response bytes preserve Playwright's body API result; see the source-body +identity limitation above. Declared redaction is preserved and can change JavaScript syntax; passive captures do not report a byte-redaction flag, so that value remains unknown. diff --git a/scripts/lib/browser-network-assertions.mjs b/scripts/lib/browser-network-assertions.mjs index 7f99ca0b5..015a71854 100644 --- a/scripts/lib/browser-network-assertions.mjs +++ b/scripts/lib/browser-network-assertions.mjs @@ -117,7 +117,7 @@ export function assertBrowserNetworkEvidence( assert.deepEqual(retained(responseFor("/compressed")), networkFixtureDecoded); assert.equal( responseFor("/compressed").body.representation, - "browser-decoded-response-bytes", + "playwright-response-body-bytes", ); const formRequest = requestEvents.find( (event) => event.url.url === `${origin}/form`, @@ -149,7 +149,7 @@ export function assertBrowserNetworkEvidence( return { same_url_markers: markers, binary_bytes: networkFixtureBinary.length, - compressed_representation: "browser-decoded-response-bytes", + compressed_representation: "playwright-response-body-bytes", unfinished_stream: "response-unfinished", empty_response_state: responseFor("/empty").body.state, }; diff --git a/src/browser/BrowserScenarioPartialObservation.ts b/src/browser/BrowserScenarioPartialObservation.ts index 23408f410..1fd395a64 100644 --- a/src/browser/BrowserScenarioPartialObservation.ts +++ b/src/browser/BrowserScenarioPartialObservation.ts @@ -1,9 +1,13 @@ import type { BrowserScenario } from "../domain/browserScenario.js"; +import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../domain/browserNetworkEvidence.js"; import type { BrowserScenarioCaptureData, BrowserScenarioPartialObservation, } from "../domain/browserScenarioCapture.js"; -import type { BrowserScenarioStep } from "../domain/browserScenarioCaptureValues.js"; +import type { + BrowserScenarioEvent, + BrowserScenarioStep, +} from "../domain/browserScenarioCaptureValues.js"; import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { @@ -20,26 +24,42 @@ const OPERATION = "capture_browser_scenario" as const; const browserScenarioLimitations = ( session: BrowserScenarioSessionPort, scenario: BrowserScenario, -): string[] => [ - "Event sequence records provider receipt order; simultaneous browser causality is not inferred.", - "Network content is retained only when selected; response bytes are browser-decoded, not compressed wire bytes.", - "Request bytes are limited to what Playwright exposes; not_exposed does not establish body absence or multipart file coverage.", - "Network content reads settle within 5 seconds each and never wait for unfinished responses or refetch them.", - "Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.", - "Storage values are hashed only after declared-secret redaction.", - ...(scenario.storage.local_storage.length === 0 && - scenario.storage.session_storage.length === 0 - ? [] - : [ - "Non-cookie storage seeds use CDP pre-script instrumentation; only pauses in the scenario's private seed script are resumed by this observer.", - ]), - ...(session.eventLimitations?.() ?? []), - ...(session.mode === "connect" - ? [ - "CDP attachment cannot recover pre-attach events or guarantee launch-time context options.", - ] - : []), -]; + events: readonly BrowserScenarioEvent[], +): string[] => { + const capturedResponseBody = + scenario.capture.network.response_body && + events.some( + (event) => + event.kind === "network-content" && + event.phase === "response" && + event.body.state === "captured" && + (event.body.representation === "playwright-response-body-bytes" || + event.body.representation === "browser-decoded-response-bytes"), + ); + return [ + "Event sequence records provider receipt order; simultaneous browser causality is not inferred.", + "Network content is retained only when selected; Playwright response-body bytes are API results and are not guaranteed to match wire bytes.", + "Request bytes are limited to what Playwright exposes; not_exposed does not establish body absence or multipart file coverage.", + "Network content reads settle within 5 seconds each and the observer does not request bodies from unfinished responses.", + ...(capturedResponseBody + ? [PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION] + : []), + "Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.", + "Storage values are hashed only after declared-secret redaction.", + ...(scenario.storage.local_storage.length === 0 && + scenario.storage.session_storage.length === 0 + ? [] + : [ + "Non-cookie storage seeds use CDP pre-script instrumentation; only pauses in the scenario's private seed script are resumed by this observer.", + ]), + ...(session.eventLimitations?.() ?? []), + ...(session.mode === "connect" + ? [ + "CDP attachment cannot recover pre-attach events or guarantee launch-time context options.", + ] + : []), + ]; +}; /** Project one session into the common capture fields used by success and failure. */ export const browserScenarioCaptureData = (input: { @@ -52,30 +72,37 @@ export const browserScenarioCaptureData = (input: { | "disconnected-external" | "incomplete"; readonly limitations?: readonly string[]; -}): BrowserScenarioCaptureData => ({ - browser: { - mode: input.session.mode, - process_ownership: input.session.processOwnership, - cleanup: input.cleanup, - product: input.session.product, - version: input.session.version, - }, - scenario: { - start_origin: new URL(input.scenario.start_url.url).origin, - action_count: input.scenario.actions.length, - secret_references: input.scenario.secrets - .map(({ secret_id: id }) => id) - .sort(), - network_content: input.scenario.capture.network, - }, - duration_ms: Date.now() - input.startedAt, - steps: [...input.steps], - events: input.session.events(), - limitations: [ - ...browserScenarioLimitations(input.session, input.scenario), - ...(input.limitations ?? []), - ], -}); +}): BrowserScenarioCaptureData => { + const events = input.session.events(); + return { + browser: { + mode: input.session.mode, + process_ownership: input.session.processOwnership, + cleanup: input.cleanup, + product: input.session.product, + version: input.session.version, + }, + scenario: { + start_origin: new URL(input.scenario.start_url.url).origin, + action_count: input.scenario.actions.length, + secret_references: input.scenario.secrets + .map(({ secret_id: id }) => id) + .sort(), + network_content: input.scenario.capture.network, + }, + duration_ms: Date.now() - input.startedAt, + steps: [...input.steps], + events, + limitations: [ + ...browserScenarioLimitations( + input.session, + input.scenario, + events.items, + ), + ...(input.limitations ?? []), + ], + }; +}; /** Describe the cleanup failure without discarding its resource evidence. */ export const browserScenarioCleanupObservation = ( diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts index af94b7f90..6d4e284db 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts @@ -16,6 +16,7 @@ import { type BrowserScenarioEvent, type BrowserStepArtifacts, } from "../domain/browserScenarioCaptureValues.js"; +import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../domain/browserNetworkEvidence.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisCancelledError, @@ -218,6 +219,70 @@ class FakeSession implements BrowserScenarioSessionPort { } describe("PlaywrightBrowserScenarioProvider", () => { + it("reports response-body provenance uncertainty without marking event coverage missing", async () => { + const session = new FakeSession("launch"); + const selected = scenario(); + session.eventItems = [ + { + sequence: 1, + step_index: 0, + kind: "response", + transaction_id: "request-1", + redirected_from_transaction_id: null, + method: "GET", + url: sanitizeBrowserUrl("https://app.example.test/main.js"), + resource_type: "script", + header_names: [], + status: 200, + failure: null, + }, + { + sequence: 2, + step_index: 0, + kind: "network-content", + transaction_id: "request-1", + source_event_sequence: 1, + phase: "response", + headers: { state: "not_requested" }, + body: { + state: "captured", + representation: "playwright-response-body-bytes", + encoding: "base64", + content: "", + bytes: 0, + sha256: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + media_type: "text/javascript", + redacted: false, + }, + }, + ]; + const input = browserScenarioSchema.parse({ + ...selected, + capture: { + ...selected.capture, + network: { ...selected.capture.network, response_body: true }, + }, + }); + const provider = new PlaywrightBrowserScenarioProvider(() => + Promise.resolve(session), + ); + + const result = await provider.captureScenario(input); + if (!result.ok) throw result.error; + expect(result.value.limitations).toContain( + PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION, + ); + expect(result.value.completeness.missing_sections).not.toContain("events"); + expect(result.value.events.items[1]).toMatchObject({ + kind: "network-content", + body: { + state: "captured", + representation: "playwright-response-body-bytes", + }, + }); + }); + it("makes reported event gaps ineligible for equality even in launch mode", async () => { const session = new FakeSession("launch"); session.eventGaps = ["Popup frames before discovery are unavailable"]; diff --git a/src/browser/assets/PublishWebScripts.ts b/src/browser/assets/PublishWebScripts.ts index 5d6bac53c..2db5d7dcf 100644 --- a/src/browser/assets/PublishWebScripts.ts +++ b/src/browser/assets/PublishWebScripts.ts @@ -7,6 +7,7 @@ import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js" import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js"; +import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../domain/browserNetworkEvidence.js"; import { webScriptExportManifestSchema, webScriptExportResultSchema, @@ -26,7 +27,7 @@ const LIMITATIONS = [ "Ordinary unambiguous URL paths preserve relative module layouts by origin, including case-distinct spellings. Query variants, exact path collisions, file/directory prefixes, inline sources, and unrepresentable paths are isolated. A case-distinct pair is isolated only when the destination filesystem cannot store both spellings; the record names that pair. Browser module relationships of isolated paths remain unknown.", "Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.", "Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.", - "Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.", + "Scenario response bytes preserve the output of Playwright's Response.body() API; any source-body provenance caveat from the capture is retained in this manifest. Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.", ]; interface PublishWebScriptsOptions { @@ -43,6 +44,22 @@ export const publishWebScripts = async ( ): Promise => options.resources.run(async () => { const planned = planWebScriptExport(capture.scripts); + const scenarioResponseBodyCaptured = + capture.kind === "browser-scenario" && + capture.scripts.some( + (script) => + script.source.kind === "scenario-response" && + script.content.state === "captured" && + (script.content.representation === "playwright-response-body-bytes" || + script.content.representation === "browser-decoded-response-bytes"), + ); + const responseBodyProvenanceLimitations = + scenarioResponseBodyCaptured && + !capture.limitations.includes( + PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION, + ) + ? [PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION] + : []; let tree: SafeOutputTree | undefined; let result: WebScriptExportResult | undefined; try { @@ -70,6 +87,7 @@ export const publishWebScripts = async ( scripts: records, limitations: [ ...capture.limitations, + ...responseBodyProvenanceLimitations, ...LIMITATIONS, ...(records.some(({ content }) => content.state === "exported") ? [] diff --git a/src/browser/assets/ScenarioScriptCapture.ts b/src/browser/assets/ScenarioScriptCapture.ts index 642dd01f9..9bb08fbc9 100644 --- a/src/browser/assets/ScenarioScriptCapture.ts +++ b/src/browser/assets/ScenarioScriptCapture.ts @@ -98,7 +98,10 @@ const projectBody = ( body.state, "No selected, completed script response bytes were retained for this request.", ); - if (body.representation !== "browser-decoded-response-bytes") + if ( + body.representation !== "playwright-response-body-bytes" && + body.representation !== "browser-decoded-response-bytes" + ) return unavailable( "unsupported-body-representation", `Captured representation ${body.representation} is not script response bytes.`, diff --git a/src/browser/assets/ScriptCaptureAdapters.test.ts b/src/browser/assets/ScriptCaptureAdapters.test.ts index 6cb010fee..7e5996186 100644 --- a/src/browser/assets/ScriptCaptureAdapters.test.ts +++ b/src/browser/assets/ScriptCaptureAdapters.test.ts @@ -152,9 +152,11 @@ describe("script capture identity and response projection", () => { ) throw new Error("Missing response bytes"); content.body.redacted = true; + content.body.representation = "playwright-response-body-bytes"; expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({ state: "captured", redacted: true, + representation: "playwright-response-body-bytes", }); content.body.representation = "browser-exposed-request-bytes"; expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({ diff --git a/src/browser/network/PlaywrightNetworkContent.ts b/src/browser/network/PlaywrightNetworkContent.ts index f74fbd3b9..9a35d179f 100644 --- a/src/browser/network/PlaywrightNetworkContent.ts +++ b/src/browser/network/PlaywrightNetworkContent.ts @@ -72,7 +72,7 @@ export const captureNetworkHeaders = async ( } }; -/** Retain only bytes exposed by the browser; no fetch or wire-byte reconstruction occurs. */ +/** Retain bytes returned by Playwright's body API, after declared-secret redaction. */ export const captureNetworkBody = async ( read: () => Promise, phase: "request" | "response", @@ -94,7 +94,7 @@ export const captureNetworkBody = async ( representation: phase === "request" ? "browser-exposed-request-bytes" - : "browser-decoded-response-bytes", + : "playwright-response-body-bytes", encoding: "base64", content: retained.toString("base64"), bytes: retained.length, diff --git a/src/browser/network/PlaywrightScenarioNetwork.test.ts b/src/browser/network/PlaywrightScenarioNetwork.test.ts index 80ea58317..0c67c7c3a 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.test.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.test.ts @@ -178,6 +178,9 @@ it("associates out-of-order same-URL responses by request identity", async () => (event) => event.phase === "response" && bodyText(event) === `reply:${marker}`, ); + expect(response).toMatchObject({ + body: { representation: "playwright-response-body-bytes" }, + }); expect(request?.transaction_id).toBe(response?.transaction_id); expect(request?.source_event_sequence).not.toBe( response?.source_event_sequence, diff --git a/src/browser/network/PlaywrightScenarioNetwork.ts b/src/browser/network/PlaywrightScenarioNetwork.ts index ca2b22517..45129cb95 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.ts @@ -128,7 +128,7 @@ export class PlaywrightScenarioNetwork { transaction.response = { source: response, sequence }; } - /** Record completion and read the original completed response, never a later refetch. */ + /** Record completion and read selected content through the response API. */ finished(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); diff --git a/src/domain/browserNetworkEvidence.test.ts b/src/domain/browserNetworkEvidence.test.ts index df2afe1d0..c46915540 100644 --- a/src/domain/browserNetworkEvidence.test.ts +++ b/src/domain/browserNetworkEvidence.test.ts @@ -17,6 +17,12 @@ const retained = { it("validates the actual retained bytes against canonical base64, count, and digest", () => { expect(browserNetworkBodySchema.safeParse(retained).success).toBe(true); + expect( + browserNetworkBodySchema.safeParse({ + ...retained, + representation: "playwright-response-body-bytes", + }).success, + ).toBe(true); for (const mutation of [ { bytes: 4 }, { sha256: "0".repeat(64) }, diff --git a/src/domain/browserNetworkEvidence.ts b/src/domain/browserNetworkEvidence.ts index bf6adf3e0..08df3aaaf 100644 --- a/src/domain/browserNetworkEvidence.ts +++ b/src/domain/browserNetworkEvidence.ts @@ -1,6 +1,9 @@ import { createHash } from "node:crypto"; import { z } from "zod"; +export const PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION = + "Captured response-body bytes are the result of Playwright's Response.body() API. Playwright may satisfy that API with a credentialed follow-up load of the request URL when CDP has no body, so transaction and event links do not verify that retained bytes came from the original network transaction."; + /** Resolved network content selection recorded by a capture. */ export const browserNetworkContentSelectionValuesSchema = z.strictObject({ request_body: z.boolean(), @@ -43,6 +46,9 @@ export const browserNetworkBodySchema = z.union([ state: z.literal("captured"), representation: z.enum([ "browser-exposed-request-bytes", + "playwright-response-body-bytes", + // Retained for captures produced before response-body provenance was + // narrowed to the bytes Playwright exposes through Response.body(). "browser-decoded-response-bytes", ]), encoding: z.literal("base64"), diff --git a/src/domain/webScriptExport.ts b/src/domain/webScriptExport.ts index a5524a110..68788b9fc 100644 --- a/src/domain/webScriptExport.ts +++ b/src/domain/webScriptExport.ts @@ -52,6 +52,9 @@ export const exportedWebScriptSchema = z.strictObject({ redacted: z.boolean().nullable(), representation: z.enum([ "debugger-source-utf8", + "playwright-response-body-bytes", + // Accept already-saved exports from before the response representation + // was narrowed to the Playwright API result. "browser-decoded-response-bytes", ]), }), @@ -116,9 +119,10 @@ export interface CapturedWebScript { readonly sha256: string; readonly media_type: string | null; readonly redacted: boolean | null; - readonly representation: - | "debugger-source-utf8" - | "browser-decoded-response-bytes"; + readonly representation: Extract< + ExportedWebScript["content"], + { readonly state: "exported" } + >["representation"]; } | Extract; } diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 7dcf0f68f..c624ba7a2 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -32,7 +32,11 @@ import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutput import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; -import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js"; +import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../../src/domain/browserNetworkEvidence.js"; +import { + webScriptExportManifestSchema, + webScriptExportResultSchema, +} from "../../../src/domain/webScriptExport.js"; import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { @@ -137,43 +141,72 @@ describe("captured script publication boundary", () => { ); }); - it("links authenticated input Evidence and preserves binary and empty source bytes", async () => { + it("links authenticated input Evidence", async () => { const { input, resources } = await setup(); const result = await exportWebScripts(input, resources); if (!result.ok) throw result.error; expect(result.value.evidence_links).toEqual([ scriptCaptureEvidenceFixture().evidence_id, ]); - const binary = Buffer.from([255, 0, 254, 1]); - const { input: binaryInput, resources: binaryResources } = await setup( - scriptScenarioFixture([ + }); + + it.each([ + "playwright-response-body-bytes", + "browser-decoded-response-bytes", + ] as const)( + "preserves binary and empty response bytes and provenance for %s", + async (representation) => { + const binary = Buffer.from([255, 0, 254, 1]); + const capture = scriptScenarioFixture([ { url: "https://fixture.test/binary.js", bytes: binary }, { url: "https://fixture.test/empty.js", bytes: Buffer.alloc(0) }, - ]), - ); - const binaryExport = await exportWebScripts(binaryInput, binaryResources); - if (!binaryExport.ok) throw binaryExport.error; - const parsed = webScriptExportResultSchema.parse( - binaryExport.value.normalized_result, - ); - const [first, second] = parsed.scripts; - if ( - first?.content.state !== "exported" || - second?.content.state !== "exported" || - parsed.analysis_input === null - ) - throw new Error("Missing exported bytes"); - expect( - await readFile( - join(parsed.analysis_input.input_path, first.content.relative_path), - ), - ).toEqual(binary); - expect( - await readFile( - join(parsed.analysis_input.input_path, second.content.relative_path), - ), - ).toHaveLength(0); - }); + ]); + for (const event of capture.events.items) { + if (event.kind === "network-content" && event.body.state === "captured") + event.body.representation = representation; + } + const { input: binaryInput, resources: binaryResources } = + await setup(capture); + const binaryExport = await exportWebScripts(binaryInput, binaryResources); + if (!binaryExport.ok) throw binaryExport.error; + const parsed = webScriptExportResultSchema.parse( + binaryExport.value.normalized_result, + ); + const [first, second] = parsed.scripts; + if ( + first?.content.state !== "exported" || + second?.content.state !== "exported" || + parsed.analysis_input === null + ) + throw new Error("Missing exported bytes"); + expect(first.content.representation).toBe(representation); + expect(second.content.representation).toBe(representation); + expect(parsed.limitations).toContain( + PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION, + ); + const persisted = webScriptExportManifestSchema.parse( + JSON.parse(await readFile(parsed.manifest.path, "utf8")), + ); + expect( + persisted.scripts.map(({ content }) => + content.state === "exported" ? content.representation : undefined, + ), + ).toEqual([representation, representation]); + expect(persisted.limitations).toContain( + PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION, + ); + expect( + await readFile( + join(parsed.analysis_input.input_path, first.content.relative_path), + ), + ).toEqual(binary); + expect( + await readFile( + join(parsed.analysis_input.input_path, second.content.relative_path), + ), + ).toHaveLength(0); + }, + ); it("retains missing sources in a manifest without inventing an analysis directory", async () => { const capture = scriptScenarioFixture();