feat(browser): deliver retained summaries of browser scenario captures

Add detail summary to capture_browser_scenario (MCP and CLI) and project
retained scenario Evidence through inspect_analysis_view: a compact summary
and exact step/event pages.

Refs #6167

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-22 01:59:51 +00:00
1 parent c465ff5a81
commit ab3893d48e
13 files changed
+1066 -26

No files matched your search

@@ -38,6 +38,8 @@ Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
browser and profile. Check `browser.cleanup` for the outcome.
Request only the DOM, accessibility, or network details needed for the question.
The default final capture contains the URL; step outcomes are reported separately.
For multi-step flows with trees or events, select `detail: "summary"` and page
exact steps or events with `inspect_analysis_view` kind `browser-scenario`.
Add a wait action for the expected page state before capturing it. Report that
REA performed the declared actions.
+40
View File
@@ -293,6 +293,46 @@ external browser without closing it. The request itself defines the operation;
there is no additional REA grant step. The browser and host still enforce their
own access and process rules.
## Summary delivery
Multi-step scenarios that select accessibility trees, DOM or network events can
produce results far larger than an MCP client accepts. Select
`"detail": "summary"` (CLI: `--detail summary --evidence_output <bundle>`) to
retain the complete Evidence and receive a compact view instead:
- one row per step with its status, error, elapsed time and `after_url`;
`before_url` appears only when it differs from the previous step's
`after_url`, and sections are listed as captured, missing or truncated
(`not_requested` sections are omitted);
- `final_accessibility`: the accessibility tree of the first failed or
cancelled step, or of the last step, falling back to the nearest earlier step
that captured one (`step_index` is `null` when none did);
- retained event counts by kind, with the budget omissions, and network
transaction counts by origin, resource type and final status class.
Page exact records from `normalized_result.parent_evidence_id` with
`inspect_analysis_view`:
```json
{
"source": { "kind": "retained-evidence", "evidence_id": "ev_..." },
"view": {
"kind": "browser-scenario",
"collection": "steps",
"step_indices": [3],
"artifacts": ["dom"]
}
}
```
Steps return only the captured sections named in `artifacts` and list the
others with their state. Events (`"collection": "events"`) accept
`step_indices`, `event_kinds` and `transaction_ids` and keep each event's
original `item_index`. An omitted filter selects all records; `[]` selects
none. Every page repeats the capture's completeness and dropped-event count:
exhausting the pages does not make a truncated capture complete. The default
`"detail": "complete"` keeps returning the whole capture inline.
## Network evidence
`capture.events: ["network"]` retains request, response, completion, failure,
@@ -0,0 +1,272 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
SCENARIO_ORIGIN,
scenarioCaptureEvidence,
scenarioCaptureFixture,
} from "../../../tests/fixtures/browserScenarioCapture.js";
import { inspectAnalysisView } from "./AnalysisViewService.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js";
const tree = (text: string) => ({
state: "captured" as const,
value: {
sha256: createHash("sha256").update(text).digest("hex"),
bytes: Buffer.byteLength(text),
text,
},
});
/** Three steps: navigate to /quiz, capture its tree, then fail on a missing control. */
const quizCapture = (
thirdStep: "failed" | "completed" = "failed",
): BrowserScenarioCapture => {
const base = scenarioCaptureFixture();
const [initial, browse] = base.steps;
if (initial === undefined || browse === undefined)
throw new Error("Fixture lacks steps");
const quiz = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/quiz`);
return {
...base,
scenario: { ...base.scenario, action_count: 2 },
steps: [
{
...initial,
artifacts: {
...initial.artifacts,
url: { state: "captured", value: initial.after_url },
},
},
{
...browse,
after_url: quiz,
artifacts: {
...browse.artifacts,
accessibility: tree('- heading "Question 1"'),
screenshot: { state: "missing", reason: "page closed" },
},
},
{
...browse,
step_index: 2,
step_id: "answer",
action: "click",
before_url: quiz,
after_url: quiz,
...(thirdStep === "failed"
? { status: "failed" as const, error: "locator not found" }
: { status: "completed" as const, error: null }),
},
],
};
};
const view = (
capture: BrowserScenarioCapture,
request: AnalysisViewRequest,
) => {
const projected = inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) },
view: request,
});
if (!projected.ok) throw projected.error;
return projected.value.normalized_result as Record<string, unknown>;
};
describe("browser scenario analysis views", () => {
it("summarizes steps compactly and focuses the first failing step's nearest captured tree", () => {
const summary = view(quizCapture(), { kind: "summary" });
expect(summary).toMatchObject({
kind: "summary",
parent_operation: "capture_browser_scenario",
artifact: { path: "", sha256: null },
coverage: { status: "complete-within-view", exhausted: true },
});
const { steps, final_accessibility, network, events } =
summary.summary as Record<string, unknown> & {
steps: Record<string, unknown>[];
};
// before_url repeats only across a discontinuity; not_requested sections are omitted.
expect(steps[0]).toHaveProperty("before_url");
expect(steps[1]).not.toHaveProperty("before_url");
expect(steps[2]).not.toHaveProperty("before_url");
expect(steps[0]?.sections).toEqual({
captured: ["url"],
missing: [],
truncated: [],
});
expect(steps[1]?.sections).toEqual({
captured: ["accessibility"],
missing: ["screenshot"],
truncated: [],
});
expect(steps[2]).toMatchObject({
status: "failed",
error: "locator not found",
after_url: { url: `${SCENARIO_ORIGIN}/quiz` },
});
expect(final_accessibility).toEqual({
focus_step_index: 2,
focus: "first-unsuccessful-step",
step_index: 1,
accessibility: tree('- heading "Question 1"'),
});
expect(network).toEqual({
transactions: 8,
by_origin: [{ origin: SCENARIO_ORIGIN, transactions: 8 }],
by_resource_type: [
{ resource_type: "document", transactions: 1 },
{ resource_type: "fetch", transactions: 7 },
],
by_status_class: [
{ status_class: "2xx", transactions: 7 },
{ status_class: "failed", transactions: 1 },
],
});
expect(events).toMatchObject({ retained: 24, dropped: 0 });
});
it("reports the last step's own state when no step captured a tree", () => {
const capture = scenarioCaptureFixture();
expect(view(capture, { kind: "summary" })).toMatchObject({
summary: {
final_accessibility: {
focus_step_index: 1,
focus: "last-step",
step_index: null,
accessibility: { state: "not_requested" },
},
},
});
});
});
describe("browser scenario analysis view pages", () => {
it("pages exact steps with only the selected captured sections", () => {
const page = view(quizCapture("completed"), {
kind: "browser-scenario",
collection: "steps",
step_indices: [1, 2],
artifacts: ["accessibility"],
offset: 0,
limit: 1,
});
expect(page).toMatchObject({
kind: "browser-scenario",
source_completeness: { status: "complete" },
source_events: { retained: 24, dropped: 0 },
coverage: { status: "page", examined: 1, total: 2, next_offset: 1 },
items: {
collection: "steps",
rows: [
{
step_index: 1,
artifacts: { accessibility: tree('- heading "Question 1"') },
omitted_artifacts: [{ section: "screenshot", state: "missing" }],
},
],
},
});
});
it("filters events by step, kind and transaction, keeping original item indices", () => {
const capture = scenarioCaptureFixture();
const responses = view(capture, {
kind: "browser-scenario",
collection: "events",
event_kinds: ["response", "request-failed"],
transaction_ids: ["request-2", "request-8"],
artifacts: [],
offset: 0,
limit: 64,
}) as { items: { rows: { item_index: number; event: unknown }[] } };
expect(
responses.items.rows.map(({ item_index, event }) => ({
item_index,
event,
})),
).toEqual(
capture.events.items.flatMap((event, item_index) =>
(event.kind === "response" || event.kind === "request-failed") &&
["request-2", "request-8"].includes(event.transaction_id)
? [{ item_index, event }]
: [],
),
);
expect(responses.items.rows).toHaveLength(2);
for (const empty of [
{ step_indices: [] },
{ event_kinds: [] },
{ transaction_ids: [] },
{ step_indices: [0] },
])
expect(
view(capture, {
kind: "browser-scenario",
collection: "events",
artifacts: [],
offset: 0,
limit: 64,
...empty,
}),
).toMatchObject({
items: { rows: [] },
coverage: { status: "empty", total: 0, exhausted: true },
});
});
it("states dropped events and refuses views of other parents", () => {
const base = scenarioCaptureFixture();
const dropped = {
...base,
events: {
...base.events,
dropped: 3,
omissions: {
...base.events.omissions,
by_kind: [
{
kind: "console" as const,
count: 3,
reasons: ["max-events" as const],
},
],
},
},
completeness: {
status: "truncated" as const,
equality_eligible: false as const,
missing_sections: [],
truncated_sections: ["events" as const],
},
};
const summary = view(dropped, { kind: "summary" });
expect(summary.unknowns).toContain(
"3 events were dropped by capture budgets; counts and pages cover retained events only.",
);
expect(summary).toMatchObject({
summary: {
events: {
dropped: 3,
omissions: {
by_kind: [{ kind: "console", count: 3 }],
network_transactions: 0,
},
},
},
});
const refused = inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence() },
view: { kind: "go", collection: "dependencies" },
});
if (refused.ok) throw new Error("Expected an unsupported view refusal");
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "invalid_request",
});
});
});
@@ -76,15 +76,14 @@ describe("environment manifests from capture_browser_scenario parents", () => {
kind: "inline",
evidence: scenarioCaptureEvidence(scenarioCaptureFixture()),
},
view: { kind: "summary" },
view: { kind: "dart-aot", collection: "package_uris" },
});
if (refused.ok) throw new Error("Expected the unsupported view refusal");
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "unsupported_target",
message: expect.stringContaining(
"supports only the environment-manifest and environment-state views",
),
});
const projected = projectAnalysisError(refused.error);
expect(projected).toMatchObject({ code: "invalid_request" });
expect(JSON.stringify(projected)).toContain(
"supports the summary, browser-scenario, environment-manifest and environment-state views",
);
});
it("serves and exports a scenario-built manifest from the same capture", () => {
+23 -2
View File
@@ -3,6 +3,11 @@ import { z } from "zod";
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
import { withCommandCancellation } from "./cli/commandCancellation.js";
import {
analysisEvidenceDeliveryOptions,
cliEvidenceDeliveryInputError,
publishCliAnalysisEvidence,
} from "./cli/analysisEvidenceDelivery.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
import { logCliCommand } from "./cliLogging.js";
@@ -16,6 +21,7 @@ import type { JsonValue } from "./domain/jsonValue.js";
import type { Logger } from "pino";
const OPERATION = "capture_browser_scenario";
const PROVIDER_ID = "rea-playwright-browser-scenario";
/** Register the one-shot controlled browser scenario command. */
export const registerBrowserScenarioCommands = (
@@ -36,9 +42,17 @@ export const registerBrowserScenarioCommands = (
.string()
.describe("Inline browser scenario JSON or JSON file path"),
}),
run: ({ args }) =>
options: z.object(analysisEvidenceDeliveryOptions),
run: ({ args, options: delivery }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
const deliveryError = cliEvidenceDeliveryInputError({
detail: delivery.detail,
evidenceOutput: delivery.evidence_output,
operation: OPERATION,
});
// Refuse undeliverable summaries before launching a browser.
if (deliveryError !== undefined) return cliError(deliveryError);
const input = await parseCliJsonInput(args.inputJson, OPERATION);
if (!input.ok) return input.error;
const scenario = browserScenarioSchema.safeParse(
@@ -68,7 +82,6 @@ export const registerBrowserScenarioCommands = (
result = await captureBrowserScenario(provider, scenario.data, {
signal,
});
return result.ok ? result.value : cliError(result.error);
} finally {
try {
await provider?.close?.();
@@ -97,6 +110,14 @@ export const registerBrowserScenarioCommands = (
});
}
}
if (!result.ok) return cliError(result.error);
return publishCliAnalysisEvidence(result.value, {
providerId: PROVIDER_ID,
operation: OPERATION,
signal,
detail: delivery.detail,
evidenceOutput: delivery.evidence_output,
});
}),
),
});
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, 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, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. 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. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. 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; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. 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 EvidenceLine truncated
'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, 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, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects, inspect_web_network_capture or capture_browser_scenario Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. 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. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. 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; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. capture_browser_scenario parents also support view {kind: "summary"} (step outcomes, focus accessibility tree, event and network counts) and view {kind: "browser-scenario", collection: "steps" | "events", offset: 0, limit: 64}: stLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+13 -4
View File
@@ -1,6 +1,10 @@
import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import {
analysisDetailOutputSchemaOf,
analysisDetailSchema,
} from "./analysisDetail.js";
import { browserScenarioSummaryViewResultSchema } from "../domain/analysisView/analysisView.js";
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -26,10 +30,15 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
name: "capture_browser_scenario",
...toolContractMetadata("capture_browser_scenario"),
description:
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Default detail complete returns the capture inline. Detail summary retains it and returns per-step status, errors and URLs, the final or first failing step's accessibility tree, and event and network counts; page exact steps (with selected artifacts) or events through inspect_analysis_view kind browser-scenario.",
kind: "browser-provider",
inputSchema: browserScenarioInputSchema,
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
inputSchema: browserScenarioInputSchema.extend({
detail: analysisDetailSchema,
}),
outputSchema: analysisDetailOutputSchemaOf(
browserScenarioCaptureSchema,
browserScenarioSummaryViewResultSchema,
),
examples: [
{ title: "Capture a deterministic browser scenario", input: example },
],
+26 -3
View File
@@ -167,6 +167,13 @@ import {
interfaceBuilderViewRequestSchema,
projectInterfaceBuilderView,
} from "./interfaceBuilderView.js";
import {
browserScenarioSummarySchema,
browserScenarioViewContextSchema,
browserScenarioViewItemsSchema,
browserScenarioViewRequestSchema,
projectBrowserScenarioView,
} from "./browserScenarioView.js";
import {
projectWebNetworkCaptureView,
captureViewRequestSchema,
@@ -322,6 +329,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
environmentManifestViewRequestSchema,
environmentStateViewRequestSchema,
captureViewRequestSchema,
browserScenarioViewRequestSchema,
protobufViewRequestSchema,
qlogViewRequestSchema,
pcapViewRequestSchema,
@@ -604,6 +612,13 @@ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
summary: captureSummarySchema,
});
/** Step outcomes, focus accessibility tree and event counts for selected scenario delivery. */
export const browserScenarioSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("capture_browser_scenario"),
summary: browserScenarioSummarySchema,
});
/** Exact package declarations with retained decoded manifest text. */
export const androidPackageSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -754,6 +769,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
webNetworkCaptureSummaryViewResultSchema,
browserScenarioSummaryViewResultSchema,
androidPackageSummaryViewResultSchema,
pcapCaptureSummaryViewResultSchema,
dartAotSummaryViewResultSchema,
@@ -788,6 +804,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_web_network_capture"),
}),
z.strictObject({
kind: z.literal("browser-scenario"),
view: browserScenarioViewRequestSchema,
items: browserScenarioViewItemsSchema,
...viewResultBase,
...browserScenarioViewContextSchema.shape,
parent_operation: z.literal("capture_browser_scenario"),
}),
z.strictObject({
kind: z.literal("qlog"),
view: qlogViewRequestSchema,
@@ -1004,9 +1028,7 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
parent.operation === "capture_browser_scenario"
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest and environment-state views.`
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, or web network capture Evidence.`,
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, web network capture or browser scenario Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -1048,6 +1070,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_managed_members", projectManagedMemberView],
["inspect_android_package", projectAndroidPackageView],
["capture_browser_scenario", projectBrowserScenarioView],
["inspect_dart_aot", projectDartAotView],
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
["inspect_model_artifact", projectModelArtifactView],
@@ -0,0 +1,502 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { sanitizedBrowserUrlSchema } from "../browserObservation.js";
import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "../browserScenarioCapture.js";
import {
browserScenarioCompletenessSchema,
browserScenarioEventKindSchema,
browserScenarioEventSchema,
browserStepArtifactsSchema,
type BrowserScenarioStep,
} from "../browserScenarioCaptureValues.js";
import { compareAsciiStrings } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import type {
AnalysisViewCoverage,
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
const OPERATION = "capture_browser_scenario" as const;
const ARTIFACT_SECTIONS = browserStepArtifactsSchema.keyof().options;
const artifactSectionSchema = browserStepArtifactsSchema.keyof();
type ArtifactSection = z.infer<typeof artifactSectionSchema>;
type Step = BrowserScenarioStep;
const countSchema = z.number().int().nonnegative();
const statusClassSchema = z.enum([
"1xx",
"2xx",
"3xx",
"4xx",
"5xx",
"failed",
"unknown",
]);
type StatusClass = z.infer<typeof statusClassSchema>;
/** Section names by recorded state; not_requested sections are omitted. */
const stepSectionsSchema = z.strictObject({
captured: z.array(artifactSectionSchema),
missing: z.array(artifactSectionSchema),
truncated: z.array(artifactSectionSchema),
});
const summaryStepSchema = z.strictObject({
step_index: countSchema,
step_id: z.string().min(1),
action: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled"]),
error: z.string().nullable(),
elapsed_ms: countSchema,
before_url: sanitizedBrowserUrlSchema
.exactOptional()
.describe(
"Present only when it differs from the previous step's after_url (always for step 0).",
),
after_url: sanitizedBrowserUrlSchema,
events: z.strictObject({
sequence_start: z.number().int().min(1),
sequence_end: countSchema,
}),
sections: stepSectionsSchema,
completeness: z.enum(["complete", "incomplete", "truncated"]),
});
const focusAccessibilitySchema = z.strictObject({
focus_step_index: countSchema,
focus: z.enum(["first-unsuccessful-step", "last-step"]),
/** The step whose tree is returned: the focus step, or the nearest earlier step that captured one. */
step_index: countSchema.nullable(),
accessibility: browserStepArtifactsSchema.shape.accessibility,
});
/** Compact capture overview; complete records stay in the retained parent. */
export const browserScenarioSummarySchema = z.strictObject({
browser: browserScenarioCaptureSchema.shape.browser,
scenario: browserScenarioCaptureSchema.shape.scenario,
duration_ms: countSchema,
completeness: browserScenarioCompletenessSchema,
steps: z.array(summaryStepSchema),
final_accessibility: focusAccessibilitySchema,
events: z.strictObject({
retained: countSchema,
dropped: countSchema,
by_kind: z.array(
z.strictObject({
kind: browserScenarioEventKindSchema,
count: countSchema,
}),
),
omissions: z.strictObject({
by_kind:
browserScenarioCaptureSchema.shape.events.shape.omissions.shape.by_kind,
network_transactions: countSchema,
network_transaction_range:
browserScenarioCaptureSchema.shape.events.shape.omissions.shape
.network_transaction_range,
}),
}),
network: z.strictObject({
transactions: countSchema,
by_origin: z.array(
z.strictObject({
origin: z.string().nullable(),
transactions: countSchema,
}),
),
by_resource_type: z.array(
z.strictObject({
resource_type: z.string().min(1),
transactions: countSchema,
}),
),
by_status_class: z
.array(
z.strictObject({
status_class: statusClassSchema,
transactions: countSchema,
}),
)
.describe(
"Final retained outcome per transaction: the first response status class, failed, or unknown when neither was retained.",
),
}),
limitation_count: countSchema,
});
const indicesSchema = z.array(countSchema);
/** Pages of exact retained steps or events, in original order. */
export const browserScenarioViewRequestSchema = z.strictObject({
kind: z.literal("browser-scenario"),
collection: z.enum(["steps", "events"]),
step_indices: indicesSchema
.optional()
.describe("Exact step indices; omitted selects all, [] selects none."),
artifacts: z
.array(artifactSectionSchema)
.default([])
.describe(
"Steps only: captured sections returned in full. Other recorded sections are listed in omitted_artifacts with their state.",
),
event_kinds: z
.array(browserScenarioEventKindSchema)
.optional()
.describe(
"Events only: exact kinds; omitted selects all, [] selects none.",
),
transaction_ids: z
.array(z.string().min(1))
.optional()
.describe(
"Events only: exact network transaction IDs; omitted selects all, [] selects none. Events without a transaction never match a transaction selection.",
),
offset: countSchema.default(0),
limit: z.number().int().positive().default(64),
});
export type BrowserScenarioViewRequest = z.output<
typeof browserScenarioViewRequestSchema
>;
/** One exact step whose unselected captured sections are named rather than returned. */
const stepRowSchema = z.strictObject({
step_index: countSchema,
step_id: z.string().min(1),
action: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled"]),
error: z.string().nullable(),
elapsed_ms: countSchema,
before_url: sanitizedBrowserUrlSchema,
after_url: sanitizedBrowserUrlSchema,
event_sequence_start: z.number().int().min(1),
event_sequence_end: countSchema,
completeness: browserScenarioCompletenessSchema,
artifacts: browserStepArtifactsSchema.partial(),
omitted_artifacts: z.array(
z.strictObject({
section: artifactSectionSchema,
state: z.enum(["captured", "missing", "truncated"]),
}),
),
});
const eventRowSchema = z.strictObject({
item_index: countSchema,
event: browserScenarioEventSchema,
});
export const browserScenarioViewItemsSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
collection: z.literal("steps"),
rows: z.array(stepRowSchema),
}),
z.strictObject({
collection: z.literal("events"),
rows: z.array(eventRowSchema),
}),
],
);
/** Capture-wide facts every page repeats so exhaustion is not read as completeness. */
export const browserScenarioViewContextSchema = z.strictObject({
source_completeness: browserScenarioCompletenessSchema,
source_events: z.strictObject({
retained: countSchema,
dropped: countSchema,
}),
});
const unsupportedView = (): AnalysisError =>
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
'Parent Evidence operation "capture_browser_scenario" supports the summary, browser-scenario, environment-manifest and environment-state views.',
},
]);
const sameUrl = (
left: Step["after_url"] | undefined,
right: Step["before_url"],
): boolean =>
left !== undefined &&
left.url === right.url &&
left.redacted === right.redacted;
const stepSections = (step: Step): z.infer<typeof stepSectionsSchema> => {
const sections: z.infer<typeof stepSectionsSchema> = {
captured: [],
missing: [],
truncated: [],
};
for (const section of ARTIFACT_SECTIONS) {
const { state } = step.artifacts[section];
if (state !== "not_requested") sections[state].push(section);
}
return sections;
};
const summaryStep = (
step: Step,
previous: Step | undefined,
): z.infer<typeof summaryStepSchema> => ({
step_index: step.step_index,
step_id: step.step_id,
action: step.action,
status: step.status,
error: step.error,
elapsed_ms: step.elapsed_ms,
...(sameUrl(previous?.after_url, step.before_url)
? {}
: { before_url: step.before_url }),
after_url: step.after_url,
events: {
sequence_start: step.event_sequence_start,
sequence_end: step.event_sequence_end,
},
sections: stepSections(step),
completeness: step.completeness.status,
});
/** The first unsuccessful step explains a failure; otherwise the final screen does. */
const focusAccessibility = (
steps: readonly Step[],
): z.infer<typeof focusAccessibilitySchema> => {
const unsuccessful = steps.findIndex(({ status }) => status !== "completed");
const focusIndex = unsuccessful === -1 ? steps.length - 1 : unsuccessful;
const focus = steps[focusIndex];
if (focus === undefined) throw new Error("Capture has no steps");
const kind =
unsuccessful === -1 ? ("last-step" as const) : "first-unsuccessful-step";
for (let index = focusIndex; index >= 0; index -= 1) {
const accessibility = steps[index]?.artifacts.accessibility;
if (accessibility?.state === "captured")
return {
focus_step_index: focusIndex,
focus: kind,
step_index: index,
accessibility,
};
}
return {
focus_step_index: focusIndex,
focus: kind,
step_index: null,
accessibility: focus.artifacts.accessibility,
};
};
/** Counts in canonical key order; a null origin sorts first. */
const sortedCounts = <Key extends string | null>(
counts: ReadonlyMap<Key, number>,
): [Key, number][] =>
[...counts].sort(([left], [right]) =>
left === null ? -1 : right === null ? 1 : compareAsciiStrings(left, right),
);
const increment = <Key>(counts: Map<Key, number>, key: Key): void => {
counts.set(key, (counts.get(key) ?? 0) + 1);
};
/** Count retained network transactions once each, from their first retained request event. */
const networkCounts = (
capture: BrowserScenarioCapture,
): z.infer<typeof browserScenarioSummarySchema>["network"] => {
const origins = new Map<string | null, number>();
const resourceTypes = new Map<string, number>();
const outcomes = new Map<string, StatusClass>();
for (const event of capture.events.items) {
if (event.kind === "request") {
if (outcomes.has(event.transaction_id)) continue;
outcomes.set(event.transaction_id, "unknown");
increment(origins, event.url.origin);
increment(resourceTypes, event.resource_type);
} else if (event.kind === "response") {
if (outcomes.get(event.transaction_id) === "unknown")
outcomes.set(
event.transaction_id,
`${String(Math.floor(event.status / 100))}xx` as StatusClass,
);
} else if (event.kind === "request-failed") {
if (outcomes.get(event.transaction_id) === "unknown")
outcomes.set(event.transaction_id, "failed");
}
}
const statusClasses = new Map<StatusClass, number>();
for (const outcome of outcomes.values()) increment(statusClasses, outcome);
return {
transactions: outcomes.size,
by_origin: sortedCounts(origins).map(([origin, transactions]) => ({
origin,
transactions,
})),
by_resource_type: sortedCounts(resourceTypes).map(
([resource_type, transactions]) => ({ resource_type, transactions }),
),
by_status_class: sortedCounts(statusClasses).map(
([status_class, transactions]) => ({ status_class, transactions }),
),
};
};
const eventCounts = (
capture: BrowserScenarioCapture,
): z.infer<typeof browserScenarioSummarySchema>["events"] => {
const kinds = new Map<
BrowserScenarioCapture["events"]["items"][number]["kind"],
number
>();
for (const event of capture.events.items) increment(kinds, event.kind);
const { omissions } = capture.events;
return {
retained: capture.events.retained,
dropped: capture.events.dropped,
by_kind: sortedCounts(kinds).map(([kind, count]) => ({ kind, count })),
omissions: {
by_kind: omissions.by_kind,
network_transactions: omissions.network_transactions.length,
network_transaction_range: omissions.network_transaction_range,
},
};
};
const selected = <Value>(
filter: readonly Value[] | undefined,
): ((value: Value) => boolean) => {
if (filter === undefined) return () => true;
const values = new Set(filter);
return (value) => values.has(value);
};
const stepRow = (
{ artifacts: recorded, ...step }: Step,
selection: ReadonlySet<ArtifactSection>,
): z.infer<typeof stepRowSchema> => {
const artifacts: z.infer<typeof stepRowSchema>["artifacts"] = {};
const omitted: z.infer<typeof stepRowSchema>["omitted_artifacts"] = [];
for (const section of ARTIFACT_SECTIONS) {
const artifact = recorded[section];
if (selection.has(section))
Object.assign(artifacts, { [section]: artifact });
else if (artifact.state !== "not_requested")
omitted.push({ section, state: artifact.state });
}
return { ...step, artifacts, omitted_artifacts: omitted };
};
/** One stable page of matching steps or events, in original order. */
const pageRecords = (
capture: BrowserScenarioCapture,
view: BrowserScenarioViewRequest,
): {
readonly items: z.infer<typeof browserScenarioViewItemsSchema>;
readonly coverage: AnalysisViewCoverage;
} => {
const stepSelected = selected(view.step_indices);
const page = <Row>(rows: readonly Row[]) => {
const returned = rows.slice(view.offset, view.offset + view.limit);
return {
rows: returned,
coverage: pageViewCoverage(view.offset, returned.length, rows.length),
};
};
if (view.collection === "steps") {
const artifacts = new Set(view.artifacts);
const { rows, coverage } = page(
capture.steps.filter(({ step_index }) => stepSelected(step_index)),
);
return {
items: {
collection: "steps",
rows: rows.map((step) => stepRow(step, artifacts)),
},
coverage,
};
}
const kindSelected = selected(view.event_kinds);
const transactionSelected = selected(view.transaction_ids);
const { rows, coverage } = page(
capture.events.items.flatMap((event, item_index) =>
stepSelected(event.step_index) &&
kindSelected(event.kind) &&
(view.transaction_ids === undefined ||
("transaction_id" in event &&
transactionSelected(event.transaction_id)))
? [{ item_index, event }]
: [],
),
);
return { items: { collection: "events", rows }, coverage };
};
/** Project retained scenario Evidence without relaunching the browser or replaying actions. */
export const projectBrowserScenarioView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "browser-scenario")
return err(unsupportedView());
const parsed = parseAnalysisViewResult(browserScenarioCaptureSchema, parent);
if (!parsed.ok) return parsed;
const capture = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: OPERATION,
parent_digest: parent.evidenceId.slice(3),
artifact: { path: "", sha256: null },
limitations: [...parent.limitations],
unknowns: [
"Step states record one controlled replay; they do not establish the application's current state.",
...(capture.events.dropped > 0
? [
`${String(capture.events.dropped)} events were dropped by capture budgets; counts and pages cover retained events only.`,
]
: []),
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
summary: {
browser: capture.browser,
scenario: capture.scenario,
duration_ms: capture.duration_ms,
completeness: capture.completeness,
steps: capture.steps.map((step, index) =>
summaryStep(step, capture.steps[index - 1]),
),
final_accessibility: focusAccessibility(capture.steps),
events: eventCounts(capture),
network: networkCounts(capture),
limitation_count: capture.limitations.length,
},
coverage: completeWithinViewCoverage(1, 1),
});
return ok({
kind: "browser-scenario",
view,
...shared,
source_completeness: capture.completeness,
source_events: {
retained: capture.events.retained,
dropped: capture.events.dropped,
},
...pageRecords(capture, view),
});
};
+1
View File
@@ -990,6 +990,7 @@ const registerObservationTools = ({
});
registerBrowserScenarioTool(server, {
...common,
evidenceById,
provider: options.browserScenarioCapture,
loadFailure: options.optionalProviderLoadFailures?.browserScenarioCapture,
});
+40 -9
View File
@@ -5,6 +5,12 @@ import {
type OptionalProviderLoadFailure,
} from "../application/OptionalObservationProviders.js";
import { err } from "../domain/result.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import { analysisDetailSchema } from "../contracts/analysisDetail.js";
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js";
@@ -23,6 +29,7 @@ interface BrowserScenarioToolRegistration {
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly provider: BrowserScenarioCapturePort | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis?: WithAdmittedAnalysis;
}
@@ -36,20 +43,29 @@ export const registerBrowserScenarioTool = (
contract.name,
toolRegistrationOptions(contract),
async (input, context) => {
const parsedScenario = browserScenarioSchema.safeParse(input);
if (!parsedScenario.success)
// Delivery detail is not part of the scenario or its recorded parameters.
const { detail: requestedDetail, ...scenarioInput } = input;
const parsedScenario = browserScenarioSchema.safeParse(scenarioInput);
const parsedDetail = analysisDetailSchema.safeParse(requestedDetail);
if (!parsedScenario.success || !parsedDetail.success) {
const issues = [
...(parsedScenario.error?.issues ?? []),
...(parsedDetail.error?.issues.map((issue) => ({
...issue,
path: ["detail", ...issue.path],
})) ?? []),
];
return server.delivery.toCallToolResult(
err(
analysisInputErrorFromIssues(
contract.name,
parsedScenario.error.issues,
input,
{ cause: parsedScenario.error },
),
analysisInputErrorFromIssues(contract.name, issues, input, {
cause: parsedScenario.error ?? parsedDetail.error,
}),
),
contract,
);
}
const scenario = parsedScenario.data;
const detail = parsedDetail.data;
if (options.loadFailure !== undefined)
return server.delivery.toCallToolResult(
err(optionalProviderUnavailable(options.loadFailure, contract.name)),
@@ -61,6 +77,19 @@ export const registerBrowserScenarioTool = (
contract.name,
context.mcpReq.signal,
async () => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
// Refuse summary delivery before launching a browser that could not retain its Evidence.
if (detail === "summary") {
const ready = requireAnalysisViewRetention(
contract.name,
retention,
);
if (!ready.ok)
return server.delivery.toCallToolResult(ready, contract);
}
const result = await logToolExecution(
options.logger,
contract.name,
@@ -71,7 +100,9 @@ export const registerBrowserScenarioTool = (
);
return deliverEvidenceResult(
server,
result,
detail === "summary" && result.ok
? summarizeRetainedAnalysis(result.value, retention)
: result,
contract,
options.recordEvidence,
);
@@ -37,6 +37,38 @@ describe("scenario CLI schema diagnostics", () => {
});
});
cliTest(
"refuses summary delivery without a complete Evidence bundle before launching",
async ({ cli }) => {
const scenario = {
browser: { mode: "launch", executable_path: "/nonexistent/chromium" },
start_url: { url: "https://app.example.test/", query: [] },
actions: [
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
],
};
const result = await cli.run({
arguments: [
"capture-browser-scenario",
JSON.stringify(scenario),
"--detail",
"summary",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "capture_browser_scenario",
issues: [{ path: ["evidence_output"], reason: "invalid_value" }],
},
});
},
);
cliTest("reports Electron scenario schema paths", async ({ cli }) => {
const result = await cli.run({
arguments: ["capture-electron-scenario", "{}", "--json"],
@@ -367,3 +367,111 @@ test("records a completed late-close cancellation as server Evidence", async ()
await Promise.all([client.close(), server.close(), session.close()]);
}
});
test("delivers a retained summary and pages its exact steps and events", async () => {
const provider = new FakeBrowserScenarioProvider();
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(ok(null)),
}));
const server = createServer(
{ kind: "session", session },
{
browserScenarioCapture: provider,
availabilityPolicy: () => ({
processCaptureEnabled: false,
investigationInputRoots: 0,
}),
},
);
const client = new Client({ name: "scenario-summary", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const tools = (await client.listTools()).tools;
const advertised = tools.find(
({ name }) => name === "capture_browser_scenario",
);
if (advertised?.outputSchema === undefined)
throw new Error("Missing advertised scenario output schema");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validateInput = ajv.compile(advertised.inputSchema);
const validateOutput = ajv.compile(
z.record(z.string(), z.unknown()).parse(advertised.outputSchema),
);
const request = { ...minimalScenario(), detail: "summary" };
expect(validateInput(request), JSON.stringify(validateInput.errors)).toBe(
true,
);
const response = await client.callTool({
name: "capture_browser_scenario",
arguments: request,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(
validateOutput(response.structuredContent),
JSON.stringify(validateOutput.errors),
).toBe(true);
const summary = z
.object({
normalized_result: z.object({
kind: z.literal("summary"),
parent_evidence_id: z.string(),
summary: z.object({
steps: z.array(z.object({ step_id: z.string() })),
}),
}),
})
.parse(response.structuredContent).normalized_result;
expect(summary.summary.steps.map(({ step_id }) => step_id)).toEqual([
"scenario_start",
"settle",
]);
// Delivery detail is neither sent to the provider nor recorded as a scenario parameter.
expect(provider.scenarios[0]).not.toHaveProperty("detail");
const parent = session.evidenceById(summary.parent_evidence_id);
expect(parent?.operation).toBe("capture_browser_scenario");
expect(parent?.parameters).not.toHaveProperty("detail");
const page = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
},
view: {
kind: "browser-scenario",
collection: "steps",
step_indices: [1],
},
},
});
expect(page.isError, JSON.stringify(page)).not.toBe(true);
expect(page.structuredContent).toMatchObject({
normalized_result: {
kind: "browser-scenario",
parent_evidence_id: summary.parent_evidence_id,
items: {
collection: "steps",
rows: [{ step_index: 1, step_id: "settle", artifacts: {} }],
},
coverage: { total: 1, exhausted: true },
},
});
const invalid = await client.callTool({
name: "capture_browser_scenario",
arguments: { ...minimalScenario(), detail: "brief" },
});
expect(parseMcpToolError(invalid)).toMatchObject({
error: { code: "invalid_request" },
});
expect(provider.scenarios).toHaveLength(1);
} finally {
await Promise.all([client.close(), server.close(), session.close()]);
}
});