mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
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:
19 files changed
+2297
-90
No files matched your search
@@ -122,6 +122,13 @@ import {
|
||||
|
||||
import { absoluteLocalPathStringSchema } from "../localPath.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserScenarioSummarySchema,
|
||||
browserScenarioViewContextSchema,
|
||||
browserScenarioViewItemsSchema,
|
||||
browserScenarioViewRequestSchema,
|
||||
} from "./browserScenarioViewSchemas.js";
|
||||
import { projectBrowserScenarioView } from "./browserScenarioView.js";
|
||||
import {
|
||||
desktopViewRequestSchema,
|
||||
desktopViewItemsSchema,
|
||||
@@ -393,6 +400,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
environmentStateViewRequestSchema,
|
||||
environmentCoverageViewRequestSchema,
|
||||
captureViewRequestSchema,
|
||||
browserScenarioViewRequestSchema,
|
||||
protobufViewRequestSchema,
|
||||
qlogViewRequestSchema,
|
||||
pcapViewRequestSchema,
|
||||
@@ -729,6 +737,14 @@ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
|
||||
summary: captureSummarySchema,
|
||||
});
|
||||
|
||||
/** Compact outcomes and the focus screen from a retained browser scenario. */
|
||||
export const browserScenarioSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
artifact: z.null(),
|
||||
parent_operation: z.literal("capture_browser_scenario"),
|
||||
summary: browserScenarioSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact package declarations with retained decoded manifest text. */
|
||||
export const androidPackageSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
@@ -1022,6 +1038,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
managedMemberSummaryViewResultSchema,
|
||||
webNetworkCaptureSummaryViewResultSchema,
|
||||
browserScenarioSummaryViewResultSchema,
|
||||
androidPackageSummaryViewResultSchema,
|
||||
pcapCaptureSummaryViewResultSchema,
|
||||
dartAotSummaryViewResultSchema,
|
||||
@@ -1076,6 +1093,15 @@ 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,
|
||||
artifact: z.null(),
|
||||
parent_operation: z.literal("capture_browser_scenario"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("qlog"),
|
||||
view: qlogViewRequestSchema,
|
||||
@@ -1283,9 +1309,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, environment-state and environment-coverage views.`
|
||||
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed Evidence from a supported parent operation with a compatible view. For a summary receipt, use its normalized_result.parent_evidence_id rather than the receipt's own evidence_id.`,
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed Evidence from a supported parent operation with a compatible view. For a summary receipt, use its normalized_result.parent_evidence_id rather than the receipt's own evidence_id.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
@@ -1331,6 +1355,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_apk_signature", projectApkSignatureView],
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { z } from "zod";
|
||||
import type { RetainedBrowserScenarioCapture } from "../browserScenarioCapture.js";
|
||||
import { isImmutableJsonSnapshot } from "../immutableJson.js";
|
||||
import { compareAsciiStrings } from "../canonicalOrdering.js";
|
||||
|
||||
const count = z.int().nonnegative();
|
||||
const statusClass = z.enum([
|
||||
"1xx",
|
||||
"2xx",
|
||||
"3xx",
|
||||
"4xx",
|
||||
"5xx",
|
||||
"failed",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
/** Counts concern retained transaction identities, including those without a request event. */
|
||||
export const browserScenarioNetworkSummarySchema = z.strictObject({
|
||||
selected: z.boolean().nullable(),
|
||||
transactions: count.nullable(),
|
||||
without_request: count,
|
||||
ambiguous_transactions: count,
|
||||
failed_transactions: count,
|
||||
by_origin: z.array(
|
||||
z.strictObject({ origin: z.string().nullable(), transactions: count }),
|
||||
),
|
||||
by_resource_type: z.array(
|
||||
z.strictObject({
|
||||
resource_type: z.string().nullable(),
|
||||
transactions: count,
|
||||
}),
|
||||
),
|
||||
by_status_class: z.array(
|
||||
z.strictObject({ status_class: statusClass, transactions: count }),
|
||||
),
|
||||
});
|
||||
|
||||
interface TransactionGroup {
|
||||
readonly transaction_id: string;
|
||||
readonly source_item_indices: readonly number[];
|
||||
readonly origin: string | null;
|
||||
readonly resource_type: string | null;
|
||||
readonly status_class: z.infer<typeof statusClass>;
|
||||
readonly has_request: boolean;
|
||||
readonly failed: boolean;
|
||||
readonly ambiguous: boolean;
|
||||
}
|
||||
|
||||
interface TransactionFacts {
|
||||
indices: number[];
|
||||
origins: Set<string | null>;
|
||||
resourceTypes: Set<string>;
|
||||
statuses: Set<number>;
|
||||
requests: number;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** Sealed historical captures own this metadata-only index until their retained lifetime ends. */
|
||||
const transactionsByCapture = new WeakMap<
|
||||
object,
|
||||
readonly TransactionGroup[]
|
||||
>();
|
||||
|
||||
/** Keep all observed alternatives; one missing event never erases its transaction. */
|
||||
export const browserScenarioTransactionGroups = (
|
||||
capture: RetainedBrowserScenarioCapture,
|
||||
): readonly TransactionGroup[] => {
|
||||
const reusable = isImmutableJsonSnapshot(capture);
|
||||
const known = reusable ? transactionsByCapture.get(capture) : undefined;
|
||||
if (known !== undefined) return known;
|
||||
const groups = new Map<string, TransactionFacts>();
|
||||
for (const [index, event] of capture.events.items.entries()) {
|
||||
if (!("transaction_id" in event)) continue;
|
||||
let group = groups.get(event.transaction_id);
|
||||
if (group === undefined) {
|
||||
group = {
|
||||
indices: [],
|
||||
origins: new Set(),
|
||||
resourceTypes: new Set(),
|
||||
statuses: new Set(),
|
||||
requests: 0,
|
||||
failed: false,
|
||||
};
|
||||
groups.set(event.transaction_id, group);
|
||||
}
|
||||
group.indices.push(index);
|
||||
if (event.kind === "network-content") continue;
|
||||
group.origins.add(event.url.origin);
|
||||
group.resourceTypes.add(event.resource_type);
|
||||
if (event.kind === "request") group.requests += 1;
|
||||
if (event.kind === "response") group.statuses.add(event.status);
|
||||
if (event.kind === "request-failed") group.failed = true;
|
||||
}
|
||||
const result = [...groups].map(
|
||||
([transaction_id, group]): TransactionGroup => {
|
||||
const status = group.statuses.values().next().value;
|
||||
return Object.freeze({
|
||||
transaction_id,
|
||||
source_item_indices: Object.freeze(group.indices),
|
||||
origin:
|
||||
group.origins.size === 1
|
||||
? (group.origins.values().next().value ?? null)
|
||||
: null,
|
||||
resource_type:
|
||||
group.resourceTypes.size === 1
|
||||
? (group.resourceTypes.values().next().value ?? null)
|
||||
: null,
|
||||
status_class:
|
||||
group.statuses.size === 1 && status !== undefined
|
||||
? (`${String(Math.floor(status / 100))}xx` as z.infer<
|
||||
typeof statusClass
|
||||
>)
|
||||
: group.statuses.size === 0 && group.failed
|
||||
? "failed"
|
||||
: "unknown",
|
||||
has_request: group.requests > 0,
|
||||
failed: group.failed,
|
||||
ambiguous:
|
||||
group.requests > 1 ||
|
||||
group.statuses.size > 1 ||
|
||||
group.origins.size > 1 ||
|
||||
group.resourceTypes.size > 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (reusable) transactionsByCapture.set(capture, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const increment = <Key>(values: Map<Key, number>, key: Key): void => {
|
||||
values.set(key, (values.get(key) ?? 0) + 1);
|
||||
};
|
||||
const sorted = <Key extends string | null>(
|
||||
values: ReadonlyMap<Key, number>,
|
||||
): [Key, number][] =>
|
||||
[...values].sort(([left], [right]) =>
|
||||
left === right
|
||||
? 0
|
||||
: left === null
|
||||
? -1
|
||||
: right === null
|
||||
? 1
|
||||
: compareAsciiStrings(left, right),
|
||||
);
|
||||
|
||||
/** Null selection/counts preserve legacy unknowns and unrequested network observation. */
|
||||
export const summarizeBrowserScenarioNetwork = (
|
||||
capture: RetainedBrowserScenarioCapture,
|
||||
): z.infer<typeof browserScenarioNetworkSummarySchema> => {
|
||||
const groups = browserScenarioTransactionGroups(capture);
|
||||
const contentSelected = Object.values(capture.scenario.network_content).some(
|
||||
Boolean,
|
||||
);
|
||||
const selected =
|
||||
contentSelected ||
|
||||
capture.scenario.event_families?.includes("network") === true
|
||||
? true
|
||||
: capture.scenario.event_families === undefined
|
||||
? null
|
||||
: false;
|
||||
const origins = new Map<string | null, number>();
|
||||
const resources = new Map<string | null, number>();
|
||||
const statuses = new Map<z.infer<typeof statusClass>, number>();
|
||||
let withoutRequest = 0;
|
||||
let ambiguous = 0;
|
||||
let failed = 0;
|
||||
for (const group of groups) {
|
||||
if (!group.has_request) withoutRequest += 1;
|
||||
if (group.ambiguous) ambiguous += 1;
|
||||
if (group.failed) failed += 1;
|
||||
increment(origins, group.origin);
|
||||
increment(resources, group.resource_type);
|
||||
increment(statuses, group.status_class);
|
||||
}
|
||||
return {
|
||||
selected,
|
||||
transactions: selected === true || groups.length > 0 ? groups.length : null,
|
||||
without_request: withoutRequest,
|
||||
ambiguous_transactions: ambiguous,
|
||||
failed_transactions: failed,
|
||||
by_origin: sorted(origins).map(([origin, transactions]) => ({
|
||||
origin,
|
||||
transactions,
|
||||
})),
|
||||
by_resource_type: sorted(resources).map(
|
||||
([resource_type, transactions]) => ({ resource_type, transactions }),
|
||||
),
|
||||
by_status_class: sorted(statuses).map(([status_class, transactions]) => ({
|
||||
status_class,
|
||||
transactions,
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import {
|
||||
retainedBrowserScenarioCaptureSchema,
|
||||
type RetainedBrowserScenarioCapture as BrowserScenarioCapture,
|
||||
} from "../browserScenarioCapture.js";
|
||||
import type { BrowserScenarioStep as Step } 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 { createAnalysisViewResultReader } from "./analysisViewParent.js";
|
||||
import type {
|
||||
AnalysisViewCoverage,
|
||||
AnalysisViewParent,
|
||||
AnalysisViewRequest,
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
ARTIFACT_SECTIONS,
|
||||
stepRowSchema,
|
||||
browserScenarioSummarySchema,
|
||||
stepSectionsSchema,
|
||||
summaryStepSchema,
|
||||
focusAccessibilitySchema,
|
||||
browserScenarioViewItemsSchema,
|
||||
type BrowserScenarioViewRequest,
|
||||
type ArtifactSection,
|
||||
} from "./browserScenarioViewSchemas.js";
|
||||
|
||||
import {
|
||||
browserScenarioTransactionGroups,
|
||||
summarizeBrowserScenarioNetwork,
|
||||
} from "./browserScenarioNetworkView.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const readCapture = createAnalysisViewResultReader(
|
||||
retainedBrowserScenarioCaptureSchema,
|
||||
);
|
||||
|
||||
const unsupportedView = (): AnalysisError =>
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
'Parent Evidence operation "capture_browser_scenario" supports summary and browser-scenario views, plus compatible environment 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 === right
|
||||
? 0
|
||||
: 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);
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
/** Scan matching rows once and materialize only the requested page. */
|
||||
const selectedPage = <Input, Row>(
|
||||
source: readonly Input[],
|
||||
view: { readonly offset: number; readonly limit: number },
|
||||
matches: (item: Input, index: number) => boolean,
|
||||
project: (item: Input, index: number) => Row,
|
||||
): { rows: Row[]; coverage: AnalysisViewCoverage } => {
|
||||
const rows: Row[] = [];
|
||||
let total = 0;
|
||||
for (const [index, item] of source.entries()) {
|
||||
if (!matches(item, index)) continue;
|
||||
if (total >= view.offset && rows.length < view.limit)
|
||||
rows.push(project(item, index));
|
||||
total += 1;
|
||||
}
|
||||
return { rows, coverage: pageViewCoverage(view.offset, rows.length, total) };
|
||||
};
|
||||
|
||||
/** Text offsets are UTF-16 positions in the exact digest-bound retained artifact. */
|
||||
const textPage = (
|
||||
capture: BrowserScenarioCapture,
|
||||
view: Extract<BrowserScenarioViewRequest, { collection: "text" }>,
|
||||
) => {
|
||||
const artifact = capture.steps[view.step_index]!.artifacts[view.artifact];
|
||||
const value = artifact.state === "captured" ? artifact.value : null;
|
||||
const text = value?.text.slice(view.offset, view.offset + view.limit) ?? null;
|
||||
return {
|
||||
items: {
|
||||
collection: "text" as const,
|
||||
step_index: view.step_index,
|
||||
artifact: view.artifact,
|
||||
state: artifact.state,
|
||||
reason: "reason" in artifact ? artifact.reason : null,
|
||||
sha256: value?.sha256 ?? null,
|
||||
bytes: value?.bytes ?? null,
|
||||
observed: artifact.state === "truncated" ? artifact.observed : null,
|
||||
retained: artifact.state === "truncated" ? artifact.retained : null,
|
||||
text,
|
||||
},
|
||||
coverage: pageViewCoverage(
|
||||
view.offset,
|
||||
text?.length ?? 0,
|
||||
value?.text.length ?? 0,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** One stable page of exact recorded facts; no whole-result filtering arrays. */
|
||||
const pageRecords = (
|
||||
capture: BrowserScenarioCapture,
|
||||
view: BrowserScenarioViewRequest,
|
||||
): {
|
||||
readonly items: z.infer<typeof browserScenarioViewItemsSchema>;
|
||||
readonly coverage: AnalysisViewCoverage;
|
||||
} => {
|
||||
if (view.collection === "text") return textPage(capture, view);
|
||||
const stepSelected = selected(view.step_indices);
|
||||
if (view.collection === "steps") {
|
||||
const artifacts = new Set(view.artifacts);
|
||||
const page = selectedPage(
|
||||
capture.steps,
|
||||
view,
|
||||
({ step_index }) => stepSelected(step_index),
|
||||
(step) => stepRow(step, artifacts),
|
||||
);
|
||||
return {
|
||||
items: { collection: "steps", rows: page.rows },
|
||||
coverage: page.coverage,
|
||||
};
|
||||
}
|
||||
const kindSelected = selected(view.event_kinds);
|
||||
const transactionSelected = selected(view.transaction_ids);
|
||||
if (view.collection === "transactions") {
|
||||
const page = selectedPage(
|
||||
browserScenarioTransactionGroups(capture),
|
||||
view,
|
||||
(group) =>
|
||||
transactionSelected(group.transaction_id) &&
|
||||
(view.step_indices === undefined ||
|
||||
group.source_item_indices.some((index) =>
|
||||
stepSelected(capture.events.items[index]!.step_index),
|
||||
)),
|
||||
(group) => {
|
||||
const events: {
|
||||
item_index: number;
|
||||
event: BrowserScenarioCapture["events"]["items"][number];
|
||||
}[] = [];
|
||||
const omitted: {
|
||||
item_index: number;
|
||||
kind: BrowserScenarioCapture["events"]["items"][number]["kind"];
|
||||
}[] = [];
|
||||
for (const item_index of group.source_item_indices) {
|
||||
const event = capture.events.items[item_index]!;
|
||||
if (kindSelected(event.kind)) events.push({ item_index, event });
|
||||
else omitted.push({ item_index, kind: event.kind });
|
||||
}
|
||||
return {
|
||||
transaction_id: group.transaction_id,
|
||||
source_item_indices: [...group.source_item_indices],
|
||||
events,
|
||||
omitted_events: omitted,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
items: { collection: "transactions", rows: page.rows },
|
||||
coverage: page.coverage,
|
||||
};
|
||||
}
|
||||
const page = selectedPage(
|
||||
capture.events.items,
|
||||
view,
|
||||
(event) =>
|
||||
stepSelected(event.step_index) &&
|
||||
kindSelected(event.kind) &&
|
||||
(view.transaction_ids === undefined ||
|
||||
("transaction_id" in event &&
|
||||
transactionSelected(event.transaction_id))),
|
||||
(event, item_index) => ({ item_index, event }),
|
||||
);
|
||||
return {
|
||||
items: { collection: "events", rows: page.rows },
|
||||
coverage: page.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 = readCapture(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: null,
|
||||
limitations: [...parent.limitations],
|
||||
unknowns: [
|
||||
"Step states record one controlled replay; they do not establish the application's current state.",
|
||||
"Event step references record receipt attribution; they do not prove an action caused a transaction.",
|
||||
...(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: summarizeBrowserScenarioNetwork(capture),
|
||||
limitation_count: shared.limitations.length,
|
||||
},
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
});
|
||||
if (
|
||||
view.collection === "text" &&
|
||||
capture.steps[view.step_index] === undefined
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "step_index"],
|
||||
reason: "invalid_value",
|
||||
message: "The selected step is not present in the retained capture.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok({
|
||||
kind: "browser-scenario",
|
||||
view,
|
||||
...shared,
|
||||
source_completeness: capture.completeness,
|
||||
source_events: {
|
||||
retained: capture.events.retained,
|
||||
dropped: capture.events.dropped,
|
||||
},
|
||||
source_network_omissions: {
|
||||
transactions: capture.events.omissions.network_transactions.length,
|
||||
transaction_range: capture.events.omissions.network_transaction_range,
|
||||
},
|
||||
...pageRecords(capture, view),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { browserScenarioNetworkSummarySchema } from "./browserScenarioNetworkView.js";
|
||||
import { sanitizedBrowserUrlSchema } from "../browserObservation.js";
|
||||
import { retainedBrowserScenarioCaptureSchema } from "../browserScenarioCapture.js";
|
||||
import {
|
||||
browserScenarioCompletenessSchema,
|
||||
browserScenarioEventKindSchema,
|
||||
browserScenarioEventSchema,
|
||||
browserStepArtifactsSchema,
|
||||
} from "../browserScenarioCaptureValues.js";
|
||||
|
||||
export const ARTIFACT_SECTIONS = browserStepArtifactsSchema.keyof().options;
|
||||
const artifactSectionSchema = browserStepArtifactsSchema.keyof();
|
||||
export type ArtifactSection = z.infer<typeof artifactSectionSchema>;
|
||||
|
||||
const countSchema = z.number().int().nonnegative();
|
||||
/** Section names by recorded state; not_requested sections are omitted. */
|
||||
export const stepSectionsSchema = z.strictObject({
|
||||
captured: z.array(artifactSectionSchema),
|
||||
missing: z.array(artifactSectionSchema),
|
||||
truncated: z.array(artifactSectionSchema),
|
||||
});
|
||||
|
||||
export 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"]),
|
||||
});
|
||||
|
||||
export 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: retainedBrowserScenarioCaptureSchema.shape.browser,
|
||||
scenario: retainedBrowserScenarioCaptureSchema.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:
|
||||
retainedBrowserScenarioCaptureSchema.shape.events.shape.omissions.shape
|
||||
.by_kind,
|
||||
network_transactions: countSchema,
|
||||
network_transaction_range:
|
||||
retainedBrowserScenarioCaptureSchema.shape.events.shape.omissions.shape
|
||||
.network_transaction_range,
|
||||
}),
|
||||
}),
|
||||
network: browserScenarioNetworkSummarySchema,
|
||||
limitation_count: countSchema.describe(
|
||||
"Number of parent Evidence limitations returned with this summary, including inherited provenance caveats.",
|
||||
),
|
||||
});
|
||||
|
||||
const pageShape = {
|
||||
kind: z.literal("browser-scenario"),
|
||||
offset: countSchema.default(0),
|
||||
limit: z.int().positive().default(64),
|
||||
};
|
||||
const stepSelection = {
|
||||
step_indices: z
|
||||
.array(countSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact recorded step indices; omitted selects all, [] selects none.",
|
||||
),
|
||||
};
|
||||
const transactionSelection = {
|
||||
transaction_ids: z
|
||||
.array(z.string().min(1))
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact recorded transaction IDs; omitted selects all, [] selects none.",
|
||||
),
|
||||
};
|
||||
|
||||
/** Collection-specific fields are validated rather than silently ignored. */
|
||||
export const browserScenarioViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...pageShape,
|
||||
...stepSelection,
|
||||
collection: z.literal("steps"),
|
||||
artifacts: z
|
||||
.array(artifactSectionSchema)
|
||||
.default([])
|
||||
.describe(
|
||||
"Return only selected artifact payloads; omitted_artifacts names the other recorded sections.",
|
||||
),
|
||||
}),
|
||||
z.strictObject({
|
||||
...pageShape,
|
||||
...stepSelection,
|
||||
...transactionSelection,
|
||||
collection: z.literal("events"),
|
||||
event_kinds: z
|
||||
.array(browserScenarioEventKindSchema)
|
||||
.optional()
|
||||
.describe("Omitted selects all event kinds; [] selects none."),
|
||||
}),
|
||||
z.strictObject({
|
||||
...pageShape,
|
||||
...stepSelection,
|
||||
...transactionSelection,
|
||||
collection: z.literal("transactions"),
|
||||
event_kinds: z
|
||||
.array(browserScenarioEventKindSchema)
|
||||
.default([
|
||||
"request",
|
||||
"response",
|
||||
"request-finished",
|
||||
"request-failed",
|
||||
"request-unfinished",
|
||||
])
|
||||
.describe(
|
||||
"Metadata events by default; select network-content explicitly for bodies and header values. [] returns identity and source indices only.",
|
||||
),
|
||||
}),
|
||||
z.strictObject({
|
||||
...pageShape,
|
||||
collection: z.literal("text"),
|
||||
step_index: countSchema,
|
||||
artifact: z.enum(["accessibility", "dom"]),
|
||||
limit: z
|
||||
.int()
|
||||
.positive()
|
||||
.default(2048)
|
||||
.describe("Caller-selected UTF-16 code units, matching offset."),
|
||||
}),
|
||||
],
|
||||
);
|
||||
export type BrowserScenarioViewRequest = z.output<
|
||||
typeof browserScenarioViewRequestSchema
|
||||
>;
|
||||
|
||||
/** One exact step whose unselected captured sections are named rather than returned. */
|
||||
export 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),
|
||||
}),
|
||||
z.strictObject({
|
||||
collection: z.literal("transactions"),
|
||||
rows: z.array(
|
||||
z.strictObject({
|
||||
transaction_id: z.string().min(1),
|
||||
source_item_indices: z.array(countSchema),
|
||||
events: z.array(eventRowSchema),
|
||||
omitted_events: z.array(
|
||||
z.strictObject({
|
||||
item_index: countSchema,
|
||||
kind: browserScenarioEventKindSchema,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.strictObject({
|
||||
collection: z.literal("text"),
|
||||
step_index: countSchema,
|
||||
artifact: z.enum(["accessibility", "dom"]),
|
||||
state: z.enum(["captured", "missing", "truncated", "not_requested"]),
|
||||
reason: z.string().nullable(),
|
||||
sha256: digestSchema.nullable(),
|
||||
bytes: countSchema.nullable(),
|
||||
observed: countSchema.nullable(),
|
||||
retained: countSchema.nullable(),
|
||||
text: z.string().nullable(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
/** 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,
|
||||
}),
|
||||
source_network_omissions: z.strictObject({
|
||||
transactions: countSchema,
|
||||
transaction_range:
|
||||
retainedBrowserScenarioCaptureSchema.shape.events.shape.omissions.shape
|
||||
.network_transaction_range,
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user