feat(browser): retain compact scenario summaries and selected views (#7120)

Retain complete browser scenario Evidence while delivering compact summaries and selected step, event, transaction and text views through shared CLI/MCP workflows. Preserve initial CLI failures across cleanup retries and validate with real Chromium/TodoMVC.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 19:03:06 +08:00
1 parent bc143125ed
commit fc43e285ea
19 files changed
+2297 -90

No files matched your search

@@ -0,0 +1,521 @@
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";
import { projectBrowserScenarioView } from "../../domain/analysisView/browserScenarioView.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 = async (
capture: BrowserScenarioCapture,
request: AnalysisViewRequest,
) => {
const projected = await 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("counts inherited Evidence limitations alongside capture-local caveats", async () => {
const capture = scenarioCaptureFixture();
expect(capture.limitations).toEqual([]);
const summary = await view(capture, { kind: "summary" });
expect(summary).toMatchObject({
limitations: [
"Artifact identity is unavailable for this observation.",
"Browser host platform and architecture were not observed.",
],
summary: { limitation_count: 2 },
});
});
it("summarizes steps compactly and focuses the first failing step's nearest captured tree", async () => {
const summary = await view(quizCapture(), { kind: "summary" });
expect(summary).toMatchObject({
kind: "summary",
parent_operation: "capture_browser_scenario",
artifact: 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({
selected: true,
transactions: 8,
without_request: 0,
ambiguous_transactions: 0,
failed_transactions: 1,
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", async () => {
const capture = scenarioCaptureFixture();
expect(await 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", async () => {
const page = await 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", async () => {
const capture = scenarioCaptureFixture();
const responses = (await view(capture, {
kind: "browser-scenario",
collection: "events",
event_kinds: ["response", "request-failed"],
transaction_ids: ["request-2", "request-8"],
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(
await view(capture, {
kind: "browser-scenario",
collection: "events",
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", async () => {
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 = await 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 = await 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",
});
});
});
describe("browser scenario retained boundaries", () => {
it("counts transactions whose request event was omitted and keeps source omissions on an exhausted page", async () => {
const base = scenarioCaptureFixture();
const omitted = (
event: BrowserScenarioCapture["events"]["items"][number],
) =>
event.kind === "request" ||
(event.kind === "network-content" && event.phase === "request");
const removed = base.events.items.filter(omitted);
const items = base.events.items.filter((event) => !omitted(event));
const capture: BrowserScenarioCapture = {
...base,
events: {
...base.events,
items,
retained: items.length,
dropped: removed.length,
omissions: {
...base.events.omissions,
by_kind: [
{ kind: "request", count: 8, reasons: ["max-events"] },
{ kind: "network-content", count: 1, reasons: ["max-events"] },
],
},
},
completeness: {
status: "truncated",
equality_eligible: false,
missing_sections: [],
truncated_sections: ["events"],
},
};
expect(await view(capture, { kind: "summary" })).toMatchObject({
summary: {
network: {
transactions: 8,
without_request: 8,
failed_transactions: 1,
},
},
});
expect(
await view(capture, {
kind: "browser-scenario",
collection: "transactions",
transaction_ids: ["request-8"],
event_kinds: [],
offset: 0,
limit: 1,
}),
).toMatchObject({
source_completeness: { status: "truncated" },
source_events: { dropped: 9 },
coverage: { total: 1, exhausted: true },
items: {
rows: [
{
transaction_id: "request-8",
events: [],
omitted_events: [{ kind: "request-failed" }],
},
],
},
});
});
it("keeps network bodies opt-in while retaining each transaction's event identities", async () => {
const capture = scenarioCaptureFixture();
const request = {
kind: "browser-scenario" as const,
collection: "transactions" as const,
transaction_ids: ["request-2"],
offset: 0,
limit: 1,
};
// Parse defaults at the public boundary rather than supplying a manually normalized request.
const page = await inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) },
view: request,
});
if (!page.ok) throw page.error;
const result = page.value.normalized_result;
expect(result).toMatchObject({
items: {
collection: "transactions",
rows: [{ transaction_id: "request-2" }],
},
});
expect(JSON.stringify(result)).not.toContain('"content":');
const selected = await view(capture, {
...request,
event_kinds: ["network-content"],
});
expect(JSON.stringify(selected)).toContain('"content":');
expect(selected).toMatchObject({
items: {
rows: [
{
omitted_events: expect.arrayContaining([
{ item_index: expect.any(Number), kind: "request" },
]),
},
],
},
});
});
it("distinguishes unrequested and legacy-unknown network observation from an observed empty inventory", () => {
const base = scenarioCaptureFixture([], {
request_body: false,
response_body: false,
header_values: false,
});
const project = (capture: unknown) => {
const result = projectBrowserScenarioView(
{
evidenceId: `ev:${"a".repeat(64)}`,
operation: "capture_browser_scenario",
normalizedResult: capture,
limitations: [],
},
{ kind: "summary" },
);
if (!result.ok) throw result.error;
return result.value;
};
expect(project(base)).toMatchObject({
summary: { network: { selected: true, transactions: 0 } },
});
expect(
project({ ...base, scenario: { ...base.scenario, event_families: [] } }),
).toMatchObject({
summary: { network: { selected: false, transactions: null } },
});
const { event_families: _families, ...legacyScenario } = base.scenario;
expect(project({ ...base, scenario: legacyScenario })).toMatchObject({
summary: { network: { selected: null, transactions: null } },
});
});
});
describe("browser scenario text and selection boundaries", () => {
it("pages exact Unicode text with the full artifact digest and reports unavailable text explicitly", async () => {
const capture = quizCapture("completed");
const step = capture.steps[1]!;
step.artifacts.accessibility = tree("A💡B\nC");
const request = {
kind: "browser-scenario" as const,
collection: "text" as const,
step_index: 1,
artifact: "accessibility" as const,
offset: 1,
limit: 2,
};
expect(await view(capture, request)).toMatchObject({
items: {
state: "captured",
text: "💡",
sha256: tree("A💡B\nC").value.sha256,
bytes: Buffer.byteLength("A💡B\nC"),
},
coverage: { examined: 2, total: 6, next_offset: 3 },
});
expect(await view(capture, { ...request, offset: 99 })).toMatchObject({
items: { text: "" },
coverage: { status: "empty", total: 6, exhausted: true },
});
expect(await view(capture, { ...request, step_index: 0 })).toMatchObject({
items: { state: "not_requested", text: null, sha256: null, bytes: null },
});
});
it("rejects irrelevant selection fields and nonexistent text steps with typed diagnostics", async () => {
for (const request of [
{ kind: "browser-scenario", collection: "events", artifacts: [] },
{
kind: "browser-scenario",
collection: "text",
step_index: 99,
artifact: "dom",
},
]) {
const result = await inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence() },
view: request,
});
if (result.ok)
throw new Error("Expected the invalid view to be rejected");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
});
}
});
it("keeps source truncation facts when a text page has no retained payload", async () => {
const capture = quizCapture("completed");
const completeness = {
status: "truncated" as const,
equality_eligible: false as const,
missing_sections: [],
truncated_sections: ["accessibility" as const],
};
capture.steps[1]!.artifacts.accessibility = {
state: "truncated",
observed: 1000,
retained: 0,
reason: "capture budget",
};
capture.steps[1]!.completeness = completeness;
capture.completeness = completeness;
expect(
await view(capture, {
kind: "browser-scenario",
collection: "text",
step_index: 1,
artifact: "accessibility",
offset: 0,
limit: 20,
}),
).toMatchObject({
source_completeness: { status: "truncated" },
items: {
state: "truncated",
observed: 1000,
retained: 0,
reason: "capture budget",
text: null,
},
coverage: { status: "empty", exhausted: true },
});
});
});