mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -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`
|
Evidence JSON as the commands write it with `--format json`; the `evidence`
|
||||||
member of `view.json` is the whole of `capture.json`.
|
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
|
## What a manifest is
|
||||||
|
|
||||||
An environment manifest is a projection of one application's recorded request
|
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
|
`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.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"name": "inspect_analysis_view",
|
"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)}`;
|
: `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}
|
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
|
Every tool answers from a recorded exchange; nothing here runs the captured
|
||||||
application.
|
application.
|
||||||
|
|
||||||
## Provenance
|
## 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}\`
|
- Capture Evidence (the manifest's parent): \`${source.evidenceId}\`
|
||||||
- Manifest Evidence: \`${source.manifestEvidenceId}\`
|
- Manifest Evidence: \`${source.manifestEvidenceId}\`
|
||||||
- Manifest view digest: \`${manifest.view_digest}\`
|
- 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: [
|
issues: [
|
||||||
{
|
{
|
||||||
path: ["evidence", "operation"],
|
path: ["evidence", "operation"],
|
||||||
expected: "inspect_web_network_capture",
|
expected: "inspect_web_network_capture or capture_browser_scenario",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
analysisViewResultSchema,
|
analysisViewResultSchema,
|
||||||
type AnalysisViewResult,
|
type AnalysisViewResult,
|
||||||
} from "../../domain/analysisView/analysisView.js";
|
} from "../../domain/analysisView/analysisView.js";
|
||||||
|
import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js";
|
||||||
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||||
|
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
AnalysisInputError,
|
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. */
|
/** 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;
|
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. */
|
/** A manifest and the parent capture it cites, authenticated and cross-checked. */
|
||||||
export interface EnvironmentSource {
|
export interface EnvironmentSource {
|
||||||
readonly manifest: EnvironmentManifest;
|
readonly manifest: EnvironmentManifest;
|
||||||
readonly manifestEvidenceId: string;
|
readonly manifestEvidenceId: string;
|
||||||
readonly evidenceId: 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;
|
readonly captureSha256: string | null;
|
||||||
/** Parent records cited by at least one tool, keyed by producer ordinal. */
|
/** Parent records cited by at least one tool, keyed by producer ordinal. */
|
||||||
readonly exchanges: ReadonlyMap<number, RecordedExchange>;
|
readonly exchanges: ReadonlyMap<number, RecordedExchange>;
|
||||||
@@ -191,7 +201,7 @@ const parseManifestEvidence = (
|
|||||||
return ok({ manifest: view.data, evidenceId: evidence.value.evidence_id });
|
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 = (
|
const parseCaptureEvidence = (
|
||||||
operation: string,
|
operation: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -199,39 +209,53 @@ const parseCaptureEvidence = (
|
|||||||
{
|
{
|
||||||
readonly records: readonly WebNetworkCaptureRecord[];
|
readonly records: readonly WebNetworkCaptureRecord[];
|
||||||
readonly evidenceId: string;
|
readonly evidenceId: string;
|
||||||
|
readonly captureKind: EnvironmentCaptureKind;
|
||||||
readonly captureSha256: string | null;
|
readonly captureSha256: string | null;
|
||||||
},
|
},
|
||||||
AnalysisError
|
AnalysisError
|
||||||
> => {
|
> => {
|
||||||
const evidence = parseEvidenceFile(operation, "evidence", value);
|
const evidence = parseEvidenceFile(operation, "evidence", value);
|
||||||
if (!evidence.ok) return evidence;
|
if (!evidence.ok) return evidence;
|
||||||
const capture = webNetworkCaptureSchema.safeParse(
|
const { evidence_id: evidenceId, normalized_result: result } = evidence.value;
|
||||||
evidence.value.normalized_result,
|
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,
|
manifest: manifest.value.manifest,
|
||||||
manifestEvidenceId: manifest.value.evidenceId,
|
manifestEvidenceId: manifest.value.evidenceId,
|
||||||
evidenceId: capture.value.evidenceId,
|
evidenceId: capture.value.evidenceId,
|
||||||
|
captureKind: capture.value.captureKind,
|
||||||
captureSha256: capture.value.captureSha256,
|
captureSha256: capture.value.captureSha256,
|
||||||
exchanges: indexExchanges(manifest.value.manifest, capture.value.records),
|
exchanges: indexExchanges(manifest.value.manifest, capture.value.records),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ const manifestArgument = z
|
|||||||
const evidenceOption = z
|
const evidenceOption = z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.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. */
|
/** 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")
|
if (parent.operation !== "inspect_web_network_capture")
|
||||||
return unsupported(
|
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);
|
const admitted = accumulator.admitCaptureClone(parent.normalizedResult);
|
||||||
if (!admitted.ok) return admitted;
|
if (!admitted.ok) return admitted;
|
||||||
|
|||||||
@@ -507,7 +507,10 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||||||
...viewResultBase,
|
...viewResultBase,
|
||||||
...environmentManifestFields,
|
...environmentManifestFields,
|
||||||
kind: z.literal("environment-manifest"),
|
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,
|
view: environmentManifestViewRequestSchema,
|
||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
|
||||||
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
|
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||||
@@ -11,6 +12,7 @@ import type {
|
|||||||
import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js";
|
import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js";
|
||||||
import { TEMPLATE_RULES } from "./environmentManifestRules.js";
|
import { TEMPLATE_RULES } from "./environmentManifestRules.js";
|
||||||
import { admitHarCaptureParent } from "./WebNetworkProjectionAccumulator.js";
|
import { admitHarCaptureParent } from "./WebNetworkProjectionAccumulator.js";
|
||||||
|
import { admitScenarioCaptureParent } from "./scenarioCaptureRecords.js";
|
||||||
import { ROUTE_RULES } from "./webNetworkRouteTemplates.js";
|
import { ROUTE_RULES } from "./webNetworkRouteTemplates.js";
|
||||||
|
|
||||||
export const environmentManifestViewRequestSchema = z.strictObject({
|
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 = (
|
export const projectEnvironmentManifestView = (
|
||||||
parent: AnalysisViewParent,
|
parent: AnalysisViewParent,
|
||||||
view: AnalysisViewRequest,
|
view: AnalysisViewRequest,
|
||||||
@@ -136,14 +176,10 @@ export const projectEnvironmentManifestView = (
|
|||||||
new AnalysisUnsupportedTargetError(
|
new AnalysisUnsupportedTargetError(
|
||||||
"inspect_analysis_view",
|
"inspect_analysis_view",
|
||||||
parent.evidenceId,
|
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(
|
const admitted = admitCaptureParent(parent, accumulator);
|
||||||
parent,
|
|
||||||
"environment-manifest",
|
|
||||||
accumulator,
|
|
||||||
);
|
|
||||||
if (!admitted.ok) return admitted;
|
if (!admitted.ok) return admitted;
|
||||||
const capture = admitted.value;
|
const capture = admitted.value;
|
||||||
const limitations = accumulator.addLimitations([
|
const limitations = accumulator.addLimitations([
|
||||||
@@ -177,7 +213,7 @@ export const projectEnvironmentManifestView = (
|
|||||||
kind: "environment-manifest",
|
kind: "environment-manifest",
|
||||||
view,
|
view,
|
||||||
parent_evidence_id: parent.evidenceId,
|
parent_evidence_id: parent.evidenceId,
|
||||||
parent_operation: "inspect_web_network_capture",
|
parent_operation: capture.operation,
|
||||||
parent_digest: parent.evidenceId.slice(3),
|
parent_digest: parent.evidenceId.slice(3),
|
||||||
artifact: { path: capture.artifact.path, sha256: capture.artifact.sha256 },
|
artifact: { path: capture.artifact.path, sha256: capture.artifact.sha256 },
|
||||||
...manifest.value,
|
...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