mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
feat(browser): import saved Playwright v10 trace evidence (#9127)
* feat(browser): import saved Playwright v10 trace evidence * fix(browser): preserve original Playwright ZIP member names
This commit is contained in:
1 parent
9044b6fb36
commit
985b9eae96
35 files changed
+3469
-28
No files matched your search
@@ -2138,3 +2138,12 @@ skill and portable conformance projections in the `generated-docs` artifact.
|
||||
These outputs describe the exact source revision being built; they are not
|
||||
checked-in snapshots. For a running server, `binary_session` remains the
|
||||
authoritative source of catalog identity and tool availability.
|
||||
|
||||
## Offline Playwright traces
|
||||
|
||||
`inspect_playwright_trace` reads a local v10 trace ZIP and defaults to a retained
|
||||
summary. `playwright-trace` views select source-located action boundaries, console
|
||||
and network facts, resource references and diagnostics; long text has bounded
|
||||
previews and character pages. Import does not contact recorded URLs or use a
|
||||
browser. See [offline Playwright traces](./playwright-traces.md) for coverage,
|
||||
resource policies and the shared CLI workflow.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Offline Playwright traces
|
||||
|
||||
Use `inspect_playwright_trace` or `inspect-playwright-trace` with an existing
|
||||
local Playwright **trace v10** ZIP. Import is offline: recorded URLs are not
|
||||
requested, and no browser, application or trace viewer is started.
|
||||
|
||||
```sh
|
||||
rea inspect-playwright-trace /absolute/trace.zip --json
|
||||
rea inspect-playwright-trace /absolute/trace.zip --detail summary \
|
||||
--evidence_output /absolute/new-evidence.bundle.json --json
|
||||
```
|
||||
|
||||
MCP defaults to a compact summary and retains complete supported Evidence.
|
||||
CLI defaults to complete delivery; summary and selected pages require an absent
|
||||
`--evidence_output` destination, so later queries can read the original parent.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_playwright_trace",
|
||||
"arguments": {
|
||||
"path": "/captures/trace.zip",
|
||||
"view": {
|
||||
"kind": "playwright-trace",
|
||||
"collection": "events",
|
||||
"kinds": ["network"],
|
||||
"fields": ["method", "url", "status"],
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`view` takes precedence over `detail`. The retained parent can be queried with
|
||||
`inspect_analysis_view`; CLI can use the same request against an exported
|
||||
Evidence bundle with `inspect-analysis-view`.
|
||||
|
||||
## Facts, selection and text
|
||||
|
||||
The `playwright-trace` view offers:
|
||||
|
||||
| Collection | Available selection |
|
||||
| ------------- | ---------------------------------------------------------------------- |
|
||||
| `events` | Batch event/stream/call IDs, event kinds, fields, row offset and limit |
|
||||
| `streams` | Batch stream IDs, fields and row pages |
|
||||
| `members` | Batch member IDs, exact archive paths, fields and row pages |
|
||||
| `diagnostics` | Codes, fields and row pages |
|
||||
| `event-text` | One event ID, UTF-16 character offset and limit |
|
||||
|
||||
An omitted filter selects all retained rows; an explicit empty filter selects
|
||||
none. Minimal event rows keep event/stream IDs, source member/line, producer
|
||||
ordinal, kind and credential-redaction status. Page exhaustion describes only
|
||||
the selected facts and never establishes complete recording coverage.
|
||||
|
||||
Default event pages return at most 256 UTF-16 code units in `text_preview`,
|
||||
with explicit text coverage. Continue from that preview's `next_offset` using
|
||||
`collection: "event-text"`, its `event_id`, `offset`, and a character `limit`
|
||||
(default 2,048). Character offsets do not split surrogate pairs. Explicit
|
||||
`fields: ["text"]` returns the complete interpreted text value; `reported`
|
||||
returns the retained supported payload and sidecars remain selectable.
|
||||
Complete Evidence remains accessible through ordinary bundle operations.
|
||||
|
||||
## Source and coverage
|
||||
|
||||
Archive SHA-256 and directory occurrence bind stable member identity. Each event
|
||||
retains its member ID, physical one-based NDJSON line and zero-based physical
|
||||
line ordinal. Headline times retain producer monotonic milliseconds, without
|
||||
aligning stream clocks. Counts derive from the retained supported records. Call IDs are producer values; they do not establish unique actions
|
||||
across streams or imply a successfully completed before/after pair. There is no
|
||||
inferred total ordering or clock alignment between `.trace` and `.network`.
|
||||
|
||||
Member `path` preserves the reader's original ZIP name; `logical_path` separately
|
||||
records its normalized interpretation. Resource references, exact `paths` filters
|
||||
and trace/network profile pairing use original names, so `resources/x` and
|
||||
`./resources/x` remain distinct. Reader limitations remain attached to each member
|
||||
and keep source interpretation partial when present.
|
||||
|
||||
`decoder_profile` names the selected interpretation policy, not a verified source version.
|
||||
Trace members require an initial numeric v10 context header. Other declared
|
||||
versions are refused. Missing, malformed, repeated or ambiguous profiles remain
|
||||
unknown; network members require one unambiguous paired trace profile. Valid
|
||||
siblings of malformed records stay useful, with explicit diagnostics. Unsafe
|
||||
numeric values are not rounded: exact literals stay in sidecars.
|
||||
|
||||
Interpreted facts cover action boundaries (class/method, title, call/page IDs,
|
||||
selected navigation URL/selector, recorded times and available error text),
|
||||
console text/location, network metadata and screencast references. Other action
|
||||
arguments, results, context options, DOM, stacks, unsupported event types and
|
||||
resource contents are outside this version's interpretation. These omissions
|
||||
are scope limitations, not assertions that omitted values are secrets or absent.
|
||||
Original archive bytes remain the complete raw source.
|
||||
|
||||
Known HAR transport credentials and structured network, navigation and console-location
|
||||
URL userinfo are excluded using the existing capture projection. Ordinary names, query values, console text and caller data
|
||||
are preserved. Network numeric/redaction pointers are relative to `reported`;
|
||||
combine them with the event source's `record_pointer` (`/snapshot`) to locate the
|
||||
original NDJSON value. Resource-reference pointers already address the physical
|
||||
record.
|
||||
|
||||
Resource references distinguish `present-unread`, `missing`, `ambiguous` and
|
||||
`unavailable`. Presence means directory metadata only: screenshots and bodies are
|
||||
not read or validated. Member `declared_bytes` and `declared_compressed_bytes`
|
||||
come from the ZIP directory; they are not measurements of unread payloads.
|
||||
Only decoded stream members have verified ZIP CRC and
|
||||
size. Duplicate names keep separate occurrence IDs; no filename silently picks
|
||||
a resource. Recording completeness always remains unknown, including after all
|
||||
retained pages have been read.
|
||||
|
||||
## Bounds and verification
|
||||
|
||||
Each decoder admits one archive import at a time; concurrent imports receive a
|
||||
retryable capacity error. Retained queries do not require another import.
|
||||
Resource policies admit a 256-MiB archive, 32 MiB of decoded streams, a
|
||||
conservative 32-MiB retained-facts representation, 4-MiB metadata reads and
|
||||
4-MiB physical lines. JSON parsing additionally checks heap headroom, a
|
||||
131,072-node record allowance and the shared 64-level capture nesting budget.
|
||||
These are resource policies, not Playwright format limits. Paging or field
|
||||
selection does not increase parsing allowances; choose a smaller complete
|
||||
recorder chunk when admission fails.
|
||||
|
||||
The owned fixtures in `tests/fixtures/playwright-trace/` preserve actual
|
||||
Playwright 1.64.1 v10 recorder text. Public CLI/MCP tests exercise advertised
|
||||
schemas, retained and bundle queries without the source file, partial facts,
|
||||
credential exclusion, bounded Unicode previews, and a loopback listener that
|
||||
must receive no import-time requests. Recorder/version/platform compatibility
|
||||
outside this verified profile is unverified.
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { PlaywrightTrace } from "../../domain/playwrightTrace/playwrightTrace.js";
|
||||
|
||||
/** Producer parsing and archive ownership stay with the offline format adapter. */
|
||||
export interface PlaywrightTracePort {
|
||||
inspect(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<PlaywrightTrace, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
primaryAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { inspectPlaywrightTraceInputSchema } from "../../domain/playwrightTrace/playwrightTraceQuery.js";
|
||||
import {
|
||||
PLAYWRIGHT_TRACE_OPERATION as operation,
|
||||
PLAYWRIGHT_TRACE_PROVIDER,
|
||||
playwrightTraceSchema,
|
||||
type PlaywrightTrace,
|
||||
} from "../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { parseAnalysisInput } from "../AnalysisInput.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import type { PlaywrightTracePort } from "./PlaywrightTracePort.js";
|
||||
|
||||
const evidence = (report: PlaywrightTrace) =>
|
||||
createEvidence(
|
||||
{
|
||||
path: report.artifact.path,
|
||||
format: "file",
|
||||
sha256: report.artifact.sha256,
|
||||
},
|
||||
PLAYWRIGHT_TRACE_PROVIDER,
|
||||
{
|
||||
operation,
|
||||
predicateType: "rea.playwright-trace-inventory",
|
||||
parameters: {
|
||||
path: report.artifact.path,
|
||||
decoder_profile: "playwright-trace-v10",
|
||||
},
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
limitations: report.limitations,
|
||||
locations: [{ kind: "artifact-path", path: report.artifact.path }],
|
||||
},
|
||||
);
|
||||
/** One Evidence workflow serves CLI and MCP; delivery selection never changes source identity. */
|
||||
export class PlaywrightTraceService {
|
||||
constructor(readonly port: PlaywrightTracePort) {}
|
||||
async inspect(raw: unknown, signal?: AbortSignal) {
|
||||
const parsed = parseAnalysisInput(
|
||||
operation,
|
||||
raw,
|
||||
inspectPlaywrightTraceInputSchema,
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (!isAbsolute(parsed.value.path))
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem trace archive path.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const loaded = await this.port.inspect(parsed.value.path, signal);
|
||||
if (!loaded.ok) {
|
||||
const source =
|
||||
loaded.error.partialObservation ??
|
||||
primaryAnalysisError(loaded.error).partialObservation;
|
||||
const partial = playwrightTraceSchema.safeParse(source);
|
||||
if (partial.success) {
|
||||
const projected = evidence(partial.data);
|
||||
let current: AnalysisError | undefined = loaded.error;
|
||||
while (current !== undefined) {
|
||||
if (
|
||||
current.partialObservation === source ||
|
||||
current.partialObservation === undefined
|
||||
)
|
||||
current.partialObservation = projected;
|
||||
current = current.cleanupPrimary;
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
return completeProviderEvidence(evidence(loaded.value), signal);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,43 @@ type Frame =
|
||||
}
|
||||
| { kind: "array"; pointer: string; items: unknown[] };
|
||||
|
||||
/** Admit each retained parser node before its object or numeric wrapper is allocated. */
|
||||
const captureNodeAdmission = (maximumNodes: number | undefined) => {
|
||||
let nodes = 0;
|
||||
return (): void => {
|
||||
nodes++;
|
||||
if (maximumNodes !== undefined && nodes > maximumNodes)
|
||||
throw new CaptureFormatError(
|
||||
"input-limit",
|
||||
"JSON exceeds its parser node representation allowance.",
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const selectCaptureObjectProperty = (
|
||||
frames: Frame[],
|
||||
key: string,
|
||||
format: string,
|
||||
): void => {
|
||||
const frame = frames.at(-1);
|
||||
if (frame?.kind !== "object")
|
||||
throw new TypeError("JSON visitor emitted a property outside an object.");
|
||||
if (frame.members.has(key))
|
||||
throw new CaptureFormatError(
|
||||
"format",
|
||||
`${format} JSON contains duplicate object members.`,
|
||||
);
|
||||
frame.pendingKey = key;
|
||||
};
|
||||
|
||||
/** Materialize upstream JSON events without duplicate folding, prototype setters or numeric rounding. */
|
||||
export const parseCaptureJson = (
|
||||
text: string,
|
||||
format: "HAR" | "qlog",
|
||||
format: "HAR" | "qlog" | "Playwright trace",
|
||||
maximumNodes?: number,
|
||||
): unknown => {
|
||||
const frames: Frame[] = [];
|
||||
const admitNode = captureNodeAdmission(maximumNodes);
|
||||
let result: unknown;
|
||||
const valuePointer = (): string => {
|
||||
const parent = frames.at(-1);
|
||||
@@ -33,6 +64,7 @@ export const parseCaptureJson = (
|
||||
// its own level stays within the budget; values inside an admitted
|
||||
// container add no level, so empty and scalar-bearing containers agree.
|
||||
const admitContainer = (): void => {
|
||||
admitNode();
|
||||
if (frames.length >= WEB_NETWORK_CAPTURE_LIMITS.depth)
|
||||
throw new CaptureFormatError(
|
||||
"input-limit",
|
||||
@@ -53,6 +85,14 @@ export const parseCaptureJson = (
|
||||
delete parent.pendingKey;
|
||||
}
|
||||
};
|
||||
const endContainer = (kind: "object" | "array"): void => {
|
||||
const frame = frames.pop();
|
||||
if (frame?.kind !== kind)
|
||||
throw new TypeError(`JSON visitor ended an unexpected ${kind}.`);
|
||||
append(
|
||||
frame.kind === "object" ? Object.fromEntries(frame.members) : frame.items,
|
||||
);
|
||||
};
|
||||
visit(
|
||||
text,
|
||||
{
|
||||
@@ -65,38 +105,17 @@ export const parseCaptureJson = (
|
||||
});
|
||||
},
|
||||
onObjectProperty: (key) => {
|
||||
// Prototype-named members are kept as ordinary own properties: the
|
||||
// members map keys them without prototype setters, and
|
||||
// Object.fromEntries materializes them as own data properties.
|
||||
const frame = frames.at(-1);
|
||||
if (frame?.kind !== "object")
|
||||
throw new TypeError(
|
||||
"JSON visitor emitted a property outside an object.",
|
||||
);
|
||||
if (frame.members.has(key))
|
||||
throw new CaptureFormatError(
|
||||
"format",
|
||||
`${format} JSON contains duplicate object members.`,
|
||||
);
|
||||
frame.pendingKey = key;
|
||||
},
|
||||
onObjectEnd: () => {
|
||||
const frame = frames.pop();
|
||||
if (frame?.kind !== "object")
|
||||
throw new TypeError("JSON visitor ended an unexpected object.");
|
||||
append(Object.fromEntries(frame.members));
|
||||
admitNode();
|
||||
selectCaptureObjectProperty(frames, key, format);
|
||||
},
|
||||
onObjectEnd: () => endContainer("object"),
|
||||
onArrayBegin: () => {
|
||||
admitContainer();
|
||||
frames.push({ kind: "array", pointer: valuePointer(), items: [] });
|
||||
},
|
||||
onArrayEnd: () => {
|
||||
const frame = frames.pop();
|
||||
if (frame?.kind !== "array")
|
||||
throw new TypeError("JSON visitor ended an unexpected array.");
|
||||
append(frame.items);
|
||||
},
|
||||
onArrayEnd: () => endContainer("array"),
|
||||
onLiteralValue: (value: unknown, offset, length) => {
|
||||
admitNode();
|
||||
if (typeof value === "number")
|
||||
append(new LosslessNumber(text.slice(offset, offset + length)));
|
||||
else if (
|
||||
|
||||
@@ -64,7 +64,7 @@ export const decodeHarCapture = (
|
||||
);
|
||||
const records = entries.map((entry, ordinal): WebNetworkCaptureRecord => {
|
||||
// Validate each original record even when a structural ancestor excludes it.
|
||||
const projected = projectHar(entry, sensitiveValues, "entry");
|
||||
const projected = projectRecordedHarEntry(entry, sensitiveValues);
|
||||
const projection: Projection = recordsExcluded
|
||||
? {
|
||||
reported: null,
|
||||
@@ -103,6 +103,12 @@ export const decodeHarCapture = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Project producer-owned HAR entry facts without pretending another format is a HAR document. */
|
||||
export const projectRecordedHarEntry = (
|
||||
value: unknown,
|
||||
sensitiveValues: readonly string[],
|
||||
): Projection => projectHar(value, sensitiveValues, "entry");
|
||||
|
||||
/** Relative HTTP paths belong only to entries, never similarly named container extensions. */
|
||||
const projectHar = (
|
||||
value: unknown,
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, open } from "node:fs/promises";
|
||||
import { expect, it } from "vitest";
|
||||
import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js";
|
||||
import { PlaywrightTraceDecoder } from "./PlaywrightTraceDecoder.js";
|
||||
import { playwrightTraceSchema } from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import {
|
||||
playwrightTraceFixture,
|
||||
playwrightTracePathFixture,
|
||||
} from "../../../../tests/fixtures/playwrightTrace.js";
|
||||
|
||||
it("imports real v10 recorder text with per-stream identity and resource presence, preserving source bytes", async () => {
|
||||
const fixture = await playwrightTraceFixture({ resources: true });
|
||||
const bytes = await readFile(fixture.path);
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const report = playwrightTraceSchema.parse(result.value);
|
||||
expect(report.summary).toEqual({
|
||||
events: 23,
|
||||
action_before: 6,
|
||||
action_after: 6,
|
||||
console: 2,
|
||||
network: 3,
|
||||
screenshots: 6,
|
||||
unavailable_references: 0,
|
||||
});
|
||||
expect(report.artifact).toEqual({
|
||||
path: fixture.path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
});
|
||||
expect(new Set(report.events.map((row) => row.event_id)).size).toBe(23);
|
||||
expect(report.streams).toHaveLength(2);
|
||||
expect(report.streams[0]?.producer?.playwright_version).toBe("1.64.1");
|
||||
expect(report.source_coverage).toEqual({
|
||||
recording_completeness: "unknown",
|
||||
interpretation: "scoped",
|
||||
resource_contents: "not-decoded",
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(serialized).not.toContain("FAKE_TRACE_CREDENTIAL");
|
||||
expect(serialized).not.toContain("FAKE_COOKIE");
|
||||
expect(serialized).toContain("ordinary caller text");
|
||||
expect(report.events.find((row) => row.kind === "console")?.text).toBe(
|
||||
"fixture loaded",
|
||||
);
|
||||
expect(
|
||||
report.events.find((row) => row.method === "click")?.selector,
|
||||
).toContain("Load value");
|
||||
expect(
|
||||
report.events
|
||||
.flatMap((row) => row.resource_references)
|
||||
.every((row) => row.state === "present-unread"),
|
||||
).toBe(true);
|
||||
expect((await readFile(fixture.path)).equals(bytes)).toBe(true);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves exact ZIP names for resources and paired stream profiles", async () => {
|
||||
const fixture = await playwrightTracePathFixture();
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const report = result.value;
|
||||
expect(report.members.map((member) => member.path)).toContain(
|
||||
"./resources/x",
|
||||
);
|
||||
expect(
|
||||
report.members.find((member) => member.path === "./resources/x"),
|
||||
).toMatchObject({
|
||||
logical_path: "resources/x",
|
||||
limitations: [expect.stringContaining("leading current-directory")],
|
||||
});
|
||||
expect(
|
||||
report.members.find((member) => member.path === "resources/x"),
|
||||
).toMatchObject({ logical_path: "resources/x", limitations: [] });
|
||||
expect(report.events).toHaveLength(4);
|
||||
expect(report.streams.map((stream) => stream.state)).toEqual([
|
||||
"supported",
|
||||
"supported",
|
||||
"supported",
|
||||
"supported",
|
||||
]);
|
||||
const members = new Map(
|
||||
report.members.map((member) => [member.member_id, member]),
|
||||
);
|
||||
for (const event of report.events) {
|
||||
const source = members.get(event.source.member_id)!;
|
||||
const expected = source.path.startsWith("./")
|
||||
? "./resources/x"
|
||||
: "resources/x";
|
||||
expect(event.resource_references).toEqual([
|
||||
expect.objectContaining({
|
||||
path: expected,
|
||||
state: "present-unread",
|
||||
member_id: report.members.find((row) => row.path === expected)
|
||||
?.member_id,
|
||||
matching_members: 1,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
expect(
|
||||
report.events
|
||||
.filter((event) => event.kind === "network")
|
||||
.map((event) => event.status),
|
||||
).toEqual([200, 201]);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps missing resource references explicit without turning page exhaustion into capture completeness", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.summary.unavailable_references).toBe(8);
|
||||
expect(result.value.source_coverage.interpretation).toBe("partial");
|
||||
expect(
|
||||
result.value.events
|
||||
.flatMap((row) => row.resource_references)
|
||||
.every((row) => row.state === "missing" && row.matching_members === 0),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
const header =
|
||||
JSON.stringify({ type: "context-options", version: 10, origin: "library" }) +
|
||||
"\n";
|
||||
const before =
|
||||
JSON.stringify({
|
||||
type: "before",
|
||||
callId: "call-a",
|
||||
class: "Frame",
|
||||
method: "goto",
|
||||
startTime: 1,
|
||||
params: {
|
||||
url: "https://u:[email protected]/path?token=ordinary",
|
||||
selector: "ordinary",
|
||||
},
|
||||
}) + "\n";
|
||||
|
||||
it("retains valid siblings of malformed records and exposes exact producer lines", async () => {
|
||||
const fixture = await playwrightTraceFixture({
|
||||
trace: header + '{"type":"console","text":NaN}\n' + before,
|
||||
network: "",
|
||||
});
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.events).toHaveLength(1);
|
||||
expect(result.value.events[0]).toMatchObject({
|
||||
source: { line: 3 },
|
||||
url: "https://example.test/path?token=ordinary",
|
||||
credential_redacted: true,
|
||||
});
|
||||
expect(result.value.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ line: 2, code: "malformed-record" }),
|
||||
);
|
||||
expect(result.value.source_coverage.interpretation).toBe("partial");
|
||||
expect(JSON.stringify(result.value)).not.toContain("u:p@");
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", 0],
|
||||
[before, 0],
|
||||
[header + before + header + before, 1],
|
||||
])(
|
||||
"never guesses a missing or repeated context version %#",
|
||||
async (trace, events) => {
|
||||
const fixture = await playwrightTraceFixture({ trace, network: "" });
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.events).toHaveLength(events);
|
||||
expect(result.value.source_coverage.interpretation).toBe("partial");
|
||||
expect(
|
||||
result.value.streams.every((stream) => stream.state === "unknown"),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses other declared trace versions with verified archive identity", async () => {
|
||||
const fixture = await playwrightTraceFixture({
|
||||
trace: header.replace('"version":10', '"version":11'),
|
||||
network: "",
|
||||
});
|
||||
const bytes = await readFile(fixture.path);
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected version refusal");
|
||||
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
|
||||
expect(
|
||||
playwrightTraceSchema.parse(result.error.partialObservation).artifact
|
||||
.sha256,
|
||||
).toBe(createHash("sha256").update(bytes).digest("hex"));
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves opaque pre-cancellation without reading or claiming a source snapshot", async () => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const reason = { reason: "owned caller" };
|
||||
controller.abort(reason);
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
"/unused.zip",
|
||||
controller.signal,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unsafe numeric literals exact instead of rounding recorded times", async () => {
|
||||
const trace =
|
||||
header +
|
||||
'{"type":"before","callId":"large","class":"Frame","method":"goto","startTime":9007199254740993,"params":{"url":"https://example.test/"}}\n';
|
||||
const fixture = await playwrightTraceFixture({ trace, network: "" });
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.events[0]?.time).toBeNull();
|
||||
expect(result.value.events[0]?.numeric_literals).toContainEqual({
|
||||
pointer: "/startTime",
|
||||
literal: "9007199254740993",
|
||||
});
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['{"type":"context-options","version":10,"version":11}\n', "unknown"],
|
||||
[header + '{"type":"resource-snapshot","snapshot":{}}\n', "supported"],
|
||||
])(
|
||||
"reports duplicate keys and incomplete known events explicitly %#",
|
||||
async (trace, state) => {
|
||||
const fixture = await playwrightTraceFixture({ trace, network: "" });
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.streams[0]?.state).toBe(state);
|
||||
expect(result.value.source_coverage.interpretation).toBe("partial");
|
||||
expect(result.value.diagnostics.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses oversized physical lines before parsing their representation", async () => {
|
||||
const trace =
|
||||
header +
|
||||
JSON.stringify({
|
||||
type: "console",
|
||||
text: "x".repeat(4 * 1024 * 1024),
|
||||
time: 1,
|
||||
}) +
|
||||
"\n";
|
||||
const fixture = await playwrightTraceFixture({ trace, network: "" });
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected line capacity refusal");
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("never promotes facts from a stream whose ZIP CRC fails", async () => {
|
||||
const fixture = await playwrightTraceFixture({ level: 0 });
|
||||
const bytes = await readFile(fixture.path);
|
||||
const needle = Buffer.from('"method":"GET"');
|
||||
const offset = bytes.indexOf(needle);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
bytes[offset + 10] = 80;
|
||||
await writeFile(fixture.path, bytes);
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected CRC refusal");
|
||||
expect(result.error._tag).toBe("AnalysisInputError");
|
||||
const partial = playwrightTraceSchema.parse(
|
||||
result.error.partialObservation,
|
||||
);
|
||||
expect(partial.summary.network).toBe(0);
|
||||
expect(partial.summary.events).toBe(20);
|
||||
expect(partial.source_coverage.interpretation).toBe("partial");
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a sparse over-budget archive before hashing or expansion", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const file = await open(fixture.path, "r+");
|
||||
await file.truncate(256 * 1024 * 1024 + 1);
|
||||
await file.close();
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected archive admission refusal");
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds JSON node expansion even when many tokens fit inside a small physical line", async () => {
|
||||
const trace =
|
||||
header +
|
||||
JSON.stringify({
|
||||
type: "frame-snapshot",
|
||||
snapshot: Array.from({ length: 132000 }, () => 0),
|
||||
}) +
|
||||
"\n";
|
||||
const fixture = await playwrightTraceFixture({ trace, network: "" });
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected node admission refusal");
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps duplicate resource names ambiguous instead of selecting one ZIP occurrence", async () => {
|
||||
const fixture = await playwrightTraceFixture({ resources: true, level: 0 });
|
||||
const frames = fixture.trace
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.filter((row) => row.type === "screencast-frame");
|
||||
const first = frames[0].file as string,
|
||||
second = frames[1].file as string;
|
||||
expect(first.length).toBe(second.length);
|
||||
const bytes = await readFile(fixture.path);
|
||||
const eocd = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
||||
expect(eocd).toBeGreaterThan(0);
|
||||
let cursor = bytes.readUInt32LE(eocd + 16);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < bytes.readUInt16LE(eocd + 10); index++) {
|
||||
expect(bytes.readUInt32LE(cursor)).toBe(0x02014b50);
|
||||
const length = bytes.readUInt16LE(cursor + 28);
|
||||
if (
|
||||
bytes.subarray(cursor + 46, cursor + 46 + length).toString("utf8") ===
|
||||
second
|
||||
) {
|
||||
const local = bytes.readUInt32LE(cursor + 42);
|
||||
expect(bytes.readUInt32LE(local)).toBe(0x04034b50);
|
||||
expect(bytes.readUInt16LE(local + 26)).toBe(length);
|
||||
// Both headers agree; CRC covers the unchanged data, not member names.
|
||||
Buffer.from(first).copy(bytes, cursor + 46);
|
||||
Buffer.from(first).copy(bytes, local + 30);
|
||||
changed++;
|
||||
}
|
||||
cursor +=
|
||||
46 +
|
||||
length +
|
||||
bytes.readUInt16LE(cursor + 30) +
|
||||
bytes.readUInt16LE(cursor + 32);
|
||||
}
|
||||
expect(changed).toBe(1);
|
||||
await writeFile(fixture.path, bytes);
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.members.filter((member) => member.path === first),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
new Set(result.value.members.map((member) => member.member_id)).size,
|
||||
).toBe(result.value.members.length);
|
||||
expect(
|
||||
result.value.events.flatMap((row) => row.resource_references),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: first,
|
||||
state: "ambiguous",
|
||||
member_id: null,
|
||||
matching_members: 2,
|
||||
}),
|
||||
);
|
||||
expect(result.value.source_coverage.interpretation).toBe("partial");
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { StableRegularFileDescriptor } from "../../../filesystem/RegularFile.js";
|
||||
import type { PlaywrightTracePort } from "../../../application/playwrightTrace/PlaywrightTracePort.js";
|
||||
import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js";
|
||||
import type { ArtifactEntry } from "../../../artifacts/ArtifactReader.js";
|
||||
import { ZipArtifactReader } from "../../../artifacts/ZipArtifactReader.js";
|
||||
import {
|
||||
artifactFilePrimaryFacts,
|
||||
assertArtifactReadActive,
|
||||
withArtifactRegularFile,
|
||||
} from "../../../artifacts/ArtifactFileOperation.js";
|
||||
import { projectStableArtifactFailure } from "../../../artifacts/StableArtifactFailure.js";
|
||||
import { assertArtifactFileSelectedSource } from "../../../artifacts/ArtifactFileSource.js";
|
||||
import { hashStableRootArtifactHandle } from "../../../artifacts/inventory/hashStableRootArtifact.js";
|
||||
import {
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../../domain/result.js";
|
||||
import {
|
||||
PLAYWRIGHT_TRACE_LIMITS,
|
||||
PLAYWRIGHT_TRACE_OPERATION as operation,
|
||||
PLAYWRIGHT_TRACE_PROVIDER,
|
||||
type PlaywrightTrace,
|
||||
type TraceMember,
|
||||
type TraceStream,
|
||||
} from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import type { FileSnapshotPartialObservation } from "../../../domain/fileSnapshotObservation.js";
|
||||
import { TraceInventory } from "./TraceInventory.js";
|
||||
import { readTraceStream } from "./TraceStream.js";
|
||||
import { traceFailure } from "./TraceFailure.js";
|
||||
|
||||
type SelectedMember = { entry: ArtifactEntry; member: TraceMember };
|
||||
|
||||
const readStreams = async (
|
||||
inventory: TraceInventory,
|
||||
reader: ZipArtifactReader,
|
||||
selected: SelectedMember[],
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const budget = { decodedBytes: 0 };
|
||||
const profiles = new Map<string, TraceStream[]>();
|
||||
for (const { entry, member } of selected.filter((row) =>
|
||||
row.member.path.endsWith(".trace"),
|
||||
)) {
|
||||
const stream = await readTraceStream({
|
||||
inventory,
|
||||
reader,
|
||||
entry,
|
||||
member,
|
||||
budget,
|
||||
profileKnown: false,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
inventory.streams.push(stream);
|
||||
const previous = profiles.get(member.path) ?? [];
|
||||
previous.push(stream);
|
||||
profiles.set(member.path, previous);
|
||||
}
|
||||
for (const { entry, member } of selected.filter((row) =>
|
||||
row.member.path.endsWith(".network"),
|
||||
)) {
|
||||
const expected = member.path.slice(0, -".network".length) + ".trace";
|
||||
const paired = profiles.get(expected) ?? [];
|
||||
const known =
|
||||
paired.length === 1 &&
|
||||
paired[0]?.version === 10 &&
|
||||
paired[0].state === "supported";
|
||||
inventory.streams.push(
|
||||
await readTraceStream({
|
||||
inventory,
|
||||
reader,
|
||||
entry,
|
||||
member,
|
||||
budget,
|
||||
profileKnown: known,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** One invocation owns its verified identity and completed facts across finalization. */
|
||||
class TraceArchiveRead {
|
||||
inventory: TraceInventory | undefined;
|
||||
identity: FileSnapshotPartialObservation | undefined;
|
||||
primary: unknown;
|
||||
complete: PlaywrightTrace | undefined;
|
||||
constructor(
|
||||
readonly resources: ArtifactResourceScope,
|
||||
readonly path: string,
|
||||
readonly signal?: AbortSignal,
|
||||
) {}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
const report = await this.resources.run(() =>
|
||||
withArtifactRegularFile(
|
||||
this.path,
|
||||
this.resources,
|
||||
this.signal,
|
||||
(source) => this.readSource(source),
|
||||
),
|
||||
);
|
||||
assertArtifactReadActive(this.signal);
|
||||
return ok(report);
|
||||
} catch (cause) {
|
||||
const error = traceFailure(
|
||||
this.primary ?? artifactFilePrimaryFacts(cause).cause,
|
||||
this.path,
|
||||
);
|
||||
const partial =
|
||||
this.complete ??
|
||||
(this.inventory?.members.length
|
||||
? this.inventory.finish(true)
|
||||
: this.identity);
|
||||
if (partial !== undefined) error.retainPartialObservation(partial);
|
||||
return err(
|
||||
projectStableArtifactFailure(
|
||||
cause,
|
||||
error,
|
||||
this.path,
|
||||
PLAYWRIGHT_TRACE_PROVIDER.id,
|
||||
operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readSource(source: StableRegularFileDescriptor) {
|
||||
if (source.initial.size > PLAYWRIGHT_TRACE_LIMITS.archiveBytes)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"file-size",
|
||||
"Trace archive exceeds the 256 MiB input allowance.",
|
||||
{
|
||||
input_bytes: source.initial.size,
|
||||
max_archive_bytes: PLAYWRIGHT_TRACE_LIMITS.archiveBytes,
|
||||
},
|
||||
);
|
||||
const hash = await hashStableRootArtifactHandle(
|
||||
this.path,
|
||||
source.handle,
|
||||
source.initial,
|
||||
this.signal,
|
||||
);
|
||||
this.identity = {
|
||||
kind: "file-snapshot-hash",
|
||||
path: this.path,
|
||||
sha256: hash.sha256,
|
||||
bytes: hash.bytes,
|
||||
};
|
||||
const facts = new TraceInventory({
|
||||
path: this.path,
|
||||
sha256: hash.sha256,
|
||||
bytes: hash.bytes,
|
||||
});
|
||||
this.inventory = facts;
|
||||
const reader = new ZipArtifactReader(
|
||||
this.path,
|
||||
"zip",
|
||||
PLAYWRIGHT_TRACE_LIMITS.metadataBytes,
|
||||
source,
|
||||
);
|
||||
let outcome:
|
||||
| { kind: "completed"; value: PlaywrightTrace }
|
||||
| { kind: "failed"; cause: unknown };
|
||||
try {
|
||||
const selected: SelectedMember[] = [];
|
||||
for await (const entry of reader.entries(this.signal)) {
|
||||
const member = facts.member(entry);
|
||||
if (member.path.endsWith(".trace") || member.path.endsWith(".network"))
|
||||
selected.push({ entry, member });
|
||||
}
|
||||
if (selected.length === 0)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
operation,
|
||||
this.path,
|
||||
"Archive contains no Playwright trace or network stream members.",
|
||||
);
|
||||
if (!selected.some((row) => row.member.path.endsWith(".network")))
|
||||
facts.diagnostic(
|
||||
null,
|
||||
null,
|
||||
"missing-network-member",
|
||||
"No network stream was supplied; this does not establish absent traffic.",
|
||||
);
|
||||
await readStreams(facts, reader, selected, this.signal);
|
||||
await assertArtifactFileSelectedSource(
|
||||
this.path,
|
||||
source,
|
||||
"Playwright trace",
|
||||
);
|
||||
this.complete = facts.finish();
|
||||
outcome = { kind: "completed", value: this.complete };
|
||||
} catch (cause) {
|
||||
this.primary = cause;
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const released = await this.resources.release({
|
||||
kind: "reader",
|
||||
resource: `Playwright ZIP reader for ${this.path}`,
|
||||
reader,
|
||||
});
|
||||
if (outcome.kind === "completed" && this.signal?.aborted) {
|
||||
const cancellation = new AnalysisCancelledError(operation, {
|
||||
cause: this.signal.reason,
|
||||
partialObservation: this.complete ?? this.identity,
|
||||
});
|
||||
this.primary = cancellation;
|
||||
outcome = { kind: "failed", cause: cancellation };
|
||||
}
|
||||
if (released.kind === "failed")
|
||||
throw this.resources.cleanupFailure(
|
||||
outcome,
|
||||
[released],
|
||||
this.complete ?? this.identity,
|
||||
);
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Offline decoder; failed cleanup stays with the caller's shared scope. */
|
||||
export class PlaywrightTraceDecoder implements PlaywrightTracePort {
|
||||
#running = false;
|
||||
constructor(readonly resources: ArtifactResourceScope) {}
|
||||
async inspect(path: string, signal?: AbortSignal) {
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
if (this.#running)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"capacity",
|
||||
"One trace archive import is already active in this decoder.",
|
||||
{ maximum_concurrent_trace_imports: 1 },
|
||||
{
|
||||
retryable: true,
|
||||
remediationAction:
|
||||
"Wait for the active import to finish, then retry. Queries against retained Evidence do not need another import.",
|
||||
},
|
||||
),
|
||||
);
|
||||
this.#running = true;
|
||||
try {
|
||||
return await new TraceArchiveRead(this.resources, path, signal).run();
|
||||
} finally {
|
||||
this.#running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { createDeferred } from "../../../../tests/fixtures/binarySession.js";
|
||||
import { readFile, rename, copyFile } from "node:fs/promises";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js";
|
||||
import { ZipArtifactReader } from "../../../artifacts/ZipArtifactReader.js";
|
||||
import { PlaywrightTraceDecoder } from "./PlaywrightTraceDecoder.js";
|
||||
import { PlaywrightTraceService } from "../../../application/playwrightTrace/PlaywrightTraceService.js";
|
||||
import { parseEvidence } from "../../../domain/evidence.js";
|
||||
import { playwrightTraceSchema } from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { playwrightTraceFixture } from "../../../../tests/fixtures/playwrightTrace.js";
|
||||
import { OwnedFileHandle } from "../../../filesystem/OwnedFileHandle.js";
|
||||
|
||||
it("preserves only fully verified streams when cancellation arrives before the network member", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const reason = { stage: "network-member" };
|
||||
const realOpen = ZipArtifactReader.prototype.open;
|
||||
const opened = vi
|
||||
.spyOn(ZipArtifactReader.prototype, "open")
|
||||
.mockImplementation(function (this: ZipArtifactReader, entry, signal) {
|
||||
if (entry.path.endsWith(".network")) controller.abort(reason);
|
||||
return realOpen.call(this, entry, signal);
|
||||
});
|
||||
try {
|
||||
const result = await new PlaywrightTraceService(
|
||||
new PlaywrightTraceDecoder(resources),
|
||||
).inspect({ path: fixture.path }, controller.signal);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
const partial = playwrightTraceSchema.parse(
|
||||
parseEvidence(result.error.partialObservation).normalized_result,
|
||||
);
|
||||
expect(partial.summary.events).toBe(20);
|
||||
expect(partial.summary.network).toBe(0);
|
||||
expect(partial.source_coverage.interpretation).toBe("partial");
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
opened.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains complete Evidence and the exact failed reader owner when abort arrives during final cleanup", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const bytes = await readFile(fixture.path);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const reason = { stage: "final-reader-cleanup" };
|
||||
const realClose = ZipArtifactReader.prototype.close;
|
||||
const closed = vi
|
||||
.spyOn(ZipArtifactReader.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: ZipArtifactReader) {
|
||||
await realClose.call(this);
|
||||
controller.abort(reason);
|
||||
throw new Error("Injected reader close acknowledgement failure");
|
||||
});
|
||||
try {
|
||||
const result = await new PlaywrightTraceService(
|
||||
new PlaywrightTraceDecoder(resources),
|
||||
).inspect({ path: fixture.path }, controller.signal);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected composed failure");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupPrimary?._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
expect(
|
||||
playwrightTraceSchema.parse(
|
||||
parseEvidence(result.error.partialObservation).normalized_result,
|
||||
).summary.events,
|
||||
).toBe(23);
|
||||
expect(resources.hasPending).toBe(true);
|
||||
await resources.retryCleanup();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(
|
||||
parseEvidence(result.error.cleanupPrimary?.partialObservation).operation,
|
||||
).toBe("inspect_playwright_trace");
|
||||
expect((await readFile(fixture.path)).equals(bytes)).toBe(true);
|
||||
} finally {
|
||||
closed.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps completed Evidence on cancellation during the outer descriptor close", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const reason = { stage: "outer-descriptor-close" };
|
||||
const realClose = OwnedFileHandle.prototype.close;
|
||||
const closed = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
||||
await realClose.call(this);
|
||||
controller.abort(reason);
|
||||
});
|
||||
try {
|
||||
const result = await new PlaywrightTraceService(
|
||||
new PlaywrightTraceDecoder(resources),
|
||||
).inspect({ path: fixture.path }, controller.signal);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(
|
||||
playwrightTraceSchema.parse(
|
||||
parseEvidence(result.error.partialObservation).normalized_result,
|
||||
).summary.events,
|
||||
).toBe(23);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
closed.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an existing unsupported-version failure ahead of cancellation during cleanup", async () => {
|
||||
const fixture = await playwrightTraceFixture({
|
||||
trace: '{"type":"context-options","version":11}\n',
|
||||
network: "",
|
||||
});
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const realClose = ZipArtifactReader.prototype.close;
|
||||
const closed = vi
|
||||
.spyOn(ZipArtifactReader.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: ZipArtifactReader) {
|
||||
await realClose.call(this);
|
||||
controller.abort("late cleanup abort");
|
||||
});
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
controller.signal,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected version refusal");
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
closed.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains independent reader and file owners until both cleanup retries complete", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const resources = new ArtifactResourceScope();
|
||||
const realClose = ZipArtifactReader.prototype.close;
|
||||
const reader = vi
|
||||
.spyOn(ZipArtifactReader.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: ZipArtifactReader) {
|
||||
await realClose.call(this);
|
||||
throw new Error("Injected reader acknowledgement failure");
|
||||
});
|
||||
const descriptor = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
||||
throw new Error("Injected descriptor close failure");
|
||||
});
|
||||
try {
|
||||
const result = await new PlaywrightTraceService(
|
||||
new PlaywrightTraceDecoder(resources),
|
||||
).inspect({ path: fixture.path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cleanup failure");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(resources.cleanupResources).toHaveLength(2);
|
||||
expect(
|
||||
playwrightTraceSchema.parse(
|
||||
parseEvidence(result.error.partialObservation).normalized_result,
|
||||
).summary.events,
|
||||
).toBe(23);
|
||||
await resources.retryCleanup();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
reader.mockRestore();
|
||||
descriptor.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses source replacement without rebinding the archive identity",
|
||||
async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const replacement = await playwrightTraceFixture({
|
||||
trace: '{"type":"context-options","version":10}\n',
|
||||
network: "",
|
||||
});
|
||||
const original = await readFile(fixture.path);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const held = fixture.path + ".held";
|
||||
const realOpen = ZipArtifactReader.prototype.open;
|
||||
let replaced = false;
|
||||
const opened = vi
|
||||
.spyOn(ZipArtifactReader.prototype, "open")
|
||||
.mockImplementation(async function (
|
||||
this: ZipArtifactReader,
|
||||
entry,
|
||||
signal,
|
||||
) {
|
||||
if (entry.path.endsWith(".network") && !replaced) {
|
||||
replaced = true;
|
||||
await rename(fixture.path, held);
|
||||
await copyFile(replacement.path, fixture.path);
|
||||
}
|
||||
return realOpen.call(this, entry, signal);
|
||||
});
|
||||
try {
|
||||
const result = await new PlaywrightTraceDecoder(resources).inspect(
|
||||
fixture.path,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected source identity refusal");
|
||||
expect(result.error._tag).toBe("AnalysisArtifactChangedError");
|
||||
expect((await readFile(held)).equals(original)).toBe(true);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
opened.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("bounds active imports without an unbounded queue and releases admission after completion", async () => {
|
||||
const fixture = await playwrightTraceFixture();
|
||||
const resources = new ArtifactResourceScope();
|
||||
const port = new PlaywrightTraceDecoder(resources);
|
||||
const reached = createDeferred<void>(),
|
||||
release = createDeferred<void>();
|
||||
const realRun = resources.run.bind(resources);
|
||||
const running = vi
|
||||
.spyOn(resources, "run")
|
||||
.mockImplementationOnce(
|
||||
async <Value>(operation: () => Promise<Value>): Promise<Value> => {
|
||||
reached.resolve();
|
||||
await release.promise;
|
||||
return realRun(operation);
|
||||
},
|
||||
);
|
||||
const first = port.inspect(fixture.path);
|
||||
try {
|
||||
await reached.promise;
|
||||
const second = await port.inspect(fixture.path);
|
||||
expect(second.ok).toBe(false);
|
||||
if (second.ok) throw new Error("Expected capacity refusal");
|
||||
expect(second.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
release.resolve();
|
||||
expect((await first).ok).toBe(true);
|
||||
expect((await port.inspect(fixture.path)).ok).toBe(true);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await first;
|
||||
running.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { JsonValue } from "../../../domain/jsonValue.js";
|
||||
import type { TraceEvent } from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { CaptureRedaction, captureObject } from "../CaptureRedaction.js";
|
||||
import { projectRecordedHarEntry } from "../HarCapture.js";
|
||||
|
||||
const string = (value: unknown): string | null =>
|
||||
typeof value === "string" ? value : null;
|
||||
const number = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
const object = (value: unknown) => (captureObject(value) ? value : {});
|
||||
|
||||
/** Action payload scope is explicit; omitted arguments/results are not alleged secrets. */
|
||||
const actionFacts = (
|
||||
raw: Record<string, unknown>,
|
||||
redactor: CaptureRedaction,
|
||||
) => {
|
||||
const facts: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
"type",
|
||||
"callId",
|
||||
"parentId",
|
||||
"pageId",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"class",
|
||||
"method",
|
||||
"title",
|
||||
])
|
||||
if (Object.hasOwn(raw, key)) facts[key] = raw[key];
|
||||
const params = object(raw.params);
|
||||
const selected: Record<string, unknown> = {};
|
||||
if (Object.hasOwn(params, "selector")) selected.selector = params.selector;
|
||||
if (
|
||||
(raw.class === "Frame" || raw.class === "Page") &&
|
||||
raw.method === "goto" &&
|
||||
typeof params.url === "string"
|
||||
)
|
||||
selected.url = redactor.text(params.url, "/params/url", {});
|
||||
if (Object.keys(selected).length > 0) facts.params = selected;
|
||||
if (Object.hasOwn(raw, "error")) {
|
||||
const error = object(raw.error);
|
||||
facts.error = captureObject(raw.error)
|
||||
? Object.fromEntries(
|
||||
["name", "message"]
|
||||
.filter((key) => Object.hasOwn(error, key))
|
||||
.map((key) => [key, error[key]]),
|
||||
)
|
||||
: raw.error;
|
||||
}
|
||||
return facts;
|
||||
};
|
||||
|
||||
const selectedEvent = (
|
||||
raw: Record<string, unknown>,
|
||||
redactor: CaptureRedaction,
|
||||
) => {
|
||||
let kind: TraceEvent["kind"];
|
||||
let selected: unknown;
|
||||
if (raw.type === "before" || raw.type === "after") {
|
||||
if (typeof raw.callId !== "string") return undefined;
|
||||
kind = raw.type === "before" ? "action-before" : "action-after";
|
||||
selected = actionFacts(raw, redactor);
|
||||
} else if (raw.type === "console") {
|
||||
kind = "console";
|
||||
const consoleFacts = Object.fromEntries(
|
||||
["type", "messageType", "text", "time", "pageId"]
|
||||
.filter((key) => Object.hasOwn(raw, key))
|
||||
.map((key) => [key, raw[key]]),
|
||||
);
|
||||
const location = object(raw.location);
|
||||
if (captureObject(raw.location))
|
||||
Object.assign(consoleFacts, {
|
||||
location: {
|
||||
...location,
|
||||
...(typeof location.url === "string"
|
||||
? { url: redactor.text(location.url, "/location/url", {}) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
selected = consoleFacts;
|
||||
} else if (raw.type === "resource-snapshot") {
|
||||
kind = "network";
|
||||
if (!captureObject(raw.snapshot)) return undefined;
|
||||
selected = raw.snapshot;
|
||||
} else if (raw.type === "screencast-frame") {
|
||||
kind = "screenshot";
|
||||
selected = Object.fromEntries(
|
||||
[
|
||||
"type",
|
||||
"pageId",
|
||||
"file",
|
||||
"width",
|
||||
"height",
|
||||
"timestamp",
|
||||
"frameSwapWallTime",
|
||||
]
|
||||
.filter((key) => Object.hasOwn(raw, key))
|
||||
.map((key) => [key, raw[key]]),
|
||||
);
|
||||
} else return undefined;
|
||||
return { kind, selected };
|
||||
};
|
||||
|
||||
/** Recognize only pinned v10 event meanings; no live browser attribution or clock alignment. */
|
||||
export const projectTraceEvent = (
|
||||
raw: Record<string, unknown>,
|
||||
identity: Pick<TraceEvent, "event_id" | "stream_id" | "ordinal" | "source">,
|
||||
): TraceEvent | undefined => {
|
||||
const redactor = new CaptureRedaction([]);
|
||||
const selected = selectedEvent(raw, redactor);
|
||||
if (selected === undefined) return undefined;
|
||||
const { kind } = selected;
|
||||
const projected = projectRecordedHarEntry(selected.selected, []);
|
||||
const facts = object(projected.reported);
|
||||
const params = object(facts.params),
|
||||
request = object(facts.request),
|
||||
response = object(facts.response);
|
||||
const error = object(facts.error);
|
||||
const redactions = [...projected.redactions, ...redactor.redactions].map(
|
||||
({ pointer, reason }) => ({ pointer, reason }),
|
||||
);
|
||||
const references: TraceEvent["resource_references"] = [];
|
||||
const reference = (value: unknown, pointer: string) => {
|
||||
if (typeof value === "string")
|
||||
references.push({
|
||||
path: value,
|
||||
source_pointer: pointer,
|
||||
state: "missing",
|
||||
member_id: null,
|
||||
matching_members: 0,
|
||||
});
|
||||
};
|
||||
if (kind === "screenshot") reference(facts.file, "/file");
|
||||
if (kind === "network") {
|
||||
reference(
|
||||
object(request.postData)._file,
|
||||
"/snapshot/request/postData/_file",
|
||||
);
|
||||
reference(
|
||||
object(response.content)._file,
|
||||
"/snapshot/response/content/_file",
|
||||
);
|
||||
}
|
||||
return {
|
||||
...identity,
|
||||
kind,
|
||||
call_id: string(facts.callId),
|
||||
page_id: string(kind === "network" ? facts.pageref : facts.pageId),
|
||||
time: number(
|
||||
kind === "network"
|
||||
? facts._monotonicTime
|
||||
: kind === "screenshot"
|
||||
? facts.timestamp
|
||||
: kind === "action-before"
|
||||
? facts.startTime
|
||||
: kind === "action-after"
|
||||
? facts.endTime
|
||||
: facts.time,
|
||||
),
|
||||
method: string(kind === "network" ? request.method : facts.method),
|
||||
url: string(kind === "network" ? request.url : params.url),
|
||||
status: number(response.status),
|
||||
text: string(
|
||||
kind === "console"
|
||||
? facts.text
|
||||
: kind === "action-after"
|
||||
? error.message
|
||||
: facts.title,
|
||||
),
|
||||
selector: string(params.selector),
|
||||
resource_references: references,
|
||||
credential_redacted: redactions.some(
|
||||
(row) => row.reason === "transport-credential",
|
||||
),
|
||||
reported: projected.reported as JsonValue,
|
||||
numeric_literals: projected.numeric_literals.map(
|
||||
({ pointer, literal }) => ({ pointer, literal }),
|
||||
),
|
||||
redactions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AnalysisError } from "../../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../../domain/analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "../../../domain/artifactOperationError.js";
|
||||
import { PLAYWRIGHT_TRACE_OPERATION as operation } from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { ArtifactReaderFailure } from "../../../artifacts/ArtifactReader.js";
|
||||
import { artifactFileCancellationCause } from "../../../artifacts/ArtifactFileOperation.js";
|
||||
import { isSelectedRegularFileError } from "../../../filesystem/RegularFile.js";
|
||||
import { stableArtifactInputRecovery } from "../../../artifacts/StableArtifactFailure.js";
|
||||
|
||||
/** Classify the archive owner's facts without interpreting opaque cancellation reasons. */
|
||||
export const traceFailure = (cause: unknown, path: string): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(operation, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(operation, path, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
cause.boundary ?? "memory",
|
||||
cause.message,
|
||||
null,
|
||||
{
|
||||
cause,
|
||||
remediationAction:
|
||||
"Select a smaller complete recorder chunk. Reply selection does not increase archive parsing allowances.",
|
||||
},
|
||||
);
|
||||
if (cause.reason === "format" || cause.reason === "path")
|
||||
return new AnalysisInputError(
|
||||
operation,
|
||||
{ cause, ...stableArtifactInputRecovery(cause) },
|
||||
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
|
||||
);
|
||||
return new ArtifactOperationError(
|
||||
operation,
|
||||
cause.reason,
|
||||
undefined,
|
||||
cause.message,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "EACCES" || cause.code === "EPERM")
|
||||
)
|
||||
return new AnalysisAccessDeniedError(operation, path, cause.code, {
|
||||
cause,
|
||||
});
|
||||
if (cause instanceof Error && isSelectedRegularFileError(cause))
|
||||
return new AnalysisInputError(operation, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message:
|
||||
"Expected a readable regular trace archive without a symbolic link.",
|
||||
},
|
||||
]);
|
||||
return new ArtifactOperationError(
|
||||
operation,
|
||||
"io",
|
||||
undefined,
|
||||
"Trace archive could not be read.",
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import { digestCanonicalValue } from "../../../domain/canonicalDigest.js";
|
||||
import { AnalysisResourceConstraintError } from "../../../domain/analysisErrorCore.js";
|
||||
import type { ArtifactEntry } from "../../../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
PLAYWRIGHT_TRACE_LIMITS,
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
type PlaywrightTrace,
|
||||
type TraceEvent,
|
||||
type TraceMember,
|
||||
type TraceStream,
|
||||
} from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
|
||||
export const traceIdentity = (prefix: string, value: unknown) =>
|
||||
`${prefix}_${digestCanonicalValue(value)}`;
|
||||
|
||||
/** Retain only bounded producer facts; ZIP directory occurrences own member identity. */
|
||||
export class TraceInventory {
|
||||
readonly members: TraceMember[] = [];
|
||||
readonly streams: TraceStream[] = [];
|
||||
readonly events: TraceEvent[] = [];
|
||||
readonly diagnostics: PlaywrightTrace["diagnostics"] = [];
|
||||
readonly #paths = new Map<string, TraceMember[]>();
|
||||
#bytes = 0;
|
||||
constructor(readonly artifact: PlaywrightTrace["artifact"]) {}
|
||||
reserve(bytes: number): void {
|
||||
this.#bytes += bytes;
|
||||
if (this.#bytes > PLAYWRIGHT_TRACE_LIMITS.factsBytes)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
"memory",
|
||||
"Trace facts exceed the 32 MiB retained representation allowance.",
|
||||
{ retained_fact_allowance: PLAYWRIGHT_TRACE_LIMITS.factsBytes },
|
||||
{
|
||||
remediationAction:
|
||||
"Select a smaller complete recorder chunk; fields and reply pagination do not increase parser allowances.",
|
||||
},
|
||||
);
|
||||
}
|
||||
retain(value: unknown): void {
|
||||
this.reserve(512 + Buffer.byteLength(JSON.stringify(value)) * 4);
|
||||
}
|
||||
member(entry: ArtifactEntry): TraceMember {
|
||||
const path = entry.sourcePath ?? entry.path;
|
||||
this.reserve(
|
||||
1024 +
|
||||
(path.length +
|
||||
entry.path.length +
|
||||
entry.limitations.reduce((sum, value) => sum + value.length, 0)) *
|
||||
12,
|
||||
);
|
||||
const row: TraceMember = {
|
||||
member_id: traceIdentity("trace_member", [
|
||||
this.artifact.sha256,
|
||||
entry.adapterKey,
|
||||
]),
|
||||
path,
|
||||
logical_path: entry.path,
|
||||
kind: entry.kind,
|
||||
declared_bytes: entry.declaredSize,
|
||||
declared_compressed_bytes: entry.compressedSize,
|
||||
encrypted: entry.encrypted,
|
||||
decoded: false,
|
||||
limitations: [...entry.limitations],
|
||||
};
|
||||
this.members.push(row);
|
||||
const matches = this.#paths.get(row.path) ?? [];
|
||||
if (matches.length > 0)
|
||||
this.diagnostic(
|
||||
row,
|
||||
null,
|
||||
"duplicate-member-path",
|
||||
"Archive member names are ambiguous; directory occurrences keep distinct identity.",
|
||||
);
|
||||
matches.push(row);
|
||||
this.#paths.set(row.path, matches);
|
||||
return row;
|
||||
}
|
||||
diagnostic(
|
||||
member: TraceMember | null,
|
||||
line: number | null,
|
||||
code: string,
|
||||
message: string,
|
||||
): void {
|
||||
const row = {
|
||||
diagnostic_id: traceIdentity("trace_diagnostic", [
|
||||
this.artifact.sha256,
|
||||
member?.member_id ?? null,
|
||||
line,
|
||||
code,
|
||||
this.diagnostics.length,
|
||||
]),
|
||||
member_id: member?.member_id ?? null,
|
||||
line,
|
||||
code,
|
||||
message,
|
||||
};
|
||||
this.retain(row);
|
||||
this.diagnostics.push(row);
|
||||
}
|
||||
/** References establish directory presence only; payload decoding and image validity stay unknown. */
|
||||
finish(incomplete = false): PlaywrightTrace {
|
||||
const events = this.events.map((event) => ({
|
||||
...event,
|
||||
resource_references: event.resource_references.map((reference) => {
|
||||
const matches = this.#paths.get(reference.path) ?? [];
|
||||
const member = matches.length === 1 ? matches[0] : undefined;
|
||||
const state: TraceEvent["resource_references"][number]["state"] =
|
||||
matches.length === 0
|
||||
? "missing"
|
||||
: matches.length > 1
|
||||
? "ambiguous"
|
||||
: member?.kind !== "file" || member.encrypted
|
||||
? "unavailable"
|
||||
: "present-unread";
|
||||
return {
|
||||
...reference,
|
||||
state,
|
||||
member_id: member?.member_id ?? null,
|
||||
matching_members: matches.length,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const unavailable = events.reduce(
|
||||
(sum, event) =>
|
||||
sum +
|
||||
event.resource_references.filter(
|
||||
(reference) => reference.state !== "present-unread",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
kind: "playwright-trace",
|
||||
trace_id: traceIdentity("playwright_trace", [this.artifact.sha256, 10]),
|
||||
decoder_profile: "playwright-trace-v10",
|
||||
artifact: this.artifact,
|
||||
ordering: "per-stream-source-order-only",
|
||||
members: this.members,
|
||||
streams: this.streams,
|
||||
events,
|
||||
diagnostics: this.diagnostics,
|
||||
summary: {
|
||||
events: events.length,
|
||||
action_before: events.filter((row) => row.kind === "action-before")
|
||||
.length,
|
||||
action_after: events.filter((row) => row.kind === "action-after")
|
||||
.length,
|
||||
console: events.filter((row) => row.kind === "console").length,
|
||||
network: events.filter((row) => row.kind === "network").length,
|
||||
screenshots: events.filter((row) => row.kind === "screenshot").length,
|
||||
unavailable_references: unavailable,
|
||||
},
|
||||
source_coverage: {
|
||||
recording_completeness: "unknown",
|
||||
interpretation:
|
||||
incomplete ||
|
||||
this.diagnostics.length > 0 ||
|
||||
this.members.some((member) => member.limitations.length > 0) ||
|
||||
unavailable > 0 ||
|
||||
this.streams.some((row) => row.state !== "supported")
|
||||
? "partial"
|
||||
: "scoped",
|
||||
resource_contents: "not-decoded",
|
||||
},
|
||||
limitations: [
|
||||
...(incomplete
|
||||
? [
|
||||
"Import stopped before all selected members completed; only fully verified stream facts are included.",
|
||||
]
|
||||
: []),
|
||||
"Recorded producer declarations are not authenticated. The archive was read offline without browser launch, replay or requests to recorded URLs.",
|
||||
"Order is preserved within each source member only; no total order or clock alignment is inferred across trace and network streams.",
|
||||
"Only v10 action boundaries, console text/location, network metadata and screencast references are interpreted. Other event types, DOM, context options, most action arguments, results, stacks and resource contents are not analyzed. Original archive bytes remain the complete raw source.",
|
||||
"Only decoded stream members have checked decompression/CRC. Resource reference presence does not establish readable content, valid images, observed runtime behavior or complete capture coverage.",
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { Readable } from "node:stream";
|
||||
import { assertArtifactReadActive } from "../../../artifacts/ArtifactFileOperation.js";
|
||||
import { AnalysisResourceConstraintError } from "../../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
PLAYWRIGHT_TRACE_LIMITS,
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
} from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
|
||||
export interface TraceLineBudget {
|
||||
decodedBytes: number;
|
||||
}
|
||||
/** Buffer one physical line; do not decode a partial UTF-8 sequence or split records by characters. */
|
||||
export async function* traceLines(
|
||||
stream: Readable,
|
||||
budget: TraceLineBudget,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<{ line: number; bytes: Buffer }> {
|
||||
let fragments: Buffer[] = [];
|
||||
let pendingBytes = 0;
|
||||
let line = 0;
|
||||
const append = (part: Buffer) => {
|
||||
pendingBytes += part.length;
|
||||
if (pendingBytes > PLAYWRIGHT_TRACE_LIMITS.lineBytes)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
"memory",
|
||||
"A trace line exceeds the 4 MiB parser representation allowance.",
|
||||
{ max_line_bytes: PLAYWRIGHT_TRACE_LIMITS.lineBytes },
|
||||
{
|
||||
remediationAction:
|
||||
"Select a smaller recorder chunk; changing reply pagination does not change parsing allowances.",
|
||||
},
|
||||
);
|
||||
fragments.push(part);
|
||||
};
|
||||
for await (const chunk of stream) {
|
||||
assertArtifactReadActive(signal);
|
||||
if (!Buffer.isBuffer(chunk))
|
||||
throw new TypeError("ZIP trace stream returned non-byte data");
|
||||
budget.decodedBytes += chunk.length;
|
||||
if (budget.decodedBytes > PLAYWRIGHT_TRACE_LIMITS.decodedBytes)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
"memory",
|
||||
"Decoded trace streams exceed the 32 MiB analysis allowance.",
|
||||
{ max_decoded_bytes: PLAYWRIGHT_TRACE_LIMITS.decodedBytes },
|
||||
{
|
||||
remediationAction:
|
||||
"Select a smaller complete recorder chunk; binary resource payloads are not decoded by this tool.",
|
||||
},
|
||||
);
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
const end = chunk.indexOf(10, start);
|
||||
if (end === -1) {
|
||||
if (start < chunk.length) append(chunk.subarray(start));
|
||||
break;
|
||||
}
|
||||
append(chunk.subarray(start, end));
|
||||
const bytes =
|
||||
fragments.length === 1
|
||||
? fragments[0]!
|
||||
: Buffer.concat(fragments, pendingBytes);
|
||||
line++;
|
||||
yield { line, bytes };
|
||||
fragments = [];
|
||||
pendingBytes = 0;
|
||||
start = end + 1;
|
||||
if (line % 128 === 0) {
|
||||
await setImmediate();
|
||||
assertArtifactReadActive(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingBytes > 0)
|
||||
yield { line: line + 1, bytes: Buffer.concat(fragments, pendingBytes) };
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { getHeapStatistics } from "node:v8";
|
||||
import { LosslessNumber, isSafeNumber } from "lossless-json";
|
||||
import type {
|
||||
ArtifactEntry,
|
||||
ArtifactReader,
|
||||
} from "../../../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
PLAYWRIGHT_TRACE_LIMITS,
|
||||
type TraceMember,
|
||||
type TraceStream,
|
||||
type TraceEvent,
|
||||
} from "../../../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { captureObject } from "../CaptureRedaction.js";
|
||||
import { CaptureFormatError } from "../CaptureFormatError.js";
|
||||
import { parseCaptureJson } from "../CaptureJson.js";
|
||||
import { TraceInventory, traceIdentity } from "./TraceInventory.js";
|
||||
import { traceLines, type TraceLineBudget } from "./TraceLines.js";
|
||||
import { projectTraceEvent } from "./TraceEventProjection.js";
|
||||
|
||||
const text = (value: unknown) => (typeof value === "string" ? value : null);
|
||||
const number = (value: unknown) =>
|
||||
value instanceof LosslessNumber && isSafeNumber(value.value)
|
||||
? Number(value.value)
|
||||
: null;
|
||||
|
||||
const readRecord = (bytes: Buffer): unknown => {
|
||||
const required = 32 * 1024 * 1024 + bytes.length * 64;
|
||||
if (required > getHeapStatistics().total_available_size)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
"memory",
|
||||
"Insufficient heap headroom for trace record expansion.",
|
||||
{ input_bytes: bytes.length, required_headroom_bytes: required },
|
||||
);
|
||||
let value: string;
|
||||
try {
|
||||
value = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new CaptureFormatError(
|
||||
"format",
|
||||
"Trace line is not valid UTF-8.",
|
||||
"",
|
||||
cause,
|
||||
);
|
||||
}
|
||||
return parseCaptureJson(
|
||||
value,
|
||||
"Playwright trace",
|
||||
PLAYWRIGHT_TRACE_LIMITS.factsBytes / 256,
|
||||
);
|
||||
};
|
||||
|
||||
const hasPartialHeadline = (event: TraceEvent): boolean =>
|
||||
(event.kind === "network" &&
|
||||
(event.method === null || event.url === null || event.status === null)) ||
|
||||
(event.kind === "console" && event.text === null) ||
|
||||
(event.kind === "action-before" && event.method === null) ||
|
||||
(event.kind === "screenshot" && event.resource_references.length === 0);
|
||||
|
||||
interface TraceStreamInput {
|
||||
readonly inventory: TraceInventory;
|
||||
readonly reader: ArtifactReader;
|
||||
readonly entry: ArtifactEntry;
|
||||
readonly member: TraceMember;
|
||||
readonly budget: TraceLineBudget;
|
||||
readonly profileKnown: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** A member's facts remain provisional until its ZIP stream verifies CRC and size. */
|
||||
class TraceStreamRead {
|
||||
readonly stream: TraceStream;
|
||||
readonly pending: TraceEvent[] = [];
|
||||
readonly diagnostics: { line: number; code: string; message: string }[] = [];
|
||||
readonly omitted = new Map<string, number>();
|
||||
#headerSeen = false;
|
||||
constructor(readonly input: TraceStreamInput) {
|
||||
const { member, profileKnown } = input;
|
||||
this.stream = {
|
||||
stream_id: traceIdentity("trace_stream", member.member_id),
|
||||
member_id: member.member_id,
|
||||
role: member.path.endsWith(".network") ? "network" : "trace",
|
||||
version: profileKnown ? 10 : null,
|
||||
state: profileKnown ? "supported" : "unknown",
|
||||
lines: 0,
|
||||
supported_events: 0,
|
||||
omitted_event_types: [],
|
||||
producer: null,
|
||||
};
|
||||
}
|
||||
diagnostic(line: number, code: string, message: string): void {
|
||||
this.input.inventory.reserve(1024);
|
||||
this.diagnostics.push({ line, code, message });
|
||||
}
|
||||
header(raw: unknown, line: number): void {
|
||||
const { inventory } = this.input;
|
||||
this.#headerSeen = true;
|
||||
if (!captureObject(raw) || raw.type !== "context-options") {
|
||||
this.diagnostic(
|
||||
line,
|
||||
"missing-context-header",
|
||||
"A trace member has no initial context-options record; its event version stays unknown.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const version = number(raw.version);
|
||||
if (version !== null && version !== 10)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
inventory.artifact.path,
|
||||
"Only Playwright trace version 10 is supported.",
|
||||
);
|
||||
if (version !== 10) {
|
||||
this.diagnostic(
|
||||
line,
|
||||
"unknown-version",
|
||||
"The context header does not declare numeric trace version 10.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.stream.version = 10;
|
||||
this.stream.state = "supported";
|
||||
this.stream.producer = {
|
||||
playwright_version: text(raw.playwrightVersion),
|
||||
browser_name: text(raw.browserName),
|
||||
origin: text(raw.origin),
|
||||
};
|
||||
inventory.retain(this.stream.producer);
|
||||
}
|
||||
record(line: { line: number; bytes: Buffer }): void {
|
||||
const { inventory, member } = this.input;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = readRecord(line.bytes);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof CaptureFormatError)) throw cause;
|
||||
if (cause.reason === "input-limit")
|
||||
throw new AnalysisResourceConstraintError(
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
"memory",
|
||||
cause.message,
|
||||
{ parser_nodes: PLAYWRIGHT_TRACE_LIMITS.factsBytes / 256 },
|
||||
{ cause },
|
||||
);
|
||||
this.diagnostic(line.line, "malformed-record", cause.message);
|
||||
this.#headerSeen = true;
|
||||
return;
|
||||
}
|
||||
if (this.stream.role === "trace" && !this.#headerSeen) {
|
||||
this.header(raw, line.line);
|
||||
return;
|
||||
}
|
||||
if (!captureObject(raw) || typeof raw.type !== "string") {
|
||||
this.diagnostic(
|
||||
line.line,
|
||||
"malformed-record",
|
||||
"An event must be an object with a type string.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (raw.type === "context-options") {
|
||||
this.stream.state = "unknown";
|
||||
this.diagnostic(
|
||||
line.line,
|
||||
"duplicate-context-header",
|
||||
"Repeated context headers make the remaining stream version unknown; later events are not interpreted.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let event: TraceEvent | undefined;
|
||||
try {
|
||||
event = projectTraceEvent(raw, {
|
||||
event_id: traceIdentity("trace_event", [member.member_id, line.line]),
|
||||
stream_id: this.stream.stream_id,
|
||||
ordinal: line.line - 1,
|
||||
source: {
|
||||
member_id: member.member_id,
|
||||
line: line.line,
|
||||
record_pointer: raw.type === "resource-snapshot" ? "/snapshot" : "",
|
||||
},
|
||||
});
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof CaptureFormatError)) throw cause;
|
||||
this.diagnostic(line.line, "malformed-record", cause.message);
|
||||
return;
|
||||
}
|
||||
if (event !== undefined) {
|
||||
inventory.reserve(event.resource_references.length * 512);
|
||||
inventory.retain(event);
|
||||
this.pending.push(event);
|
||||
const partial = hasPartialHeadline(event);
|
||||
if (partial)
|
||||
this.diagnostic(
|
||||
line.line,
|
||||
"partial-event",
|
||||
"Selected headline fields are unavailable; known facts remain retained without assuming absent values.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.omitted.has(raw.type))
|
||||
inventory.reserve(512 + raw.type.length * 12);
|
||||
this.omitted.set(raw.type, (this.omitted.get(raw.type) ?? 0) + 1);
|
||||
if (
|
||||
[
|
||||
"before",
|
||||
"after",
|
||||
"console",
|
||||
"resource-snapshot",
|
||||
"screencast-frame",
|
||||
].includes(raw.type)
|
||||
)
|
||||
this.diagnostic(
|
||||
line.line,
|
||||
"malformed-event",
|
||||
"A supported event is missing its required payload identity.",
|
||||
);
|
||||
}
|
||||
async read(): Promise<TraceStream> {
|
||||
const { inventory, reader, entry, member, budget, signal } = this.input;
|
||||
if (entry.kind !== "file" || entry.encrypted) {
|
||||
this.stream.state = "unavailable";
|
||||
inventory.diagnostic(
|
||||
member,
|
||||
null,
|
||||
"unavailable-stream",
|
||||
"A selected stream is encrypted or is not a regular archive member.",
|
||||
);
|
||||
return this.stream;
|
||||
}
|
||||
const content = await reader.open(entry, signal);
|
||||
for await (const line of traceLines(content, budget, signal)) {
|
||||
this.stream.lines = line.line;
|
||||
if (line.bytes.length === 0) continue;
|
||||
if (this.stream.role === "network" && this.stream.state !== "supported")
|
||||
continue;
|
||||
if (
|
||||
this.stream.role === "trace" &&
|
||||
this.#headerSeen &&
|
||||
this.stream.state !== "supported"
|
||||
)
|
||||
continue;
|
||||
this.record(line);
|
||||
}
|
||||
member.decoded = true;
|
||||
if (this.stream.version === null)
|
||||
inventory.diagnostic(
|
||||
member,
|
||||
null,
|
||||
"unknown-stream-version",
|
||||
"This stream has no unambiguous v10 trace profile; its events were not interpreted.",
|
||||
);
|
||||
for (const diagnostic of this.diagnostics)
|
||||
inventory.diagnostic(
|
||||
member,
|
||||
diagnostic.line,
|
||||
diagnostic.code,
|
||||
diagnostic.message,
|
||||
);
|
||||
this.stream.supported_events = this.pending.length;
|
||||
this.stream.omitted_event_types = Array.from(
|
||||
this.omitted,
|
||||
([type, count]) => ({ type, count }),
|
||||
);
|
||||
inventory.retain(this.stream);
|
||||
for (const event of this.pending) inventory.events.push(event);
|
||||
return this.stream;
|
||||
}
|
||||
}
|
||||
|
||||
export const readTraceStream = (input: TraceStreamInput) =>
|
||||
new TraceStreamRead(input).read();
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerPlaywrightTraceCommand } from "./playwrightTraceCommand.js";
|
||||
import { registerApiContractCommand } from "./apiContractCommand.js";
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
@@ -76,6 +77,7 @@ export const registerApplicationCommands = (
|
||||
registerSourceToBundleCommand(cli, logger);
|
||||
registerStaticObservedEndpointsCommand(cli, logger);
|
||||
registerApiContractCommand(cli, logger);
|
||||
registerPlaywrightTraceCommand(cli, logger);
|
||||
registerEvidenceJsonCommand({
|
||||
cli,
|
||||
logger,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { projectArtifactCloseFailure } from "../artifacts/StableArtifactFailure.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
PLAYWRIGHT_TRACE_OPERATION as operation,
|
||||
PLAYWRIGHT_TRACE_PROVIDER,
|
||||
} from "../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { playwrightTraceViewRequestSchema } from "../domain/playwrightTrace/playwrightTraceQuery.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import {
|
||||
analysisEvidenceDeliveryOptions,
|
||||
parseCliAnalysisEvidenceDelivery,
|
||||
deliverCliAnalysisEvidence,
|
||||
} from "./analysisEvidenceDelivery.js";
|
||||
|
||||
const deliverySchema = z
|
||||
.object({
|
||||
detail: analysisEvidenceDeliveryOptions.detail,
|
||||
view: playwrightTraceViewRequestSchema.optional(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.view !== undefined && value.detail !== "summary")
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["view"],
|
||||
message:
|
||||
"A selected page requires --detail summary and --evidence_output.",
|
||||
});
|
||||
});
|
||||
|
||||
/** Standard one-shot publication keeps complete inventory Evidence for later offline pages. */
|
||||
export const registerPlaywrightTraceCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.inspectPlaywrightTrace, {
|
||||
description:
|
||||
"Inventory local saved Playwright v10 trace ZIP events offline without launching a browser",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe("Local Playwright trace ZIP path"),
|
||||
}),
|
||||
options: z.object({
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
view: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSON playwright-trace page; requires --detail summary and --evidence_output",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectPlaywrightTrace, async () => {
|
||||
const selected = parseCliAnalysisEvidenceDelivery(
|
||||
operation,
|
||||
options,
|
||||
deliverySchema,
|
||||
);
|
||||
if (!selected.ok) return projectAnalysisError(selected.error);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const { PlaywrightTraceService } =
|
||||
await import("../application/playwrightTrace/PlaywrightTraceService.js");
|
||||
const { PlaywrightTraceDecoder } =
|
||||
await import("../browser/history/playwrightTrace/PlaywrightTraceDecoder.js");
|
||||
const service = new PlaywrightTraceService(
|
||||
new PlaywrightTraceDecoder(resources),
|
||||
);
|
||||
return deliverCliAnalysisEvidence({
|
||||
callerInputPath: args.path,
|
||||
run: () =>
|
||||
service.inspect(
|
||||
{
|
||||
path: absoluteCallerPath(args.path),
|
||||
detail: "complete",
|
||||
},
|
||||
signal,
|
||||
),
|
||||
service: {
|
||||
close: () =>
|
||||
resources.close().catch((cause: unknown) => {
|
||||
throw projectArtifactCloseFailure(
|
||||
cause,
|
||||
PLAYWRIGHT_TRACE_PROVIDER.id,
|
||||
operation,
|
||||
);
|
||||
}),
|
||||
},
|
||||
providerId: PLAYWRIGHT_TRACE_PROVIDER.id,
|
||||
operation,
|
||||
signal,
|
||||
detail: selected.value.detail,
|
||||
evidenceOutput: selected.value.evidenceOutput,
|
||||
...(selected.value.view === undefined
|
||||
? {}
|
||||
: { view: selected.value.view }),
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -69,6 +69,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
inspectIl2cppMetadata: "inspect-il2cpp-metadata",
|
||||
inspectModelArtifact: "inspect-model-artifact",
|
||||
inspectApiContract: "inspect-api-contract",
|
||||
inspectPlaywrightTrace: "inspect-playwright-trace",
|
||||
inspectGpuCodeObjects: "inspect-gpu-code-objects",
|
||||
inspectRustBinary: "inspect-rust-binary",
|
||||
inspectPythonPyc: "inspect-python-pyc",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PLAYWRIGHT_TRACE_TOOL_CONTRACTS } from "./playwrightTraceToolContracts.js";
|
||||
import { API_CONTRACT_TOOL_CONTRACTS } from "./apiContractToolContracts.js";
|
||||
import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js";
|
||||
import { z } from "zod";
|
||||
@@ -94,6 +95,7 @@ const retained = (evidence_id: string) => ({
|
||||
/** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */
|
||||
export const APPLICATION_TOOL_CONTRACTS = [
|
||||
...API_CONTRACT_TOOL_CONTRACTS,
|
||||
...PLAYWRIGHT_TRACE_TOOL_CONTRACTS,
|
||||
{
|
||||
name: "search_guidance",
|
||||
...toolContractMetadata("search_guidance"),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import { playwrightTraceSchema } from "../domain/playwrightTrace/playwrightTrace.js";
|
||||
import { inspectPlaywrightTraceInputSchema } from "../domain/playwrightTrace/playwrightTraceQuery.js";
|
||||
import {
|
||||
playwrightTraceSummaryViewResultSchema,
|
||||
playwrightTracePageViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
|
||||
export const PLAYWRIGHT_TRACE_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_playwright_trace",
|
||||
...toolContractMetadata("inspect_playwright_trace"),
|
||||
kind: "application",
|
||||
description:
|
||||
"Inspect a caller-selected Playwright v10 trace ZIP offline. No browser, replay or recorded-URL requests. Retains action boundaries, console text, network facts and screenshot/body references with archive/member/line identity. No cross-stream total order or complete-capture claim. Credentials use existing HAR/URL projection; context options, most action arguments/results, DOM and resource bytes are not analyzed. Default summary retains complete supported Evidence; view selects events/streams/members/diagnostics with batch IDs, fields and paging. Default text previews cover 256 UTF-16 units; event-text reads further exact text chunks and fields text returns the complete value. Archive/decoded-stream/fact allowances are 256/32/32 MiB; individual lines 4 MiB with bounded JSON node admission. One archive import is admitted at a time per decoder; retained views remain independent. Paging does not increase parsing allowances.",
|
||||
inputSchema: inspectPlaywrightTraceInputSchema,
|
||||
outputSchema: evidenceResultOf(
|
||||
z.union([
|
||||
playwrightTraceSchema,
|
||||
playwrightTraceSummaryViewResultSchema,
|
||||
playwrightTracePageViewResultSchema,
|
||||
]),
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Read recorded network headlines without contacting endpoints",
|
||||
input: {
|
||||
path: "/captures/trace.zip",
|
||||
view: {
|
||||
kind: "playwright-trace",
|
||||
collection: "events",
|
||||
kinds: ["network"],
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -159,6 +159,7 @@ const TOOL_EFFECT_AUDIT: Readonly<Record<string, EffectAudit>> = {
|
||||
inspect_il2cpp_metadata: evidence,
|
||||
inspect_model_artifact: evidence,
|
||||
inspect_api_contract: evidence,
|
||||
inspect_playwright_trace: evidence,
|
||||
inspect_gpu_code_objects: evidence,
|
||||
inspect_rust_binary: evidence,
|
||||
inspect_apk_signature: evidence,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
|
||||
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
|
||||
import type { AnalysisSettingLimit } from "./analysisSettings.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
@@ -400,6 +401,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| PlaywrightTrace
|
||||
| SavedAnalysisSnapshotPartialObservation
|
||||
| WebArtifactAcquisition
|
||||
| WebTraceObservation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectPlaywrightTraceView } from "./playwrightTraceView.js";
|
||||
import { projectApiContractView } from "./apiContractView.js";
|
||||
import {
|
||||
isNativeObservationOperation,
|
||||
@@ -159,6 +160,8 @@ import {
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
apiContractSummaryViewResultSchema,
|
||||
playwrightTraceSummaryViewResultSchema,
|
||||
playwrightTracePageViewResultSchema,
|
||||
apiContractPageViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
featureTraceSummaryViewResultSchema,
|
||||
@@ -232,6 +235,8 @@ export {
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
apiContractSummaryViewResultSchema,
|
||||
playwrightTraceSummaryViewResultSchema,
|
||||
playwrightTracePageViewResultSchema,
|
||||
apiContractPageViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
featureTraceSummaryViewResultSchema,
|
||||
@@ -362,6 +367,7 @@ const retainedViewProjectors = new Map<string, RetainedViewProjector>([
|
||||
retain("match_native_functions", projectNativeFunctionMatchingView),
|
||||
retain("compare_source_to_bundle", projectSourceToBundleView),
|
||||
retain("inspect_api_contract", projectApiContractView),
|
||||
retain("inspect_playwright_trace", projectPlaywrightTraceView),
|
||||
retain(
|
||||
"compare_static_and_observed_endpoints",
|
||||
projectEndpointComparisonView,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js";
|
||||
import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js";
|
||||
import {
|
||||
nativeObservationViewRequestSchema,
|
||||
@@ -76,6 +77,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
sourceToBundleViewRequestSchema,
|
||||
endpointComparisonViewRequestSchema,
|
||||
apiContractViewRequestSchema,
|
||||
playwrightTraceViewRequestSchema,
|
||||
featureTraceViewRequestSchema,
|
||||
nativeFeatureTraceViewRequestSchema,
|
||||
batchDecompileViewRequestSchema,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js";
|
||||
import {
|
||||
playwrightTraceViewContextSchema,
|
||||
playwrightTraceViewItemsSchema,
|
||||
} from "./playwrightTraceView.js";
|
||||
import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js";
|
||||
import { apiContractArtifactSchema } from "../apiContract/apiContract.js";
|
||||
import {
|
||||
@@ -1028,6 +1033,19 @@ export const sourceToBundleSummaryViewResultSchema = z.strictObject({
|
||||
parent_operation: z.literal("compare_source_to_bundle"),
|
||||
summary: sourceToBundleViewContextSchema,
|
||||
});
|
||||
export const playwrightTraceSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_playwright_trace"),
|
||||
summary: playwrightTraceViewContextSchema,
|
||||
});
|
||||
export const playwrightTracePageViewResultSchema = z.strictObject({
|
||||
...viewResultBase,
|
||||
...playwrightTraceViewContextSchema.shape,
|
||||
kind: z.literal("playwright-trace"),
|
||||
parent_operation: z.literal("inspect_playwright_trace"),
|
||||
view: playwrightTraceViewRequestSchema,
|
||||
items: playwrightTraceViewItemsSchema,
|
||||
});
|
||||
export const apiContractSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_api_contract"),
|
||||
@@ -1169,6 +1187,7 @@ const machoPageViewResultSchema = z
|
||||
|
||||
/** Projected facts for one selected view of retained analysis Evidence. */
|
||||
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
playwrightTracePageViewResultSchema,
|
||||
z.strictObject({
|
||||
...viewResultBase,
|
||||
...nativeViewSourceContext,
|
||||
@@ -1285,6 +1304,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
sourceToBundleSummaryViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
apiContractSummaryViewResultSchema,
|
||||
playwrightTraceSummaryViewResultSchema,
|
||||
featureTraceSummaryViewResultSchema,
|
||||
batchDecompileSummaryViewResultSchema,
|
||||
webBundleSummaryViewResultSchema,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
analysisTextPage,
|
||||
analysisTextPageItemSchema,
|
||||
} from "./analysisViewText.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
playwrightTraceSchema,
|
||||
traceEventSchema,
|
||||
traceSourceSchema,
|
||||
traceMemberSchema,
|
||||
traceStreamSchema,
|
||||
traceDiagnosticSchema,
|
||||
PLAYWRIGHT_TRACE_OPERATION,
|
||||
type PlaywrightTrace,
|
||||
} from "../playwrightTrace/playwrightTrace.js";
|
||||
import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { err, ok } from "../result.js";
|
||||
import {
|
||||
createAnalysisViewResultReader,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisViewParent.js";
|
||||
import {
|
||||
ownedView,
|
||||
type AdmittedView,
|
||||
type SummaryAnalysisView,
|
||||
} from "./analysisViewShape.js";
|
||||
import {
|
||||
analysisViewCoverageSchema,
|
||||
boundedViewMessages,
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
import { selectViewRows } from "./selectViewRows.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
|
||||
export const playwrightTraceViewContextSchema = playwrightTraceSchema
|
||||
.omit({
|
||||
streams: true,
|
||||
members: true,
|
||||
events: true,
|
||||
diagnostics: true,
|
||||
limitations: true,
|
||||
})
|
||||
.extend({
|
||||
counts: z.strictObject({
|
||||
streams: z.int().nonnegative(),
|
||||
members: z.int().nonnegative(),
|
||||
diagnostics: z.int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
export const playwrightTraceViewItemsSchema = z.union([
|
||||
z.array(
|
||||
analysisTextPageItemSchema.extend({
|
||||
event_id: traceEventSchema.shape.event_id,
|
||||
source: traceSourceSchema,
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
traceEventSchema
|
||||
.partial()
|
||||
.required({
|
||||
event_id: true,
|
||||
stream_id: true,
|
||||
ordinal: true,
|
||||
source: true,
|
||||
kind: true,
|
||||
credential_redacted: true,
|
||||
})
|
||||
.extend({
|
||||
text_preview: analysisTextPageItemSchema
|
||||
.extend({ coverage: analysisViewCoverageSchema })
|
||||
.nullable()
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
traceMemberSchema.partial().required({
|
||||
member_id: true,
|
||||
path: true,
|
||||
logical_path: true,
|
||||
kind: true,
|
||||
encrypted: true,
|
||||
decoded: true,
|
||||
limitations: true,
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
traceStreamSchema.partial().required({
|
||||
stream_id: true,
|
||||
member_id: true,
|
||||
role: true,
|
||||
version: true,
|
||||
state: true,
|
||||
}),
|
||||
),
|
||||
z.array(
|
||||
traceDiagnosticSchema.partial().required({
|
||||
diagnostic_id: true,
|
||||
member_id: true,
|
||||
line: true,
|
||||
code: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
const readReport = createAnalysisViewResultReader(playwrightTraceSchema);
|
||||
const values = (selected: readonly string[] | undefined) =>
|
||||
selected === undefined ? undefined : new Set(selected);
|
||||
const allows = (
|
||||
selection: ReadonlySet<string> | undefined,
|
||||
value: string | null,
|
||||
) => selection === undefined || (value !== null && selection.has(value));
|
||||
const fields = (row: object, selected: readonly string[]) =>
|
||||
Object.fromEntries(
|
||||
selected.map((key) => [key, (row as Record<string, JsonValue>)[key]!]),
|
||||
);
|
||||
type View = z.output<typeof playwrightTraceViewRequestSchema>;
|
||||
const eventFields = (
|
||||
row: PlaywrightTrace["events"][number],
|
||||
view: Extract<View, { collection: "events" }>,
|
||||
) => {
|
||||
const selected = view.fields?.filter((field) => field !== "text_preview") ?? [
|
||||
"call_id",
|
||||
"page_id",
|
||||
"time",
|
||||
"method",
|
||||
"url",
|
||||
"status",
|
||||
"selector",
|
||||
"resource_references",
|
||||
];
|
||||
const preview =
|
||||
view.fields === undefined || view.fields.includes("text_preview");
|
||||
let text_preview:
|
||||
| (z.output<typeof analysisTextPageItemSchema> & {
|
||||
coverage: z.output<typeof analysisViewCoverageSchema>;
|
||||
})
|
||||
| null = null;
|
||||
if (preview && row.text !== null) {
|
||||
const page = analysisTextPage(row.text, 0, 256, "Recorded event text");
|
||||
if (!page.ok) throw page.error;
|
||||
text_preview = { ...page.value.item, coverage: page.value.coverage };
|
||||
}
|
||||
return {
|
||||
...fields(row, selected),
|
||||
...(preview ? { text_preview } : {}),
|
||||
event_id: row.event_id,
|
||||
stream_id: row.stream_id,
|
||||
ordinal: row.ordinal,
|
||||
source: row.source,
|
||||
kind: row.kind,
|
||||
credential_redacted: row.credential_redacted,
|
||||
};
|
||||
};
|
||||
|
||||
const selectRows = (
|
||||
report: PlaywrightTrace,
|
||||
view: Exclude<View, { collection: "event-text" }>,
|
||||
) => {
|
||||
if (view.collection === "events") {
|
||||
const ids = values(view.event_ids),
|
||||
streams = values(view.stream_ids),
|
||||
kinds = values(view.kinds),
|
||||
calls = values(view.call_ids);
|
||||
return selectViewRows(
|
||||
report.events,
|
||||
view,
|
||||
(row) =>
|
||||
allows(ids, row.event_id) &&
|
||||
allows(streams, row.stream_id) &&
|
||||
allows(kinds, row.kind) &&
|
||||
allows(calls, row.call_id),
|
||||
(row) => eventFields(row, view),
|
||||
);
|
||||
}
|
||||
if (view.collection === "members") {
|
||||
const ids = values(view.member_ids),
|
||||
paths = values(view.paths);
|
||||
return selectViewRows(
|
||||
report.members,
|
||||
view,
|
||||
(row) => allows(ids, row.member_id) && allows(paths, row.path),
|
||||
(row) => ({
|
||||
...fields(
|
||||
row,
|
||||
view.fields ?? ["declared_bytes", "declared_compressed_bytes"],
|
||||
),
|
||||
member_id: row.member_id,
|
||||
path: row.path,
|
||||
logical_path: row.logical_path,
|
||||
kind: row.kind,
|
||||
encrypted: row.encrypted,
|
||||
decoded: row.decoded,
|
||||
limitations: row.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (view.collection === "streams") {
|
||||
const ids = values(view.stream_ids);
|
||||
return selectViewRows(
|
||||
report.streams,
|
||||
view,
|
||||
(row) => allows(ids, row.stream_id),
|
||||
(row) => ({
|
||||
...fields(
|
||||
row,
|
||||
view.fields ?? ["producer", "lines", "supported_events"],
|
||||
),
|
||||
stream_id: row.stream_id,
|
||||
member_id: row.member_id,
|
||||
role: row.role,
|
||||
version: row.version,
|
||||
state: row.state,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const codes = values(view.codes);
|
||||
return selectViewRows(
|
||||
report.diagnostics,
|
||||
view,
|
||||
(row) => allows(codes, row.code),
|
||||
(row) => ({
|
||||
...fields(row, view.fields ?? ["message"]),
|
||||
diagnostic_id: row.diagnostic_id,
|
||||
member_id: row.member_id,
|
||||
line: row.line,
|
||||
code: row.code,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const selectEventText = (
|
||||
report: PlaywrightTrace,
|
||||
view: Extract<View, { collection: "event-text" }>,
|
||||
) => {
|
||||
const event = report.events.find((row) => row.event_id === view.event_id);
|
||||
if (event === undefined || event.text === null)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "event_id"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Choose a retained event with interpreted text; events without a text value remain unknown.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const page = analysisTextPage(
|
||||
event.text,
|
||||
view.offset,
|
||||
view.limit,
|
||||
"Recorded event text",
|
||||
);
|
||||
if (!page.ok) return page;
|
||||
return ok({
|
||||
items: [
|
||||
{ ...page.value.item, event_id: event.event_id, source: event.source },
|
||||
],
|
||||
coverage: page.value.coverage,
|
||||
});
|
||||
};
|
||||
|
||||
/** Counts do not materialize stream/member/event arrays into the initial agent response. */
|
||||
export const projectPlaywrightTraceView = (
|
||||
parent: AnalysisViewParent,
|
||||
admitted: AdmittedView,
|
||||
) => {
|
||||
const view = ownedView<SummaryAnalysisView | View>(
|
||||
admitted,
|
||||
"summary",
|
||||
"playwright-trace",
|
||||
);
|
||||
if (view === undefined)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Playwright trace inventories support summary and playwright-trace pages.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = readReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const {
|
||||
streams,
|
||||
members,
|
||||
events: _events,
|
||||
diagnostics,
|
||||
limitations,
|
||||
...metadata
|
||||
} = parsed.value;
|
||||
const context = {
|
||||
...metadata,
|
||||
counts: {
|
||||
streams: streams.length,
|
||||
members: members.length,
|
||||
diagnostics: diagnostics.length,
|
||||
},
|
||||
};
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: PLAYWRIGHT_TRACE_OPERATION,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
limitations: boundedViewMessages(
|
||||
[...parent.limitations, ...limitations],
|
||||
parent.evidenceId,
|
||||
"limitations",
|
||||
),
|
||||
unknowns: [
|
||||
"Recording completeness and cross-stream ordering remain unknown. Resource contents are not decoded.",
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
...shared,
|
||||
kind: "summary" as const,
|
||||
view,
|
||||
artifact: {
|
||||
path: context.artifact.path,
|
||||
sha256: context.artifact.sha256,
|
||||
},
|
||||
summary: context,
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
});
|
||||
if (view.collection === "event-text") {
|
||||
const text = selectEventText(parsed.value, view);
|
||||
return text.ok
|
||||
? ok({
|
||||
...shared,
|
||||
...context,
|
||||
kind: "playwright-trace" as const,
|
||||
view,
|
||||
...text.value,
|
||||
})
|
||||
: text;
|
||||
}
|
||||
const selected = selectRows(parsed.value, view);
|
||||
return ok({
|
||||
...shared,
|
||||
...context,
|
||||
kind: "playwright-trace" as const,
|
||||
view,
|
||||
items: selected.rows,
|
||||
coverage: pageViewCoverage(
|
||||
view.offset,
|
||||
selected.rows.length,
|
||||
selected.total,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -35,6 +35,7 @@ export class ArtifactOperationError extends AnalysisError {
|
||||
| "inspect_elf_notes"
|
||||
| "inspect_devicetree"
|
||||
| "inspect_api_contract"
|
||||
| "inspect_playwright_trace"
|
||||
| "inspect_kernel_module"
|
||||
| "inspect_erlang_beam"
|
||||
| "extract_artifact"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema, prefixedDigestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
|
||||
export const PLAYWRIGHT_TRACE_OPERATION = "inspect_playwright_trace" as const;
|
||||
export const PLAYWRIGHT_TRACE_PROVIDER = {
|
||||
id: "rea-playwright-trace",
|
||||
name: "REA offline Playwright trace inventory",
|
||||
version: "1",
|
||||
} as const;
|
||||
/** Resource policies; these are not Playwright format limits. */
|
||||
export const PLAYWRIGHT_TRACE_LIMITS = {
|
||||
archiveBytes: 256 * 1024 * 1024,
|
||||
decodedBytes: 32 * 1024 * 1024,
|
||||
factsBytes: 32 * 1024 * 1024,
|
||||
metadataBytes: 4 * 1024 * 1024,
|
||||
lineBytes: 4 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export const traceMemberSchema = z.strictObject({
|
||||
member_id: prefixedDigestSchema("trace_member"),
|
||||
path: z.string().describe("Original ZIP member name reported by the reader"),
|
||||
logical_path: z.string().describe("Separate reader-normalized logical path"),
|
||||
kind: z.enum(["file", "directory", "symlink", "slice", "metadata"]),
|
||||
declared_bytes: z.int().nonnegative().nullable(),
|
||||
declared_compressed_bytes: z.int().nonnegative().nullable(),
|
||||
encrypted: z.boolean(),
|
||||
decoded: z.boolean(),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export const traceSourceSchema = z.strictObject({
|
||||
member_id: prefixedDigestSchema("trace_member"),
|
||||
line: z.int().positive(),
|
||||
record_pointer: z.string(),
|
||||
});
|
||||
export const traceResourceReferenceSchema = z.strictObject({
|
||||
path: z.string(),
|
||||
source_pointer: z.string(),
|
||||
state: z.enum(["present-unread", "missing", "ambiguous", "unavailable"]),
|
||||
member_id: traceMemberSchema.shape.member_id.nullable(),
|
||||
matching_members: z.int().nonnegative(),
|
||||
});
|
||||
export const traceEventSchema = z.strictObject({
|
||||
event_id: prefixedDigestSchema("trace_event"),
|
||||
stream_id: prefixedDigestSchema("trace_stream"),
|
||||
ordinal: z.int().nonnegative(),
|
||||
source: traceSourceSchema,
|
||||
kind: z.enum([
|
||||
"action-before",
|
||||
"action-after",
|
||||
"console",
|
||||
"network",
|
||||
"screenshot",
|
||||
]),
|
||||
call_id: z.string().nullable(),
|
||||
page_id: z.string().nullable(),
|
||||
time: z.number().finite().nullable(),
|
||||
method: z.string().nullable(),
|
||||
url: z.string().nullable(),
|
||||
status: z.number().finite().nullable(),
|
||||
text: z.string().nullable(),
|
||||
selector: z.string().nullable(),
|
||||
resource_references: z.array(traceResourceReferenceSchema),
|
||||
credential_redacted: z.boolean(),
|
||||
reported: jsonValueSchema,
|
||||
numeric_literals: z.array(
|
||||
z.strictObject({ pointer: z.string(), literal: z.string() }),
|
||||
),
|
||||
redactions: z.array(
|
||||
z.strictObject({ pointer: z.string(), reason: z.string() }),
|
||||
),
|
||||
});
|
||||
export const traceDiagnosticSchema = z.strictObject({
|
||||
diagnostic_id: prefixedDigestSchema("trace_diagnostic"),
|
||||
member_id: traceMemberSchema.shape.member_id.nullable(),
|
||||
line: z.int().positive().nullable(),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
export const traceStreamSchema = z.strictObject({
|
||||
stream_id: traceEventSchema.shape.stream_id,
|
||||
member_id: traceMemberSchema.shape.member_id,
|
||||
role: z.enum(["trace", "network"]),
|
||||
version: z.literal(10).nullable(),
|
||||
state: z.enum(["supported", "unknown", "unavailable"]),
|
||||
lines: z.int().nonnegative(),
|
||||
supported_events: z.int().nonnegative(),
|
||||
omitted_event_types: z.array(
|
||||
z.strictObject({ type: z.string(), count: z.int().nonnegative() }),
|
||||
),
|
||||
producer: z
|
||||
.strictObject({
|
||||
playwright_version: z.string().nullable(),
|
||||
browser_name: z.string().nullable(),
|
||||
origin: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export const playwrightTraceSchema = z.strictObject({
|
||||
kind: z.literal("playwright-trace"),
|
||||
trace_id: prefixedDigestSchema("playwright_trace"),
|
||||
decoder_profile: z.literal("playwright-trace-v10"),
|
||||
artifact: z.strictObject({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
bytes: z.int().nonnegative(),
|
||||
}),
|
||||
ordering: z.literal("per-stream-source-order-only"),
|
||||
streams: z.array(traceStreamSchema),
|
||||
members: z.array(traceMemberSchema),
|
||||
events: z.array(traceEventSchema),
|
||||
diagnostics: z.array(traceDiagnosticSchema),
|
||||
summary: z.strictObject({
|
||||
events: z.int().nonnegative(),
|
||||
action_before: z.int().nonnegative(),
|
||||
action_after: z.int().nonnegative(),
|
||||
console: z.int().nonnegative(),
|
||||
network: z.int().nonnegative(),
|
||||
screenshots: z.int().nonnegative(),
|
||||
unavailable_references: z.int().nonnegative(),
|
||||
}),
|
||||
source_coverage: z.strictObject({
|
||||
recording_completeness: z.literal("unknown"),
|
||||
interpretation: z.enum(["scoped", "partial"]),
|
||||
resource_contents: z.literal("not-decoded"),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type PlaywrightTrace = z.output<typeof playwrightTraceSchema>;
|
||||
export type TraceEvent = z.output<typeof traceEventSchema>;
|
||||
export type TraceMember = z.output<typeof traceMemberSchema>;
|
||||
export type TraceStream = z.output<typeof traceStreamSchema>;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import {
|
||||
traceDiagnosticSchema,
|
||||
traceEventSchema,
|
||||
traceMemberSchema,
|
||||
traceStreamSchema,
|
||||
} from "./playwrightTrace.js";
|
||||
const page = {
|
||||
kind: z.literal("playwright-trace"),
|
||||
offset: z.int().nonnegative().default(0),
|
||||
limit: z.int().positive().default(32),
|
||||
};
|
||||
export const playwrightTraceViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("event-text"),
|
||||
event_id: traceEventSchema.shape.event_id,
|
||||
limit: z.int().positive().default(2048),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("streams"),
|
||||
stream_ids: z.array(traceStreamSchema.shape.stream_id).optional(),
|
||||
fields: z.array(traceStreamSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("events"),
|
||||
event_ids: z.array(traceEventSchema.shape.event_id).optional(),
|
||||
stream_ids: z.array(traceEventSchema.shape.stream_id).optional(),
|
||||
kinds: z.array(traceEventSchema.shape.kind).optional(),
|
||||
call_ids: z.array(z.string()).optional(),
|
||||
fields: z
|
||||
.array(z.enum([...traceEventSchema.keyof().options, "text_preview"]))
|
||||
.optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("members"),
|
||||
member_ids: z.array(traceMemberSchema.shape.member_id).optional(),
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Exact original ZIP member names; logical_path is separate"),
|
||||
fields: z.array(traceMemberSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("diagnostics"),
|
||||
codes: z.array(z.string()).optional(),
|
||||
fields: z.array(traceDiagnosticSchema.keyof()).optional(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
export const inspectPlaywrightTraceInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute path to an offline Playwright v10 trace ZIP",
|
||||
),
|
||||
detail: z.enum(["summary", "complete"]).default("summary"),
|
||||
view: playwrightTraceViewRequestSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Return this selected page immediately; complete supported Evidence is retained",
|
||||
),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerPlaywrightTraceTool } from "./registerApplicationTools/playwrightTrace.js";
|
||||
import { registerApiContractTool } from "./registerApplicationTools/apiContract.js";
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
|
||||
@@ -27,6 +28,7 @@ export const registerApplicationTools = (
|
||||
registerCompareApplicationVersionsTool(server, options);
|
||||
registerCompareEndpointsTool(server, options);
|
||||
registerApiContractTool(server, options);
|
||||
registerPlaywrightTraceTool(server, options);
|
||||
registerCompareSourceToBundleTool(server, options);
|
||||
registerCompareJavaScriptExportShapesTool(server, options);
|
||||
registerReconstructionObligationLedgerTool(server, options);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PlaywrightTraceDecoder } from "../../browser/history/playwrightTrace/PlaywrightTraceDecoder.js";
|
||||
import type { EvidenceMcpServer } from "../EvidenceMcpServer.js";
|
||||
import { runAdmittedToolOperation } from "../admittedToolOperation.js";
|
||||
import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js";
|
||||
import { PlaywrightTraceService } from "../../application/playwrightTrace/PlaywrightTraceService.js";
|
||||
import { toolContract } from "../../contracts/toolContracts.js";
|
||||
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
|
||||
import { logToolExecution } from "../toolLogging.js";
|
||||
import { deliverApplicationEvidence } from "./helpers.js";
|
||||
import type { ApplicationToolRegistration } from "./types.js";
|
||||
|
||||
const contract = toolContract("inspect_playwright_trace");
|
||||
|
||||
/** Reads use the server's existing artifact resource owner and Evidence session. */
|
||||
export const registerPlaywrightTraceTool = (
|
||||
server: EvidenceMcpServer,
|
||||
options: ApplicationToolRegistration,
|
||||
): void => {
|
||||
const service = new PlaywrightTraceService(
|
||||
new PlaywrightTraceDecoder(options.artifactResources),
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) =>
|
||||
runAdmittedToolOperation(
|
||||
server,
|
||||
options.withAdmittedAnalysis,
|
||||
contract.name,
|
||||
context.mcpReq.signal,
|
||||
async () => {
|
||||
if (input.detail === "summary" || input.view !== undefined) {
|
||||
const retention = requireAnalysisViewRetention(
|
||||
contract.name,
|
||||
options,
|
||||
);
|
||||
if (!retention.ok)
|
||||
return server.delivery.toCallToolResult(retention, contract);
|
||||
}
|
||||
const result = await logToolExecution(
|
||||
options.logger,
|
||||
contract.name,
|
||||
() => service.inspect(input, context.mcpReq.signal),
|
||||
);
|
||||
if (!result.ok)
|
||||
return server.delivery.toCallToolResult(result, contract);
|
||||
return deliverApplicationEvidence(
|
||||
server,
|
||||
options,
|
||||
contract,
|
||||
{ evidence: result.value, sources: [] },
|
||||
input.view ??
|
||||
(input.detail === "summary" ? { kind: "summary" } : undefined),
|
||||
context.mcpReq.signal,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,433 @@
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||
import {
|
||||
playwrightTraceFixture,
|
||||
playwrightTracePathFixture,
|
||||
} from "../../fixtures/playwrightTrace.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
|
||||
import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
||||
import { playwrightTraceSchema } from "../../../src/domain/playwrightTrace/playwrightTrace.js";
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
|
||||
type McpCall = Awaited<ReturnType<typeof connectLocalToolsMcp>>["call"];
|
||||
|
||||
cliTest(
|
||||
"preserves original ZIP paths across CLI/MCP, resource lookup and exact member filters",
|
||||
async ({ cli }) => {
|
||||
const fixture = await playwrightTracePathFixture();
|
||||
const original = await readFile(fixture.path);
|
||||
const { call } = await connectLocalToolsMcp({
|
||||
REA_MCP_TOOL_FAMILIES: "application,session",
|
||||
PLAYWRIGHT_BROWSERS_PATH: "/rea-no-browser-required",
|
||||
});
|
||||
const parent = parseEvidence(
|
||||
(
|
||||
await call("inspect_playwright_trace", {
|
||||
path: fixture.path,
|
||||
detail: "complete",
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
const report = playwrightTraceSchema.parse(parent.normalized_result);
|
||||
expect(report.events).toHaveLength(4);
|
||||
expect(report.summary.unavailable_references).toBe(0);
|
||||
expect(report.streams.every((stream) => stream.state === "supported")).toBe(
|
||||
true,
|
||||
);
|
||||
const view = {
|
||||
kind: "playwright-trace",
|
||||
collection: "members",
|
||||
paths: ["./resources/x"],
|
||||
fields: [],
|
||||
};
|
||||
const selected = parseEvidence(
|
||||
(
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
},
|
||||
view,
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(selected.normalized_result).toMatchObject({
|
||||
kind: "playwright-trace",
|
||||
items: [
|
||||
{
|
||||
path: "./resources/x",
|
||||
logical_path: "resources/x",
|
||||
limitations: [expect.stringContaining("leading current-directory")],
|
||||
},
|
||||
],
|
||||
coverage: { total: 1, exhausted: true },
|
||||
source_coverage: { interpretation: "partial" },
|
||||
});
|
||||
expect(
|
||||
(await call("inspect_playwright_trace", { path: fixture.path, view }))
|
||||
.structuredContent,
|
||||
).toEqual(selected);
|
||||
const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli);
|
||||
const result = await run({
|
||||
arguments: ["inspect-playwright-trace", fixture.path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseEvidence(result.json)).toEqual({
|
||||
...parent,
|
||||
caller_input_path: fixture.path,
|
||||
});
|
||||
expect((await readFile(fixture.path)).equals(original)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
const checkCliPublication = async ({
|
||||
cli,
|
||||
fixture,
|
||||
parent,
|
||||
selected,
|
||||
view,
|
||||
call,
|
||||
}: {
|
||||
cli: TestCli;
|
||||
fixture: Awaited<ReturnType<typeof playwrightTraceFixture>>;
|
||||
parent: Evidence;
|
||||
selected: Evidence;
|
||||
view: Record<string, unknown>;
|
||||
call: McpCall;
|
||||
}) => {
|
||||
const source = { kind: "retained-evidence", evidence_id: parent.evidence_id };
|
||||
const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli);
|
||||
const fullCli = await run({
|
||||
arguments: ["inspect-playwright-trace", fixture.path, "--json"],
|
||||
});
|
||||
expect(fullCli.exitCode, fullCli.stderr).toBe(0);
|
||||
expect(parseEvidence(fullCli.json)).toEqual({
|
||||
...parent,
|
||||
caller_input_path: fixture.path,
|
||||
});
|
||||
const bundlePath = join(fixture.root, "retained.bundle.json");
|
||||
const receipt = await run({
|
||||
arguments: [
|
||||
"inspect-playwright-trace",
|
||||
fixture.path,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--view",
|
||||
JSON.stringify(view),
|
||||
"--evidence_output",
|
||||
bundlePath,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(receipt.exitCode, receipt.stderr).toBe(0);
|
||||
expect(receipt.json).toMatchObject({
|
||||
summary: {
|
||||
...selected,
|
||||
caller_input_path: fixture.path,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
evidenceBundleSchema.parse(JSON.parse(await readFile(bundlePath, "utf8")))
|
||||
.records,
|
||||
).toEqual([{ ...parent, caller_input_path: fixture.path }]);
|
||||
await rm(fixture.path);
|
||||
const retained = await call("inspect_analysis_view", { source, view });
|
||||
expect(parseEvidence(retained.structuredContent)).toEqual(selected);
|
||||
const request = join(fixture.root, "page.json");
|
||||
await writeFile(
|
||||
request,
|
||||
JSON.stringify({
|
||||
source: {
|
||||
kind: "evidence-file",
|
||||
path: bundlePath,
|
||||
evidence_id: parent.evidence_id,
|
||||
},
|
||||
view,
|
||||
}),
|
||||
);
|
||||
const fromBundle = await run({
|
||||
arguments: ["inspect-analysis-view", request, "--json"],
|
||||
});
|
||||
expect(fromBundle.exitCode, fromBundle.stderr).toBe(0);
|
||||
expect(parseEvidence(fromBundle.json)).toEqual(selected);
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"imports recorder v10 through actual CLI/MCP schemas with retained pages and publication parity",
|
||||
async ({ cli }) => {
|
||||
const fixture = await playwrightTraceFixture({ resources: true });
|
||||
const { client, call } = await connectLocalToolsMcp({
|
||||
REA_MCP_TOOL_FAMILIES: "application,session",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
PLAYWRIGHT_BROWSERS_PATH: "/rea-no-browser-required",
|
||||
});
|
||||
const schemas = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_playwright_trace",
|
||||
);
|
||||
expect(
|
||||
schemas.ajv.validate(schemas.inputSchema, { path: fixture.path }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
schemas.ajv.validate(schemas.inputSchema, {
|
||||
path: fixture.path,
|
||||
view: {
|
||||
kind: "playwright-trace",
|
||||
collection: "events",
|
||||
fields: ["unknown"],
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
const first = await call("inspect_playwright_trace", {
|
||||
path: fixture.path,
|
||||
});
|
||||
expect(first.isError, JSON.stringify(first.content)).not.toBe(true);
|
||||
const summary = analysisViewResultSchema.parse(
|
||||
parseEvidence(first.structuredContent).normalized_result,
|
||||
);
|
||||
expect(summary).toMatchObject({
|
||||
kind: "summary",
|
||||
summary: { summary: { events: 23 }, counts: { streams: 2 } },
|
||||
});
|
||||
expect(JSON.stringify(first.structuredContent)).not.toContain(
|
||||
"fixture loaded",
|
||||
);
|
||||
const complete = await call("inspect_playwright_trace", {
|
||||
path: fixture.path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(complete.isError).not.toBe(true);
|
||||
const parent = parseEvidence(complete.structuredContent);
|
||||
const report = playwrightTraceSchema.parse(parent.normalized_result);
|
||||
expect(summary.parent_evidence_id).toBe(parent.evidence_id);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
const rows = [];
|
||||
for (let offset = 0; offset < 23; offset += 5) {
|
||||
const page = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: {
|
||||
kind: "playwright-trace",
|
||||
collection: "events",
|
||||
offset,
|
||||
limit: 5,
|
||||
fields: Object.keys(report.events[0]!),
|
||||
},
|
||||
});
|
||||
expect(page.isError).not.toBe(true);
|
||||
const view = analysisViewResultSchema.parse(
|
||||
parseEvidence(page.structuredContent).normalized_result,
|
||||
);
|
||||
if (view.kind !== "playwright-trace")
|
||||
throw new Error("Expected trace page");
|
||||
rows.push(...view.items);
|
||||
expect(view.source_coverage).toEqual(report.source_coverage);
|
||||
}
|
||||
expect(rows).toEqual(report.events);
|
||||
const view = {
|
||||
kind: "playwright-trace",
|
||||
collection: "events",
|
||||
kinds: ["network"],
|
||||
fields: [],
|
||||
limit: 1,
|
||||
};
|
||||
const selected = await call("inspect_playwright_trace", {
|
||||
path: fixture.path,
|
||||
view,
|
||||
});
|
||||
expect(selected.isError).not.toBe(true);
|
||||
const selectedView = analysisViewResultSchema.parse(
|
||||
parseEvidence(selected.structuredContent).normalized_result,
|
||||
);
|
||||
expect(selectedView).toMatchObject({
|
||||
kind: "playwright-trace",
|
||||
items: [{ kind: "network", source: { record_pointer: "/snapshot" } }],
|
||||
});
|
||||
if (selectedView.kind !== "playwright-trace")
|
||||
throw new Error("Expected trace selection");
|
||||
expect(Object.keys(selectedView.items[0]!).sort()).toEqual([
|
||||
"credential_redacted",
|
||||
"event_id",
|
||||
"kind",
|
||||
"ordinal",
|
||||
"source",
|
||||
"stream_id",
|
||||
]);
|
||||
for (const filter of [
|
||||
{ event_ids: [] },
|
||||
{ stream_ids: [] },
|
||||
{ kinds: [] },
|
||||
{ call_ids: [] },
|
||||
]) {
|
||||
const response = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "playwright-trace", collection: "events", ...filter },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
).toMatchObject({ items: [], coverage: { total: 0, exhausted: true } });
|
||||
}
|
||||
await checkCliPublication({
|
||||
cli,
|
||||
fixture,
|
||||
parent,
|
||||
selected: parseEvidence(selected.structuredContent),
|
||||
view,
|
||||
call,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps console previews bounded and malformed siblings offline through public CLI/MCP",
|
||||
async ({ cli }) => {
|
||||
let requests = 0;
|
||||
const server = createServer((_request, response) => {
|
||||
requests++;
|
||||
response.end("must not be fetched");
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
onTestFinished(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
),
|
||||
);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Expected loopback listener");
|
||||
const base = await playwrightTraceFixture();
|
||||
const message = "雪🦊".repeat(2000);
|
||||
const recordedOrigin = new URL(
|
||||
JSON.parse(base.network.split("\n")[0]!).snapshot.request.url,
|
||||
).origin;
|
||||
const trace =
|
||||
base.trace.replaceAll(
|
||||
recordedOrigin,
|
||||
`http://127.0.0.1:${address.port}`,
|
||||
) +
|
||||
JSON.stringify({
|
||||
type: "console",
|
||||
messageType: "log",
|
||||
text: message,
|
||||
time: 123,
|
||||
}) +
|
||||
'\n{"type":"console","text":NaN}\n';
|
||||
const network = base.network.replaceAll(
|
||||
recordedOrigin,
|
||||
`http://127.0.0.1:${address.port}`,
|
||||
);
|
||||
const fixture = await playwrightTraceFixture({ trace, network });
|
||||
const { call } = await connectLocalToolsMcp({
|
||||
REA_MCP_TOOL_FAMILIES: "application,session",
|
||||
PLAYWRIGHT_BROWSERS_PATH: "/rea-no-browser-required",
|
||||
});
|
||||
const valid = await playwrightTraceFixture({
|
||||
trace: base.trace.replaceAll(
|
||||
recordedOrigin,
|
||||
`http://127.0.0.1:${address.port}`,
|
||||
),
|
||||
network,
|
||||
resources: true,
|
||||
});
|
||||
const control = await call("inspect_playwright_trace", {
|
||||
path: valid.path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(control.isError, JSON.stringify(control.content)).not.toBe(true);
|
||||
expect(
|
||||
playwrightTraceSchema.parse(
|
||||
parseEvidence(control.structuredContent).normalized_result,
|
||||
).source_coverage.interpretation,
|
||||
).toBe("scoped");
|
||||
const response = await call("inspect_playwright_trace", {
|
||||
path: fixture.path,
|
||||
view: {
|
||||
kind: "playwright-trace",
|
||||
collection: "events",
|
||||
kinds: ["console"],
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
const view = analysisViewResultSchema.parse(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
);
|
||||
expect(view).toMatchObject({
|
||||
kind: "playwright-trace",
|
||||
source_coverage: { interpretation: "partial" },
|
||||
items: [
|
||||
{
|
||||
text_preview: {
|
||||
unit: "utf16-code-units",
|
||||
coverage: { total: message.length, exhausted: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(response)).not.toContain(message);
|
||||
if (view.kind !== "playwright-trace")
|
||||
throw new Error("Expected text preview");
|
||||
const item = view.items[0];
|
||||
if (item === undefined || !("event_id" in item))
|
||||
throw new Error("Expected event identity");
|
||||
const chunk = await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: view.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "playwright-trace",
|
||||
collection: "event-text",
|
||||
event_id: item.event_id,
|
||||
offset: 256,
|
||||
limit: 2048,
|
||||
},
|
||||
});
|
||||
expect(chunk.isError, JSON.stringify(chunk.content)).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(chunk.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
items: [{ text: message.slice(256, 2304), unit: "utf16-code-units" }],
|
||||
coverage: { next_offset: 2304, exhausted: false },
|
||||
});
|
||||
|
||||
const full = await call("inspect_playwright_trace", {
|
||||
path: fixture.path,
|
||||
view: {
|
||||
kind: "playwright-trace",
|
||||
collection: "events",
|
||||
kinds: ["console"],
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
fields: ["text"],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseEvidence(full.structuredContent).normalized_result,
|
||||
).toMatchObject({ items: [{ text: message }] });
|
||||
const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli);
|
||||
const result = await run({
|
||||
arguments: ["inspect-playwright-trace", fixture.path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(
|
||||
playwrightTraceSchema.parse(parseEvidence(result.json).normalized_result)
|
||||
.summary.network,
|
||||
).toBe(3);
|
||||
expect(requests).toBe(0);
|
||||
},
|
||||
);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Owned Playwright v10 recorder fixture
|
||||
|
||||
These two files contain unchanged NDJSON bytes from an actual local Playwright
|
||||
1.64.1 recording on Linux, Node 24.22.1 and Chromium 156.0.8078.4. They are
|
||||
stored with `.txt` suffixes to preserve producer JSON spelling and framing.
|
||||
|
||||
The owned loopback application redirects to a page, logs `fixture loaded`, then
|
||||
loads JSON after a button click and logs its count. Requests intentionally use
|
||||
`Authorization: Bearer FAKE_TRACE_CREDENTIAL`; the response sets the deliberately
|
||||
fake `trace_session=FAKE_COOKIE` cookie. These are regression data, not real
|
||||
credentials. The ordinary `X-Trace-Note` value must remain available.
|
||||
|
||||
Recording used `context.tracing.start({screenshots:true,snapshots:true,sources:false})`,
|
||||
a new page, navigation through the redirect, one button click, a wait for the
|
||||
result text, a screenshot, a 100-ms wait, and `context.tracing.stop({path})`.
|
||||
The archive's SHA-256 was
|
||||
`7a7e0210f0c815225b7b65e93a37f350e2e44061cd8cf6d8170479b79c0f0b87`;
|
||||
it was 10,643 bytes with 11 members. The binary archive and image payloads are
|
||||
not committed.
|
||||
|
||||
- `trace.trace.txt`: 51 physical lines; 6 before/6 after action boundaries,
|
||||
2 console messages, 6 screencast references, 11 frame snapshots, 17 logs,
|
||||
one input event, one browser event, and one context header.
|
||||
- `trace.network.txt`: 3 resource-snapshot records, including the redirect and
|
||||
fetched JSON. The body references use v10 `_file` members.
|
||||
|
||||
`tests/fixtures/playwrightTrace.ts` reconstructs ZIPs from these original text
|
||||
streams. Optional resource members are **opaque text markers**: they test
|
||||
presence and ambiguity semantics and do not establish valid screenshots,
|
||||
response bodies or recorder compatibility beyond the supplied stream facts.
|
||||
Separate manual verification uses the original complete recorder archive.
|
||||
Tests also create clearly identified malformed and partial variants.
|
||||
The path-identity scenario constructs two v10 stream pairs and resource names
|
||||
with and without `./`; it is controlled relationship data, not another recorder capture.
|
||||
|
||||
The application and recording were created for this repository; no external
|
||||
application code, credentials or screenshot binaries are included.
|
||||
|
||||
Exact text identities:
|
||||
|
||||
| File | Bytes | SHA-256 |
|
||||
| ------------------- | -----: | ------------------------------------------------------------------ |
|
||||
| `trace.trace.txt` | 10,654 | `27ecb5b6fd8582fce16fd47f68f6d12a582d9c45b9c02e4a1d69f933d2c228b3` |
|
||||
| `trace.network.txt` | 5,981 | `cd7d35df7ea586f675c5e1818cc654d74722d8bb9f656843fa761e6e59406a20` |
|
||||
@@ -0,0 +1,3 @@
|
||||
{"type":"resource-snapshot","snapshot":{"pageref":"page@0dcd3fc52027f9b8ab1fe8ed50847967","startedDateTime":"2026-11-06T07:18:16.514Z","time":16.483,"request":{"method":"GET","url":"http://127.0.0.1:43883/redirect","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:43883"},{"name":"Sec-Fetch-Dest","value":"document"},{"name":"Sec-Fetch-Mode","value":"navigate"},{"name":"Sec-Fetch-Site","value":"none"},{"name":"Sec-Fetch-User","value":"?1"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36"},{"name":"sec-ch-ua","value":"\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"HeadlessChrome\";v=\"156\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":302,"statusText":"Found","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Date","value":"Fri, 06 Nov 2026 07:18:16 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"location","value":"/"}],"content":{"size":-1,"mimeType":"x-unknown"},"headersSize":-1,"bodySize":-1,"redirectURL":"http://127.0.0.1:43883/","_transferSize":-1},"cache":{},"timings":{"dns":0.064,"connect":1.003,"ssl":4.839,"send":0,"wait":2.171,"receive":8.406},"_frameref":"frame@cd1850d4c52e8087c375b939d7372467","_monotonicTime":988.341,"_resourceType":"document","serverIPAddress":"127.0.0.1","_serverPort":43883,"_securityDetails":{}}}
|
||||
{"type":"resource-snapshot","snapshot":{"pageref":"page@0dcd3fc52027f9b8ab1fe8ed50847967","startedDateTime":"2026-11-06T07:18:16.529Z","time":3.093,"request":{"method":"GET","url":"http://127.0.0.1:43883/","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:43883"},{"name":"Sec-Fetch-Dest","value":"document"},{"name":"Sec-Fetch-Mode","value":"navigate"},{"name":"Sec-Fetch-Site","value":"none"},{"name":"Sec-Fetch-User","value":"?1"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36"},{"name":"sec-ch-ua","value":"\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"HeadlessChrome\";v=\"156\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":639,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Date","value":"Fri, 06 Nov 2026 07:18:16 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"content-type","value":"text/html"}],"content":{"size":484,"mimeType":"text/html","compression":0,"_file":"resources/7f55d0c7c0c54857e59ff1bf742dbede400cb502.html"},"headersSize":156,"bodySize":496,"redirectURL":"","_transferSize":652},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":1.948,"receive":1.145},"_frameref":"frame@cd1850d4c52e8087c375b939d7372467","_monotonicTime":1001.778,"_resourceType":"document","serverIPAddress":"127.0.0.1","_serverPort":43883,"_securityDetails":{}}}
|
||||
{"type":"resource-snapshot","snapshot":{"pageref":"page@0dcd3fc52027f9b8ab1fe8ed50847967","startedDateTime":"2026-11-06T07:18:16.668Z","time":2.638,"request":{"method":"GET","url":"http://127.0.0.1:43883/api/value","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Authorization","value":"Bearer FAKE_TRACE_CREDENTIAL"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:43883"},{"name":"Referer","value":"http://127.0.0.1:43883/"},{"name":"Sec-Fetch-Dest","value":"empty"},{"name":"Sec-Fetch-Mode","value":"cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36"},{"name":"X-Trace-Note","value":"ordinary caller text"},{"name":"sec-ch-ua","value":"\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"HeadlessChrome\";v=\"156\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":571,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[{"name":"trace_session","value":"FAKE_COOKIE","path":"/"}],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Date","value":"Fri, 06 Nov 2026 07:18:16 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"content-type","value":"application/json"},{"name":"set-cookie","value":"trace_session=FAKE_COOKIE; Path=/"}],"content":{"size":35,"mimeType":"application/json","compression":0,"_file":"resources/4dbd8adef8cfcd7b3f50fc99f2aa379f4d8423de.json"},"headersSize":210,"bodySize":46,"redirectURL":"","_transferSize":256},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":0.55,"receive":2.088},"_frameref":"frame@cd1850d4c52e8087c375b939d7372467","_monotonicTime":1142.967,"_resourceType":"fetch","serverIPAddress":"127.0.0.1","_serverPort":43883,"_securityDetails":{}}}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
{"version":10,"type":"context-options","origin":"library","browserName":"chromium","playwrightVersion":"1.64.1","options":{"noDefaultViewport":false,"selectorEngines":[],"acceptDownloads":"accept","viewport":{"width":1280,"height":720}},"platform":"linux","wallTime":1793949496297,"monotonicTime":767.965,"sdkLanguage":"javascript","testIdAttributeName":"data-testid"}
|
||||
{"type":"before","callId":"fnpx@6","startTime":770.631,"class":"BrowserContext","method":"newPage","params":{}}
|
||||
{"type":"event","time":962.87,"class":"BrowserContext","method":"page","params":{"pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967"}}
|
||||
{"type":"after","callId":"fnpx@6","endTime":962.959,"result":{"page":"<Page>"}}
|
||||
{"type":"before","callId":"fnpx@8","startTime":965.977,"class":"Frame","method":"goto","params":{"url":"http://127.0.0.1:43883/redirect","waitUntil":"load","timeout":30000}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@8","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"about:blank","html":["HTML",{},["HEAD",{},["BASE",{"href":"about:blank"}]],["BODY"]],"viewport":{"width":1280,"height":720},"timestamp":975.83,"wallTime":1793949496503,"collectionTime":6.199999988079071,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"log","callId":"fnpx@8","time":977.163,"message":"navigating to \"http://127.0.0.1:43883/redirect\", waiting until \"load\""}
|
||||
{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/[email protected]","width":1280,"height":720,"timestamp":993.356,"frameSwapWallTime":1793949496494.3298}
|
||||
{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/[email protected]","width":1280,"height":720,"timestamp":994.803,"frameSwapWallTime":1793949496515.41}
|
||||
{"type":"console","messageType":"log","text":"fixture loaded","args":[{"preview":"fixture loaded","value":"fixture loaded"}],"location":{"url":"http://127.0.0.1:43883/","lineNumber":0,"columnNumber":157},"time":1025.079,"pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967"}
|
||||
{"type":"after","callId":"fnpx@8","endTime":1040.379,"result":{"response":"<Response>"}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@8","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},["HEAD",{},["BASE",{"href":"http://127.0.0.1:43883/"}],["META",{"charset":"utf-8"}],["TITLE",{},"REA owned trace fixture"]],["BODY",{},["BUTTON",{"id":"load"},"Load value"],["OUTPUT",{"id":"result"}]]],"viewport":{"width":1280,"height":720},"timestamp":1048.948,"wallTime":1793949496578,"collectionTime":3.899999976158142,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"before","callId":"fnpx@10","startTime":1050.33,"class":"Frame","method":"click","params":{"selector":"internal:role=button[name=\"Load value\"i]","strict":true,"timeout":30000}}
|
||||
{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/[email protected]","width":1280,"height":720,"timestamp":1054.155,"frameSwapWallTime":1793949496582.074}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@10","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[1,9]],"viewport":{"width":1280,"height":720},"timestamp":1056.269,"wallTime":1793949496585,"collectionTime":0.20000004768371582,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"log","callId":"fnpx@10","time":1058.209,"message":"waiting for getByRole('button', { name: 'Load value' })"}
|
||||
{"type":"log","callId":"fnpx@10","time":1085.066,"message":" locator resolved to <button id=\"load\">Load value</button>"}
|
||||
{"type":"log","callId":"fnpx@10","time":1086.221,"message":"attempting click action"}
|
||||
{"type":"log","callId":"fnpx@10","time":1086.649,"message":" waiting for element to be visible, enabled and stable"}
|
||||
{"type":"log","callId":"fnpx@10","time":1121.065,"message":" element is visible, enabled and stable"}
|
||||
{"type":"log","callId":"fnpx@10","time":1121.088,"message":" scrolling into view if needed"}
|
||||
{"type":"log","callId":"fnpx@10","time":1122.588,"message":" done scrolling"}
|
||||
{"type":"input","callId":"fnpx@10","point":{"x":48.6,"y":18.5},"box":{"x":8,"y":8,"width":81.21875,"height":21}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@10","phase":"action","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},[[2,4]],["BODY",{},["BUTTON",{"__playwright_target__":"","id":"load"},[[2,5]]],[[2,7]]]],"viewport":{"width":1280,"height":720},"timestamp":1125.868,"wallTime":1793949496654,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"log","callId":"fnpx@10","time":1131.318,"message":" performing click action"}
|
||||
{"type":"log","callId":"fnpx@10","time":1143.761,"message":" click action done"}
|
||||
{"type":"log","callId":"fnpx@10","time":1143.782,"message":" waiting for scheduled navigations to finish"}
|
||||
{"type":"console","messageType":"info","text":"loaded value 3","args":[{"preview":"loaded value","value":"loaded value"},{"preview":"3","value":3}],"location":{"url":"http://127.0.0.1:43883/","lineNumber":0,"columnNumber":445},"time":1152.774,"pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967"}
|
||||
{"type":"log","callId":"fnpx@10","time":1153.049,"message":" navigations have finished"}
|
||||
{"type":"after","callId":"fnpx@10","endTime":1153.154}
|
||||
{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/[email protected]","width":1280,"height":720,"timestamp":1153.487,"frameSwapWallTime":1793949496682.071}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@10","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},[[3,4]],["BODY",{},[[1,0]],["OUTPUT",{"id":"result"},"café ready"]]],"viewport":{"width":1280,"height":720},"timestamp":1155.716,"wallTime":1793949496683,"collectionTime":0.09999996423721313,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"before","callId":"fnpx@12","startTime":1156.385,"class":"Frame","method":"waitForSelector","params":{"selector":"internal:text=\"café ready\"s","strict":true,"omitReturnValue":true,"timeout":30000}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@12","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},[[4,4]],["BODY",{},["BUTTON",{"id":"load"},[[4,5]]],[[1,1]]]],"viewport":{"width":1280,"height":720},"timestamp":1161.717,"wallTime":1793949496690,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"log","callId":"fnpx@12","time":1162.482,"message":"waiting for getByText('café ready', { exact: true }) to be visible"}
|
||||
{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/[email protected]","width":1280,"height":720,"timestamp":1163.201,"frameSwapWallTime":1793949496698.7368}
|
||||
{"type":"log","callId":"fnpx@12","time":1165.406,"message":" locator resolved to visible <output id=\"result\">café ready</output>"}
|
||||
{"type":"after","callId":"fnpx@12","endTime":1165.481,"result":{}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@12","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[1,2]],"viewport":{"width":1280,"height":720},"timestamp":1166.097,"wallTime":1793949496695,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"before","callId":"fnpx@14","startTime":1166.805,"class":"Page","method":"screenshot","params":{"timeout":30000}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@14","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[2,2]],"viewport":{"width":1280,"height":720},"timestamp":1167.437,"wallTime":1793949496696,"collectionTime":0.10000002384185791,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"log","callId":"fnpx@14","time":1167.847,"message":"taking page screenshot"}
|
||||
{"type":"log","callId":"fnpx@14","time":1168.964,"message":"waiting for fonts to load..."}
|
||||
{"type":"log","callId":"fnpx@14","time":1170.096,"message":"fonts loaded"}
|
||||
{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/[email protected]","width":1280,"height":720,"timestamp":1192.358,"frameSwapWallTime":1793949496715.4019}
|
||||
{"type":"after","callId":"fnpx@14","endTime":1209.436,"result":{"binary":"<Buffer>"}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@14","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[3,2]],"viewport":{"width":1280,"height":720},"timestamp":1210.204,"wallTime":1793949496739,"collectionTime":0.09999996423721313,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"before","callId":"fnpx@16","startTime":1210.951,"class":"Frame","method":"waitForTimeout","params":{"waitTimeout":100}}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@16","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[4,2]],"viewport":{"width":1280,"height":720},"timestamp":1211.611,"wallTime":1793949496740,"collectionTime":0.20000004768371582,"resourceOverrides":[],"isMainFrame":true}}
|
||||
{"type":"after","callId":"fnpx@16","endTime":1312.319}
|
||||
{"type":"frame-snapshot","snapshot":{"callId":"fnpx@16","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[5,2]],"viewport":{"width":1280,"height":720},"timestamp":1313.434,"wallTime":1793949496842,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}}
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
import { ZipWriter, Uint8ArrayWriter, TextReader } from "@zip.js/zip.js";
|
||||
import { writeFile, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
||||
|
||||
/** Assemble actual recorder text; opaque resource markers deliberately do not claim image validity. */
|
||||
export const playwrightTraceFixture = async (
|
||||
options: {
|
||||
resources?: boolean;
|
||||
trace?: string;
|
||||
network?: string;
|
||||
level?: 0 | 6;
|
||||
extraMembers?: readonly { path: string; contents: string }[];
|
||||
} = {},
|
||||
) => {
|
||||
const trace =
|
||||
options.trace ??
|
||||
(await readFile(
|
||||
new URL("./playwright-trace/trace.trace.txt", import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
const network =
|
||||
options.network ??
|
||||
(await readFile(
|
||||
new URL("./playwright-trace/trace.network.txt", import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
const root = await createTestTempDirectory("rea-playwright-trace-");
|
||||
const path = join(root, "trace.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter(), {
|
||||
useWebWorkers: false,
|
||||
});
|
||||
const compression =
|
||||
options.level === undefined ? {} : { level: options.level };
|
||||
await writer.add("trace.trace", new TextReader(trace), compression);
|
||||
await writer.add("trace.network", new TextReader(network), compression);
|
||||
if (options.resources) {
|
||||
const paths = new Set<string>();
|
||||
for (const line of trace.split("\n").filter(Boolean)) {
|
||||
const row = JSON.parse(line);
|
||||
if (row.type === "screencast-frame" && typeof row.file === "string")
|
||||
paths.add(row.file);
|
||||
}
|
||||
for (const line of network.split("\n").filter(Boolean)) {
|
||||
const row = JSON.parse(line);
|
||||
for (const field of [
|
||||
row.snapshot?.request?.postData,
|
||||
row.snapshot?.response?.content,
|
||||
])
|
||||
if (typeof field?._file === "string") paths.add(field._file);
|
||||
}
|
||||
for (const name of paths)
|
||||
await writer.add(
|
||||
name,
|
||||
new TextReader(
|
||||
"Opaque fixture resource: bytes are not interpreted as an image or body.",
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const member of options.extraMembers ?? [])
|
||||
await writer.add(member.path, new TextReader(member.contents));
|
||||
await writeFile(path, await writer.close());
|
||||
return { root, path, trace, network };
|
||||
};
|
||||
|
||||
/** Controlled v10 relationships with distinct source names sharing logical paths. */
|
||||
export const playwrightTracePathFixture = () => {
|
||||
const trace = (resource: string) =>
|
||||
[
|
||||
{ type: "context-options", version: 10 },
|
||||
{ type: "screencast-frame", file: resource, timestamp: 1 },
|
||||
]
|
||||
.map((row) => JSON.stringify(row))
|
||||
.join("\n") + "\n";
|
||||
const network = (resource: string, status: number) =>
|
||||
JSON.stringify({
|
||||
type: "resource-snapshot",
|
||||
snapshot: {
|
||||
request: { method: "GET", url: "http://captured.invalid/value" },
|
||||
response: { status, content: { _file: resource } },
|
||||
},
|
||||
}) + "\n";
|
||||
return playwrightTraceFixture({
|
||||
resources: true,
|
||||
trace: trace("resources/x"),
|
||||
network: network("resources/x", 200),
|
||||
extraMembers: [
|
||||
{ path: "./trace.trace", contents: trace("./resources/x") },
|
||||
{ path: "./trace.network", contents: network("./resources/x", 201) },
|
||||
{ path: "./resources/x", contents: "Alternate unread resource bytes" },
|
||||
],
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user