mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00: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:
1 parent
481c9883b3
commit
3c2a8db2e7
17 files changed
+1031
-43
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profiLine truncated
|
||||
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; a transaction whose URL was redacted is left unrepresented. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishesLine truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -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