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.
This commit is contained in:
parent
6bae774328
commit
966f7cc84b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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<WebScriptExportResult> =>
|
||||
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")
|
||||
? []
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<Buffer | null>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<ExportedWebScript["content"], { state: "unavailable" }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue