mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
feat(environment): build environments from browser scenario captures
The environment-manifest view, environment-serve and environment-export accepted only inspect_web_network_capture HAR Evidence, so an environment needed an external capture script and HAR scrubbing step. They now also accept capture_browser_scenario Evidence: a session REA records in a browser it launches, with credential headers and declared secrets redacted. Each network transaction becomes one HAR-shaped record in request order, so the manifest accumulator, the replay and the bundle work unchanged. A redacted URL is never templated, an unattributable transaction is left unrepresented, and a scenario that selected neither response bodies nor header values is refused because tools are chosen by response media type. The bundle README names the capture kind and, for a scenario, the scenario definition's SHA-256. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,75 @@ The third command serves that manifest. Both served files are complete REA
|
||||
Evidence JSON as the commands write it with `--format json`; the `evidence`
|
||||
member of `view.json` is the whole of `capture.json`.
|
||||
|
||||
## From a browser scenario
|
||||
|
||||
The capture can also come from REA itself: a
|
||||
[browser scenario](browser-scenario-contract.md) records a scripted session
|
||||
in a browser REA launches, and its Evidence is a manifest parent like a HAR
|
||||
inspection. Select the `network` event family and response bodies, and raise
|
||||
the body budget to the 256 KiB a replayed reply can carry:
|
||||
|
||||
```json
|
||||
{
|
||||
"browser": { "mode": "launch", "executable_path": "/usr/bin/chromium" },
|
||||
"start_url": { "url": "http://127.0.0.1:5230/auth", "query": [] },
|
||||
"secrets": [
|
||||
{ "secret_id": "admin_password", "environment_variable": "APP_PASSWORD" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"step_id": "user",
|
||||
"action": "fill",
|
||||
"locator": { "kind": "css", "selector": "input[name=username]" },
|
||||
"value": { "source": "literal", "value": "admin" }
|
||||
},
|
||||
{
|
||||
"step_id": "password",
|
||||
"action": "fill",
|
||||
"locator": { "kind": "css", "selector": "input[type=password]" },
|
||||
"value": { "source": "secret", "secret_id": "admin_password" }
|
||||
},
|
||||
{
|
||||
"step_id": "sign-in",
|
||||
"action": "press",
|
||||
"locator": { "kind": "css", "selector": "input[type=password]" },
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"step_id": "first-memo",
|
||||
"action": "goto",
|
||||
"destination": { "url": "http://127.0.0.1:5230/m/1", "query": [] },
|
||||
"wait_until": "load"
|
||||
}
|
||||
],
|
||||
"capture": {
|
||||
"events": ["network"],
|
||||
"network": {
|
||||
"request_body": true,
|
||||
"response_body": true,
|
||||
"header_values": false
|
||||
},
|
||||
"limits": { "max_network_body_bytes": 262144 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
APP_PASSWORD=… rea capture-browser-scenario scenario.json --format json > capture.json
|
||||
rea inspect-analysis-view view.json --format json > manifest.json # view.json wraps capture.json as before
|
||||
rea environment-serve manifest.json --evidence capture.json --port 8765
|
||||
```
|
||||
|
||||
Each network transaction becomes one record, in request order, and the
|
||||
record's ordinal is its zero-based position; a transaction whose URL carried a
|
||||
redacted secret is left unrepresented rather than templated, and one the
|
||||
capture cannot attribute to a single request and response is left
|
||||
unrepresented too. Tools are chosen by response media type, so a scenario that
|
||||
selected neither response bodies nor header values is refused. The browser
|
||||
scenario redacts credential headers and the secrets it declares; values the
|
||||
application returns in response bodies, such as session tokens, are kept, and
|
||||
the replayed body is the bytes the browser exposes for each response.
|
||||
|
||||
## What a manifest is
|
||||
|
||||
An environment manifest is a projection of one application's recorded request
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Implementation plan
|
||||
|
||||
1. Add `src/domain/analysisView/scenarioCaptureRecords.ts`: index scenario
|
||||
events by transaction, project each request event as a HAR-shaped
|
||||
`WebNetworkCaptureRecord`, and admit a scenario parent for the manifest
|
||||
view (clone budget, schema, network-content selection).
|
||||
2. Route `projectEnvironmentManifestView` through one capture-parent gate that
|
||||
selects the HAR or scenario admission by parent operation, and widen the
|
||||
environment-manifest result's `parent_operation`.
|
||||
3. Branch `parseCaptureEvidence` in `environmentSource.ts` on the Evidence
|
||||
operation, carry the capture kind and digest on the source, and word the
|
||||
bundle README and CLI help for both kinds.
|
||||
4. Add a schema-valid scenario capture fixture and domain and services tests:
|
||||
the record projection, the manifest from a scenario parent, the refusal,
|
||||
replay of UTF-8 and binary bodies, and the bundle README.
|
||||
5. Document scenario parents in `docs/agent-environments.md` and
|
||||
`docs/web-network-captures.md`, extend the tool contract text, and record
|
||||
the spec.
|
||||
|
||||
No new dependency is introduced and no command is added.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Build environments from browser scenario captures
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
The `environment-manifest` view, `environment-serve` and `environment-export`
|
||||
accept `capture_browser_scenario` Evidence as the manifest's parent, in
|
||||
addition to `inspect_web_network_capture` HAR Evidence. A session REA records
|
||||
itself, in a browser it launches, with credential headers and declared secrets
|
||||
redacted, becomes an agent environment without an external capture script or
|
||||
HAR scrubbing step. The replay, the tool surface and the bundle layout do not
|
||||
change.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A scenario parent is admitted only when its scenario selected
|
||||
`capture.network.response_body` or `header_values`, because tools are chosen
|
||||
by response media type; otherwise the view refuses with
|
||||
`unsupported_target`.
|
||||
- Each network transaction becomes one record in request order; its ordinal
|
||||
is the zero-based position of its `request` event, and its location is the
|
||||
JSON pointer of that event in the capture. The record is shaped like a HAR
|
||||
entry: request method and URL, the request body as `postData` when it was
|
||||
captured and decodes as UTF-8, the response status, and the response media
|
||||
type with its retained bytes as base64 content and a retained binary field.
|
||||
- A transaction whose URL the scenario redacted keeps a null URL, so no path
|
||||
template is formed from a redaction marker. A transaction that does not
|
||||
resolve to exactly one request event, at most one response event and at most
|
||||
one content event per phase keeps no request line. Both are counted as
|
||||
unrepresented records, as HAR records without a usable request line are.
|
||||
- The response media type is the captured body's `media_type`, or the
|
||||
`content-type` header value when only headers were captured.
|
||||
- The manifest result names `parent_operation: "capture_browser_scenario"`,
|
||||
carries an empty `artifact` (`path: ""`, `sha256: null`) and states, as
|
||||
limitations, the ordinal rule, any dropped capture events and how many
|
||||
transactions retained no response body.
|
||||
- `environment-serve` and `environment-export` authenticate either parent
|
||||
kind through the same loader, project scenario transactions through the same
|
||||
adapter, and keep the cross-check that the manifest's parent is the supplied
|
||||
Evidence. The bundle README names the capture kind and, for a scenario, the
|
||||
SHA-256 of the scenario definition in place of a capture artifact digest.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new browser action: scenarios still cover what the UI requests plus GET
|
||||
reads through `goto`. A fetch or script action would be a new effect and
|
||||
needs its own scope decision.
|
||||
- No change to what the scenario redacts. Values the application returns in
|
||||
response bodies are retained, as they are in HAR captures.
|
||||
- The api-surface view keeps HAR parents only.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the scenario-parent contract, requirements and non-goals.
|
||||
- [x] Project scenario transactions as HAR-shaped records and admit scenario parents.
|
||||
- [x] Route the manifest view through one capture-parent gate and widen its result schema.
|
||||
- [x] Load scenario Evidence in serve and export; name the capture kind in the README and help.
|
||||
- [x] Add the scenario capture fixture and domain and services tests.
|
||||
- [x] Document scenario parents and extend the tool contract text.
|
||||
- [ ] Run static, formatting, duplication, documentation and test gates.
|
||||
- [ ] Build environments from real browser scenario captures and record the results.
|
||||
@@ -328,6 +328,13 @@ whose content types are JSON-like. Every other group is listed under
|
||||
`excluded` with reason `other-origin`, `ui-page-or-asset` or `non-json`, and
|
||||
retained records without a usable request line are counted in a limitation.
|
||||
|
||||
The parent may instead be `capture_browser_scenario` Evidence whose scenario
|
||||
selected response bodies or header values: each network transaction is then
|
||||
one record, in request order, and the result names
|
||||
`parent_operation: "capture_browser_scenario"` with an empty `artifact`, since
|
||||
a scenario records no file. See
|
||||
[agent environments](agent-environments.md#from-a-browser-scenario).
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_analysis_view",
|
||||
|
||||
@@ -220,13 +220,13 @@ export const bundleReadme = (
|
||||
: `calls \`${plan.check.tool}\` and checks the replayed \`status\` equals the recorded ${String(plan.check.status)} of record ${String(plan.check.recordOrdinal)}`;
|
||||
return `# ${plan.name}
|
||||
|
||||
An agent environment exported by REA from one retained web network capture.
|
||||
An agent environment exported by REA from one retained ${source.captureKind === "browser-scenario" ? "browser scenario capture" : "web network capture"}.
|
||||
Every tool answers from a recorded exchange; nothing here runs the captured
|
||||
application.
|
||||
|
||||
## Provenance
|
||||
|
||||
- Capture artifact SHA-256: \`${source.captureSha256 ?? "excluded from Evidence"}\`
|
||||
- ${source.captureKind === "browser-scenario" ? "Scenario definition" : "Capture artifact"} SHA-256: \`${source.captureSha256 ?? "excluded from Evidence"}\`
|
||||
- Capture Evidence (the manifest's parent): \`${source.evidenceId}\`
|
||||
- Manifest Evidence: \`${source.manifestEvidenceId}\`
|
||||
- Manifest view digest: \`${manifest.view_digest}\`
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
scenarioCaptureEvidence,
|
||||
scenarioCaptureFixture,
|
||||
scenarioServeFixture,
|
||||
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||
import {
|
||||
planEnvironmentBundle,
|
||||
registryPackageSource,
|
||||
} from "./environmentBundle.js";
|
||||
import { replayEnvironmentTool } from "./environmentReplay.js";
|
||||
import { loadEnvironmentSource } from "./environmentSource.js";
|
||||
|
||||
const fixture = scenarioServeFixture();
|
||||
|
||||
describe("environment manifests from capture_browser_scenario parents", () => {
|
||||
it("projects the scenario's transactions into tools, citing them by request order", () => {
|
||||
const manifest = fixture.manifest.normalized_result as {
|
||||
parent_operation: string;
|
||||
artifact: unknown;
|
||||
tools: {
|
||||
name: string;
|
||||
record_ordinals: number[];
|
||||
query_parameter_names: string[];
|
||||
body_shape: unknown;
|
||||
}[];
|
||||
limitations: string[];
|
||||
};
|
||||
expect(manifest.parent_operation).toBe("capture_browser_scenario");
|
||||
expect(manifest.artifact).toEqual({ path: "", sha256: null });
|
||||
const tool = (name: string) =>
|
||||
manifest.tools.find((entry) => entry.name === name);
|
||||
expect(tool("get_users_by_id")?.record_ordinals).toEqual([1, 2]);
|
||||
expect(tool("get_search")?.query_parameter_names).toEqual(["query"]);
|
||||
expect(tool("post_folders")?.body_shape).toEqual({
|
||||
type: "object",
|
||||
properties: { title: "string" },
|
||||
});
|
||||
// The redacted URL never becomes a template, so no tool is named from its marker.
|
||||
expect(
|
||||
manifest.tools.some((entry) => entry.name.includes("redacted")),
|
||||
).toBe(false);
|
||||
expect(manifest.limitations).toContain(
|
||||
"Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a scenario capture that selected neither response bodies nor header values", () => {
|
||||
const refused = inspectAnalysisView({
|
||||
source: {
|
||||
kind: "inline",
|
||||
evidence: scenarioCaptureEvidence(
|
||||
scenarioCaptureFixture(undefined, {
|
||||
request_body: false,
|
||||
response_body: false,
|
||||
header_values: false,
|
||||
}),
|
||||
),
|
||||
},
|
||||
view: { kind: "environment-manifest", template_rules: [] },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (refused.ok) return;
|
||||
expect(projectAnalysisError(refused.error)).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
});
|
||||
});
|
||||
|
||||
it("serves and exports a scenario-built manifest from the same capture", () => {
|
||||
const source = loadEnvironmentSource(fixture.manifest, fixture.capture);
|
||||
if (!source.ok) throw source.error;
|
||||
expect(source.value.captureKind).toBe("browser-scenario");
|
||||
expect(source.value.captureSha256).toMatch(/^[0-9a-f]{64}$/u);
|
||||
const users = source.value.manifest.tools.find(
|
||||
(entry) => entry.name === "get_users_by_id",
|
||||
);
|
||||
if (users === undefined) throw new Error("Manifest lacks get_users_by_id");
|
||||
const replayed = replayEnvironmentTool(source.value, users, { id: "3" });
|
||||
if (!replayed.ok) throw replayed.error;
|
||||
expect(replayed.value).toMatchObject({
|
||||
status: 200,
|
||||
body: { id: 3, login: "viewer" },
|
||||
replay: { record_ordinal: 2, exact_match: true, body_encoding: "utf-8" },
|
||||
});
|
||||
const blobs = source.value.manifest.tools.find(
|
||||
(entry) => entry.name === "get_blobs_by_id",
|
||||
);
|
||||
if (blobs === undefined) throw new Error("Manifest lacks get_blobs_by_id");
|
||||
const binary = replayEnvironmentTool(source.value, blobs, { id: "1" });
|
||||
if (!binary.ok) throw binary.error;
|
||||
expect(binary.value).toMatchObject({
|
||||
body: Buffer.from([0xff, 0xfe, 0x00, 0x41]).toString("base64"),
|
||||
replay: { body_encoding: "base64" },
|
||||
});
|
||||
const planned = planEnvironmentBundle(source.value, {
|
||||
packageSource: registryPackageSource(),
|
||||
});
|
||||
if (!planned.ok) throw planned.error;
|
||||
const readme =
|
||||
planned.value.files.find((entry) => entry.path === "README.md")
|
||||
?.content ?? "";
|
||||
expect(readme).toContain(
|
||||
"exported by REA from one retained browser scenario capture",
|
||||
);
|
||||
expect(readme).toContain(
|
||||
`Scenario definition SHA-256: \`${source.value.captureSha256 ?? ""}\``,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe("loadEnvironmentSource", () => {
|
||||
issues: [
|
||||
{
|
||||
path: ["evidence", "operation"],
|
||||
expected: "inspect_web_network_capture",
|
||||
expected: "inspect_web_network_capture or capture_browser_scenario",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
analysisViewResultSchema,
|
||||
type AnalysisViewResult,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js";
|
||||
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
@@ -49,12 +51,20 @@ export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
||||
/** Base64 characters that decode to just over the replay limit, so a cut prefix still fills it. */
|
||||
const BASE64_PREFIX_CHARS = Math.ceil((REPLAY_BODY_LIMIT_BYTES + 4) / 3) * 4;
|
||||
|
||||
/** A HAR inspected by inspect_web_network_capture, or a session recorded by capture_browser_scenario. */
|
||||
export type EnvironmentCaptureKind = "web-network-capture" | "browser-scenario";
|
||||
|
||||
/** A manifest and the parent capture it cites, authenticated and cross-checked. */
|
||||
export interface EnvironmentSource {
|
||||
readonly manifest: EnvironmentManifest;
|
||||
readonly manifestEvidenceId: string;
|
||||
readonly evidenceId: string;
|
||||
/** SHA-256 of the captured artifact, or null when the capture excluded it from Evidence. */
|
||||
/** Which REA operation recorded the parent capture. */
|
||||
readonly captureKind: EnvironmentCaptureKind;
|
||||
/**
|
||||
* SHA-256 of the captured artifact for a web network capture, or of the scenario definition for a
|
||||
* browser scenario; null when the capture excluded it from Evidence.
|
||||
*/
|
||||
readonly captureSha256: string | null;
|
||||
/** Parent records cited by at least one tool, keyed by producer ordinal. */
|
||||
readonly exchanges: ReadonlyMap<number, RecordedExchange>;
|
||||
@@ -191,7 +201,7 @@ const parseManifestEvidence = (
|
||||
return ok({ manifest: view.data, evidenceId: evidence.value.evidence_id });
|
||||
};
|
||||
|
||||
/** Authenticate the evidence file and require complete capture Evidence. */
|
||||
/** Authenticate the evidence file and require complete capture Evidence of either kind. */
|
||||
const parseCaptureEvidence = (
|
||||
operation: string,
|
||||
value: unknown,
|
||||
@@ -199,39 +209,53 @@ const parseCaptureEvidence = (
|
||||
{
|
||||
readonly records: readonly WebNetworkCaptureRecord[];
|
||||
readonly evidenceId: string;
|
||||
readonly captureKind: EnvironmentCaptureKind;
|
||||
readonly captureSha256: string | null;
|
||||
},
|
||||
AnalysisError
|
||||
> => {
|
||||
const evidence = parseEvidenceFile(operation, "evidence", value);
|
||||
if (!evidence.ok) return evidence;
|
||||
const capture = webNetworkCaptureSchema.safeParse(
|
||||
evidence.value.normalized_result,
|
||||
const { evidence_id: evidenceId, normalized_result: result } = evidence.value;
|
||||
if (evidence.value.operation === "capture_browser_scenario") {
|
||||
const scenario = browserScenarioCaptureSchema.safeParse(result);
|
||||
const definition = z
|
||||
.object({ scenario_sha256: z.string() })
|
||||
.safeParse(evidence.value.parameters);
|
||||
if (scenario.success)
|
||||
return ok({
|
||||
records: scenarioCaptureRecords(scenario.data).records,
|
||||
evidenceId,
|
||||
captureKind: "browser-scenario",
|
||||
captureSha256: definition.success
|
||||
? definition.data.scenario_sha256
|
||||
: null,
|
||||
});
|
||||
} else if (evidence.value.operation === "inspect_web_network_capture") {
|
||||
const capture = webNetworkCaptureSchema.safeParse(result);
|
||||
if (capture.success)
|
||||
return ok({
|
||||
records: capture.data.records,
|
||||
evidenceId,
|
||||
captureKind: "web-network-capture",
|
||||
captureSha256: capture.data.artifact.sha256,
|
||||
});
|
||||
}
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: ["evidence", "operation"],
|
||||
reason: "invalid_value",
|
||||
expected: "inspect_web_network_capture or capture_browser_scenario",
|
||||
message:
|
||||
"The evidence file must be complete inspect_web_network_capture or capture_browser_scenario Evidence.",
|
||||
},
|
||||
],
|
||||
"The evidence file is not web network capture or browser scenario Evidence.",
|
||||
),
|
||||
);
|
||||
if (
|
||||
evidence.value.operation !== "inspect_web_network_capture" ||
|
||||
!capture.success
|
||||
)
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: ["evidence", "operation"],
|
||||
reason: "invalid_value",
|
||||
expected: "inspect_web_network_capture",
|
||||
message:
|
||||
"The evidence file must be complete inspect_web_network_capture Evidence.",
|
||||
},
|
||||
],
|
||||
"The evidence file is not web network capture Evidence.",
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
records: capture.data.records,
|
||||
evidenceId: evidence.value.evidence_id,
|
||||
captureSha256: capture.data.artifact.sha256,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -290,6 +314,7 @@ export const loadEnvironmentSource = (
|
||||
manifest: manifest.value.manifest,
|
||||
manifestEvidenceId: manifest.value.evidenceId,
|
||||
evidenceId: capture.value.evidenceId,
|
||||
captureKind: capture.value.captureKind,
|
||||
captureSha256: capture.value.captureSha256,
|
||||
exchanges: indexExchanges(manifest.value.manifest, capture.value.records),
|
||||
});
|
||||
|
||||
@@ -206,7 +206,7 @@ const manifestArgument = z
|
||||
const evidenceOption = z
|
||||
.string()
|
||||
.describe(
|
||||
"Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites",
|
||||
"Parent capture Evidence JSON path (inspect-web-network-capture or capture-browser-scenario output) the manifest cites",
|
||||
);
|
||||
|
||||
/** Register the one-shot bundle export command. */
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -107,7 +107,7 @@ export const admitHarCaptureParent = (
|
||||
);
|
||||
if (parent.operation !== "inspect_web_network_capture")
|
||||
return unsupported(
|
||||
`View ${viewKind} is unsupported for parent operation ${parent.operation}; ${viewKind} requires inspect_web_network_capture.`,
|
||||
`View ${viewKind} is unsupported for parent operation ${parent.operation}; ${viewKind} requires ${viewKind === "environment-manifest" ? "inspect_web_network_capture or capture_browser_scenario" : "inspect_web_network_capture"}.`,
|
||||
);
|
||||
const admitted = accumulator.admitCaptureClone(parent.normalizedResult);
|
||||
if (!admitted.ok) return admitted;
|
||||
|
||||
@@ -507,7 +507,10 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
...viewResultBase,
|
||||
...environmentManifestFields,
|
||||
kind: z.literal("environment-manifest"),
|
||||
parent_operation: z.literal("inspect_web_network_capture"),
|
||||
parent_operation: z.enum([
|
||||
"inspect_web_network_capture",
|
||||
"capture_browser_scenario",
|
||||
]),
|
||||
view: environmentManifestViewRequestSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
|
||||
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js";
|
||||
import { TEMPLATE_RULES } from "./environmentManifestRules.js";
|
||||
import { admitHarCaptureParent } from "./WebNetworkProjectionAccumulator.js";
|
||||
import { admitScenarioCaptureParent } from "./scenarioCaptureRecords.js";
|
||||
import { ROUTE_RULES } from "./webNetworkRouteTemplates.js";
|
||||
|
||||
export const environmentManifestViewRequestSchema = z.strictObject({
|
||||
@@ -119,7 +121,45 @@ export const environmentManifestFields = {
|
||||
),
|
||||
};
|
||||
|
||||
/** Project retained HAR request lines into a replayable tool surface; templates are inferences. */
|
||||
/** The records, coverage and identity a manifest projects from, for either parent kind. */
|
||||
interface CaptureParent {
|
||||
readonly operation:
|
||||
| "inspect_web_network_capture"
|
||||
| "capture_browser_scenario";
|
||||
readonly records: readonly WebNetworkCaptureRecord[];
|
||||
readonly total_records: number;
|
||||
readonly limitations: readonly string[];
|
||||
readonly artifact: { readonly path: string; readonly sha256: string | null };
|
||||
}
|
||||
|
||||
/** HAR captures keep their own gate; a browser scenario has no captured file, so its artifact is empty. */
|
||||
const admitCaptureParent = (
|
||||
parent: AnalysisViewParent,
|
||||
accumulator: EnvironmentManifestAccumulator,
|
||||
): Result<CaptureParent, AnalysisError> => {
|
||||
if (parent.operation === "capture_browser_scenario") {
|
||||
const scenario = admitScenarioCaptureParent(parent, accumulator);
|
||||
return scenario.ok
|
||||
? ok({
|
||||
operation: "capture_browser_scenario",
|
||||
records: scenario.value.records,
|
||||
total_records: scenario.value.totalRecords,
|
||||
limitations: scenario.value.limitations,
|
||||
artifact: { path: "", sha256: null },
|
||||
})
|
||||
: scenario;
|
||||
}
|
||||
const har = admitHarCaptureParent(
|
||||
parent,
|
||||
"environment-manifest",
|
||||
accumulator,
|
||||
);
|
||||
return har.ok
|
||||
? ok({ operation: "inspect_web_network_capture", ...har.value })
|
||||
: har;
|
||||
};
|
||||
|
||||
/** Project retained request lines, from a HAR or a browser scenario, into a replayable tool surface; templates are inferences. */
|
||||
export const projectEnvironmentManifestView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
@@ -136,14 +176,10 @@ export const projectEnvironmentManifestView = (
|
||||
new AnalysisUnsupportedTargetError(
|
||||
"inspect_analysis_view",
|
||||
parent.evidenceId,
|
||||
`View ${view.kind} is unsupported for parent operation ${parent.operation}; environment-manifest requires inspect_web_network_capture.`,
|
||||
`View ${view.kind} is unsupported for parent operation ${parent.operation}; environment-manifest requires inspect_web_network_capture or capture_browser_scenario.`,
|
||||
),
|
||||
);
|
||||
const admitted = admitHarCaptureParent(
|
||||
parent,
|
||||
"environment-manifest",
|
||||
accumulator,
|
||||
);
|
||||
const admitted = admitCaptureParent(parent, accumulator);
|
||||
if (!admitted.ok) return admitted;
|
||||
const capture = admitted.value;
|
||||
const limitations = accumulator.addLimitations([
|
||||
@@ -177,7 +213,7 @@ export const projectEnvironmentManifestView = (
|
||||
kind: "environment-manifest",
|
||||
view,
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_web_network_capture",
|
||||
parent_operation: capture.operation,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: { path: capture.artifact.path, sha256: capture.artifact.sha256 },
|
||||
...manifest.value,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SCENARIO_EXCHANGES,
|
||||
scenarioCaptureFixture,
|
||||
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import { scenarioCaptureRecords } from "./scenarioCaptureRecords.js";
|
||||
|
||||
describe("scenarioCaptureRecords", () => {
|
||||
it("projects each transaction, in request order, as a HAR-shaped record with its retained bytes", () => {
|
||||
const projected = scenarioCaptureRecords(scenarioCaptureFixture());
|
||||
expect(projected.totalRecords).toBe(SCENARIO_EXCHANGES.length);
|
||||
expect(projected.records.map((record) => record.ordinal)).toEqual([
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
]);
|
||||
const user = projected.records[1];
|
||||
expect(user?.reported).toEqual({
|
||||
request: { method: "GET", url: "http://127.0.0.1:3101/api/users/2" },
|
||||
response: {
|
||||
status: 200,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: Buffer.from('{"id":2,"login":"probe"}').toString("base64"),
|
||||
encoding: "base64",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(user?.binary_fields).toMatchObject([
|
||||
{ pointer: "/response/content/text", state: "retained", bytes: 24 },
|
||||
]);
|
||||
expect(user?.location).toEqual({
|
||||
kind: "json-pointer",
|
||||
pointer: "/events/items/3",
|
||||
});
|
||||
// A JSON request body becomes postData, so the manifest can read its key types.
|
||||
expect(projected.records[4]?.reported).toMatchObject({
|
||||
request: {
|
||||
postData: {
|
||||
mimeType: "application/json",
|
||||
text: '{"title":"Lighthouse Rota"}',
|
||||
},
|
||||
},
|
||||
});
|
||||
// A redacted URL is withheld rather than templated; a failed request has no response.
|
||||
expect(projected.records[5]?.reported).toMatchObject({
|
||||
request: { url: null },
|
||||
});
|
||||
expect(projected.records[7]?.reported).toEqual({
|
||||
request: { method: "GET", url: "http://127.0.0.1:3101/api/slow" },
|
||||
});
|
||||
expect(projected.limitations).toContain(
|
||||
"1 of 8 scenario transactions retained no response body; their tools replay status and content type only.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a transaction that cannot be attributed to one request and response unrepresented", () => {
|
||||
const capture = scenarioCaptureFixture(SCENARIO_EXCHANGES.slice(1, 2));
|
||||
const response = capture.events.items.find(
|
||||
(event) => event.kind === "response",
|
||||
);
|
||||
if (response === undefined) throw new Error("Fixture lacks a response");
|
||||
const duplicated = {
|
||||
...capture,
|
||||
events: {
|
||||
...capture.events,
|
||||
items: [...capture.events.items, { ...response, sequence: 99 }],
|
||||
},
|
||||
};
|
||||
expect(scenarioCaptureRecords(duplicated).records[0]?.reported).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import type {
|
||||
BrowserNetworkBody,
|
||||
BrowserNetworkHeaders,
|
||||
} from "../browserNetworkEvidence.js";
|
||||
import {
|
||||
browserScenarioCaptureSchema,
|
||||
type BrowserScenarioCapture,
|
||||
} from "../browserScenarioCapture.js";
|
||||
import type { BrowserScenarioEvent } from "../browserScenarioCaptureValues.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import type { AnalysisViewParent } from "./analysisView.js";
|
||||
import type { WebNetworkProjectionAccumulator } from "./WebNetworkProjectionAccumulator.js";
|
||||
|
||||
type RequestEvent = Extract<BrowserScenarioEvent, { kind: "request" }>;
|
||||
type ResponseEvent = Extract<BrowserScenarioEvent, { kind: "response" }>;
|
||||
type ContentEvent = Extract<BrowserScenarioEvent, { kind: "network-content" }>;
|
||||
type CapturedBody = Extract<BrowserNetworkBody, { state: "captured" }>;
|
||||
|
||||
/** A scenario capture's network transactions in the record shape HAR captures project from. */
|
||||
export interface ScenarioCaptureRecords {
|
||||
readonly records: readonly WebNetworkCaptureRecord[];
|
||||
readonly totalRecords: number;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
interface TransactionIndex {
|
||||
readonly requests: { readonly event: RequestEvent; readonly item: number }[];
|
||||
readonly requestCounts: Map<string, number>;
|
||||
readonly responses: Map<string, ResponseEvent[]>;
|
||||
/** Content events keyed by the sequence of the request or response event they belong to. */
|
||||
readonly contents: Map<number, ContentEvent[]>;
|
||||
}
|
||||
|
||||
const append = <Key, Value>(
|
||||
map: Map<Key, Value[]>,
|
||||
key: Key,
|
||||
value: Value,
|
||||
): void => {
|
||||
const existing = map.get(key);
|
||||
if (existing === undefined) map.set(key, [value]);
|
||||
else existing.push(value);
|
||||
};
|
||||
|
||||
const indexTransactions = (
|
||||
events: readonly BrowserScenarioEvent[],
|
||||
): TransactionIndex => {
|
||||
const index: TransactionIndex = {
|
||||
requests: [],
|
||||
requestCounts: new Map(),
|
||||
responses: new Map(),
|
||||
contents: new Map(),
|
||||
};
|
||||
for (const [item, event] of events.entries()) {
|
||||
if (event.kind === "request") {
|
||||
index.requests.push({ event, item });
|
||||
index.requestCounts.set(
|
||||
event.transaction_id,
|
||||
(index.requestCounts.get(event.transaction_id) ?? 0) + 1,
|
||||
);
|
||||
} else if (event.kind === "response")
|
||||
append(index.responses, event.transaction_id, event);
|
||||
else if (event.kind === "network-content")
|
||||
append(index.contents, event.source_event_sequence, event);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
/** The one content event of an exchange phase, or undefined when absent or ambiguous. */
|
||||
const onlyContent = (
|
||||
index: TransactionIndex,
|
||||
sequence: number | undefined,
|
||||
): ContentEvent | undefined => {
|
||||
const found =
|
||||
sequence === undefined ? undefined : index.contents.get(sequence);
|
||||
return found?.length === 1 ? found[0] : undefined;
|
||||
};
|
||||
|
||||
const headerValue = (
|
||||
headers: BrowserNetworkHeaders | undefined,
|
||||
name: string,
|
||||
): string | undefined => {
|
||||
if (headers?.state !== "captured") return undefined;
|
||||
const item = headers.items.find(
|
||||
(candidate) => candidate.name.toLowerCase() === name,
|
||||
);
|
||||
return item?.value ?? undefined;
|
||||
};
|
||||
|
||||
const captured = (
|
||||
content: ContentEvent | undefined,
|
||||
): CapturedBody | undefined =>
|
||||
content?.body.state === "captured" ? content.body : undefined;
|
||||
|
||||
/** Strictly decoded text, keeping a byte-order mark; undefined for bytes that are not UTF-8. */
|
||||
const utf8Text = (body: CapturedBody): string | undefined => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
Buffer.from(body.content, "base64"),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const mediaType = (content: ContentEvent | undefined): string | undefined =>
|
||||
captured(content)?.media_type ??
|
||||
headerValue(content?.headers, "content-type");
|
||||
|
||||
/** The request object HAR readers expect; a redacted URL is withheld so no path is templated from it. */
|
||||
const reportedRequest = (
|
||||
request: RequestEvent,
|
||||
content: ContentEvent | undefined,
|
||||
): { readonly [key: string]: JsonValue } => {
|
||||
const body = captured(content);
|
||||
const text = body === undefined ? undefined : utf8Text(body);
|
||||
const mimeType = mediaType(content);
|
||||
return {
|
||||
method: request.method,
|
||||
url: request.url.redacted ? null : request.url.url,
|
||||
...(text === undefined || mimeType === undefined
|
||||
? {}
|
||||
: { postData: { mimeType, text } }),
|
||||
};
|
||||
};
|
||||
|
||||
const record = (
|
||||
ordinal: number,
|
||||
item: number,
|
||||
request: RequestEvent,
|
||||
index: TransactionIndex,
|
||||
): { readonly record: WebNetworkCaptureRecord; readonly body: boolean } => {
|
||||
const location = {
|
||||
kind: "json-pointer" as const,
|
||||
pointer: `/events/items/${String(item)}`,
|
||||
};
|
||||
const empty = {
|
||||
binary_fields: [],
|
||||
numeric_literals: [],
|
||||
redactions: [],
|
||||
limitations: [],
|
||||
};
|
||||
const responses = index.responses.get(request.transaction_id) ?? [];
|
||||
const response = responses.length === 1 ? responses[0] : undefined;
|
||||
// One request, at most one response and one content event per phase: otherwise the
|
||||
// transaction cannot be attributed and is left unrepresented.
|
||||
if (
|
||||
index.requestCounts.get(request.transaction_id) !== 1 ||
|
||||
responses.length > 1 ||
|
||||
(index.contents.get(request.sequence)?.length ?? 0) > 1 ||
|
||||
(response !== undefined &&
|
||||
(index.contents.get(response.sequence)?.length ?? 0) > 1)
|
||||
)
|
||||
return {
|
||||
record: { ordinal, location, reported: {}, ...empty },
|
||||
body: false,
|
||||
};
|
||||
const requestContent = onlyContent(index, request.sequence);
|
||||
const responseContent = onlyContent(index, response?.sequence);
|
||||
const body = captured(responseContent);
|
||||
const mimeType = mediaType(responseContent) ?? "";
|
||||
return {
|
||||
record: {
|
||||
ordinal,
|
||||
location,
|
||||
reported: {
|
||||
request: reportedRequest(request, requestContent),
|
||||
...(response === undefined
|
||||
? {}
|
||||
: {
|
||||
response: {
|
||||
status: response.status,
|
||||
content:
|
||||
body === undefined
|
||||
? { mimeType }
|
||||
: { mimeType, text: body.content, encoding: "base64" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
...empty,
|
||||
binary_fields:
|
||||
body === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
pointer: "/response/content/text",
|
||||
representation: "har-base64-content",
|
||||
state: "retained",
|
||||
bytes: body.bytes,
|
||||
sha256: body.sha256,
|
||||
content_base64: body.content,
|
||||
},
|
||||
],
|
||||
},
|
||||
body: body !== undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** Project each network transaction, in request order, as one record; the ordinal is its zero-based position. */
|
||||
export const scenarioCaptureRecords = (
|
||||
capture: BrowserScenarioCapture,
|
||||
): ScenarioCaptureRecords => {
|
||||
const index = indexTransactions(capture.events.items);
|
||||
const projected = index.requests.map(({ event, item }, ordinal) =>
|
||||
record(ordinal, item, event, index),
|
||||
);
|
||||
const withoutBody = projected.filter((entry) => !entry.body).length;
|
||||
return {
|
||||
records: projected.map((entry) => entry.record),
|
||||
totalRecords: projected.length,
|
||||
limitations: [
|
||||
"Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.",
|
||||
...(capture.events.dropped > 0
|
||||
? [
|
||||
`The scenario capture omitted ${String(capture.events.dropped)} events; the manifest covers only retained transactions.`,
|
||||
]
|
||||
: []),
|
||||
...(withoutBody > 0
|
||||
? [
|
||||
`${String(withoutBody)} of ${String(projected.length)} scenario transactions retained no response body; their tools replay status and content type only.`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/** A scenario capture parent in the shape the environment manifest projects from. */
|
||||
export interface ScenarioCaptureParent extends ScenarioCaptureRecords {
|
||||
readonly capture: BrowserScenarioCapture;
|
||||
}
|
||||
|
||||
/** Admit a capture_browser_scenario parent whose network content identifies response media types. */
|
||||
export const admitScenarioCaptureParent = (
|
||||
parent: AnalysisViewParent,
|
||||
accumulator: WebNetworkProjectionAccumulator,
|
||||
): Result<ScenarioCaptureParent, AnalysisError> => {
|
||||
const admitted = accumulator.admitCaptureClone(parent.normalizedResult);
|
||||
if (!admitted.ok) return admitted;
|
||||
const parsed = browserScenarioCaptureSchema.safeParse(
|
||||
parent.normalizedResult,
|
||||
);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Parent Evidence result does not match capture_browser_scenario.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const content = parsed.data.scenario.network_content;
|
||||
// Tools are chosen by response media type, which only bodies or header values carry.
|
||||
if (!content.response_body && !content.header_values)
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(
|
||||
"inspect_analysis_view",
|
||||
parent.evidenceId,
|
||||
"environment-manifest needs a capture_browser_scenario capture with capture.network.response_body or header_values selected.",
|
||||
),
|
||||
);
|
||||
return ok({ capture: parsed.data, ...scenarioCaptureRecords(parsed.data) });
|
||||
};
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { inspectAnalysisView } from "../../src/application/analysisView/AnalysisViewService.js";
|
||||
import { createBrowserScenarioEvidence } from "../../src/application/BrowserScenarioEvidence.js";
|
||||
import { sanitizeBrowserUrl } from "../../src/domain/browserObservation.js";
|
||||
import { browserScenarioSchema } from "../../src/domain/browserScenario.js";
|
||||
import {
|
||||
browserScenarioCaptureSchema,
|
||||
type BrowserScenarioCapture,
|
||||
} from "../../src/domain/browserScenarioCapture.js";
|
||||
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../../src/domain/browserScenarioCaptureLimits.js";
|
||||
import type { Evidence } from "../../src/domain/evidence.js";
|
||||
import {
|
||||
ALL_TEMPLATE_RULES,
|
||||
type EnvironmentServeFixture,
|
||||
} from "./environmentServe.js";
|
||||
|
||||
/** One recorded transaction; omit `status` for a request that never got a response. */
|
||||
export interface ScenarioExchange {
|
||||
readonly method: string;
|
||||
readonly url: string;
|
||||
readonly resourceType?: string;
|
||||
readonly redactedUrl?: boolean;
|
||||
readonly status?: number;
|
||||
readonly mediaType?: string | null;
|
||||
readonly body?: string | Buffer;
|
||||
readonly requestMediaType?: string;
|
||||
readonly requestBody?: string;
|
||||
}
|
||||
|
||||
export const SCENARIO_ORIGIN = "http://127.0.0.1:3101";
|
||||
|
||||
/** A small app session: a page load, JSON reads, a search, a JSON write, a redacted URL, binary bytes and a failure. */
|
||||
export const SCENARIO_EXCHANGES: readonly ScenarioExchange[] = [
|
||||
{
|
||||
method: "GET",
|
||||
url: "/",
|
||||
resourceType: "document",
|
||||
status: 200,
|
||||
mediaType: "text/html",
|
||||
body: "<!doctype html><title>Rota</title>",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: "/api/users/2",
|
||||
status: 200,
|
||||
mediaType: "application/json",
|
||||
body: '{"id":2,"login":"probe"}',
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: "/api/users/3",
|
||||
status: 200,
|
||||
mediaType: "application/json",
|
||||
body: '{"id":3,"login":"viewer"}',
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: "/api/search?query=lighthouse",
|
||||
status: 200,
|
||||
mediaType: "application/json",
|
||||
body: '[{"title":"Lighthouse Rota"}]',
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
url: "/api/folders",
|
||||
status: 200,
|
||||
mediaType: "application/json",
|
||||
body: '{"uid":"f1","title":"Lighthouse Rota"}',
|
||||
requestMediaType: "application/json",
|
||||
requestBody: '{"title":"Lighthouse Rota"}',
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: "/api/sessions/[REDACTED:session]",
|
||||
redactedUrl: true,
|
||||
status: 200,
|
||||
mediaType: "application/json",
|
||||
body: "{}",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: "/api/blobs/1",
|
||||
status: 200,
|
||||
mediaType: "application/json",
|
||||
body: Buffer.from([0xff, 0xfe, 0x00, 0x41]),
|
||||
},
|
||||
{ method: "GET", url: "/api/slow" },
|
||||
];
|
||||
|
||||
const capturedBody = (
|
||||
content: string | Buffer,
|
||||
representation:
|
||||
| "browser-exposed-request-bytes"
|
||||
| "playwright-response-body-bytes",
|
||||
mediaType: string | null,
|
||||
) => {
|
||||
const bytes =
|
||||
typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
||||
return {
|
||||
state: "captured" as const,
|
||||
representation,
|
||||
encoding: "base64" as const,
|
||||
content: bytes.toString("base64"),
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
media_type: mediaType,
|
||||
redacted: false,
|
||||
};
|
||||
};
|
||||
|
||||
const exchangeEvents = (
|
||||
exchange: ScenarioExchange,
|
||||
index: number,
|
||||
first: number,
|
||||
) => {
|
||||
const transaction = `request-${String(index + 1)}`;
|
||||
const url = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}${exchange.url}`);
|
||||
const network = {
|
||||
step_index: 1,
|
||||
transaction_id: transaction,
|
||||
redirected_from_transaction_id: null,
|
||||
redirected_from_transaction_state: "root" as const,
|
||||
method: exchange.method,
|
||||
url: exchange.redactedUrl === true ? { ...url, redacted: true } : url,
|
||||
resource_type: exchange.resourceType ?? "fetch",
|
||||
header_names: [],
|
||||
};
|
||||
const content = (
|
||||
sequence: number,
|
||||
source: number,
|
||||
phase: "request" | "response",
|
||||
body: ReturnType<typeof capturedBody>,
|
||||
) => ({
|
||||
sequence,
|
||||
step_index: 1,
|
||||
kind: "network-content" as const,
|
||||
transaction_id: transaction,
|
||||
source_event_sequence: source,
|
||||
phase,
|
||||
headers: { state: "not_requested" as const },
|
||||
body,
|
||||
});
|
||||
const events: unknown[] = [
|
||||
{
|
||||
...network,
|
||||
sequence: first,
|
||||
kind: "request",
|
||||
status: null,
|
||||
failure: null,
|
||||
},
|
||||
];
|
||||
let next = first + 1;
|
||||
if (exchange.requestBody !== undefined) {
|
||||
events.push(
|
||||
content(
|
||||
next,
|
||||
first,
|
||||
"request",
|
||||
capturedBody(
|
||||
exchange.requestBody,
|
||||
"browser-exposed-request-bytes",
|
||||
exchange.requestMediaType ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
next += 1;
|
||||
}
|
||||
if (exchange.status === undefined) {
|
||||
events.push({
|
||||
...network,
|
||||
sequence: next,
|
||||
kind: "request-failed",
|
||||
status: null,
|
||||
failure: "net::ERR_CONNECTION_RESET",
|
||||
});
|
||||
return { events, next: next + 1 };
|
||||
}
|
||||
const response = next;
|
||||
events.push({
|
||||
...network,
|
||||
sequence: response,
|
||||
kind: "response",
|
||||
status: exchange.status,
|
||||
failure: null,
|
||||
});
|
||||
next += 1;
|
||||
if (exchange.body !== undefined) {
|
||||
events.push(
|
||||
content(
|
||||
next,
|
||||
response,
|
||||
"response",
|
||||
capturedBody(
|
||||
exchange.body,
|
||||
"playwright-response-body-bytes",
|
||||
exchange.mediaType ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
next += 1;
|
||||
}
|
||||
return { events, next };
|
||||
};
|
||||
|
||||
const step = (index: number) => ({
|
||||
step_index: index,
|
||||
step_id: index === 0 ? "initial" : "browse",
|
||||
action: index === 0 ? "initial" : "goto",
|
||||
elapsed_ms: 0,
|
||||
before_url: sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/`),
|
||||
after_url: sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/`),
|
||||
event_sequence_start: 1,
|
||||
event_sequence_end: 0,
|
||||
artifacts: {
|
||||
screenshot: { state: "not_requested" },
|
||||
dom: { state: "not_requested" },
|
||||
accessibility: { state: "not_requested" },
|
||||
url: { state: "not_requested" },
|
||||
history: { state: "not_requested" },
|
||||
storage: { state: "not_requested" },
|
||||
},
|
||||
completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
|
||||
/** A complete, schema-valid scenario capture of the given transactions. */
|
||||
export const scenarioCaptureFixture = (
|
||||
exchanges: readonly ScenarioExchange[] = SCENARIO_EXCHANGES,
|
||||
networkContent = {
|
||||
request_body: true,
|
||||
response_body: true,
|
||||
header_values: false,
|
||||
},
|
||||
): BrowserScenarioCapture => {
|
||||
const items: unknown[] = [];
|
||||
let sequence = 1;
|
||||
for (const [index, exchange] of exchanges.entries()) {
|
||||
const built = exchangeEvents(exchange, index, sequence);
|
||||
items.push(...built.events);
|
||||
sequence = built.next;
|
||||
}
|
||||
return browserScenarioCaptureSchema.parse({
|
||||
browser: {
|
||||
mode: "launch",
|
||||
process_ownership: "provider-owned",
|
||||
cleanup: "terminated-owned-process",
|
||||
product: "Chromium",
|
||||
version: "fixture",
|
||||
},
|
||||
scenario: {
|
||||
start_origin: SCENARIO_ORIGIN,
|
||||
action_count: 1,
|
||||
secret_references: [],
|
||||
network_content: networkContent,
|
||||
event_families: ["network"],
|
||||
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||
},
|
||||
duration_ms: 1,
|
||||
steps: [step(0), step(1)],
|
||||
events: {
|
||||
retained: items.length,
|
||||
dropped: 0,
|
||||
items,
|
||||
omissions: {
|
||||
by_kind: [],
|
||||
network_transactions: [],
|
||||
network_transaction_range: null,
|
||||
},
|
||||
},
|
||||
completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
||||
/** The capture as Evidence, the way capture_browser_scenario records it. */
|
||||
export const scenarioCaptureEvidence = (
|
||||
capture: BrowserScenarioCapture = scenarioCaptureFixture(),
|
||||
): Evidence =>
|
||||
createBrowserScenarioEvidence(
|
||||
browserScenarioSchema.parse({
|
||||
browser: { mode: "launch", executable_path: process.execPath },
|
||||
start_url: { url: `${SCENARIO_ORIGIN}/`, query: [] },
|
||||
actions: [
|
||||
{ step_id: "browse", action: "wait_for_timeout", duration_ms: 1 },
|
||||
],
|
||||
}),
|
||||
capture,
|
||||
{ id: "fixture", name: "fixture", version: "1" },
|
||||
);
|
||||
|
||||
/** Project the scenario capture through the real view service, with every opt-in rule. */
|
||||
export const scenarioServeFixture = (
|
||||
capture: Evidence = scenarioCaptureEvidence(),
|
||||
): EnvironmentServeFixture => {
|
||||
const manifest = inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: capture },
|
||||
view: {
|
||||
kind: "environment-manifest",
|
||||
template_rules: [...ALL_TEMPLATE_RULES],
|
||||
},
|
||||
});
|
||||
if (!manifest.ok) throw manifest.error;
|
||||
return { manifest: manifest.value, capture };
|
||||
};
|
||||
Reference in New Issue
Block a user