Files
rea/src/domain/browserScenarioDiff.test.ts
T
morluto 57a23f0a2d fix(browser)!: expose capture budgets and queue admitted content reads
BREAKING CHANGE: Browser capture results require resolved limits, event families, and omission facts. Caller-selected budgets replace hidden retention ceilings; admitted content reads queue behind bounded concurrency.
2026-10-15 03:43:29 +08:00

1013 lines
30 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { compareBrowserScenarios } from "./browserScenarioDiff.js";
import {
compareBrowserScenariosInputSchema,
browserScenarioDiffSchema,
} from "./browserScenarioDiffValues.js";
import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "./browserScenarioCapture.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js";
const noEventOmissions = {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
} as const;
const networkCapture = (sourceSequence: number, receiptStep: number) => {
const base = scenarioCapture({});
const events = [
{
sequence: sourceSequence,
step_index: 0,
kind: "request",
method: "POST",
url: {
url: "https://app.example.test/api",
origin: "https://app.example.test",
query_parameter_names: [],
redacted: false,
},
resource_type: "fetch",
transaction_id: "request-1",
redirected_from_transaction_id: null,
redirected_from_transaction_state: "root",
header_names: [],
status: null,
failure: null,
},
{
sequence: sourceSequence + 1,
step_index: receiptStep,
kind: "network-content",
transaction_id: "request-1",
phase: "request",
source_event_sequence: sourceSequence,
headers: { state: "not_requested" },
body: {
state: "captured",
representation: "browser-exposed-request-bytes",
encoding: "base64",
content: "eA==",
bytes: 1,
sha256: createHash("sha256").update("x").digest("hex"),
media_type: null,
redacted: false,
},
},
];
return browserScenarioCaptureSchema.parse({
...base,
scenario: {
...base.scenario,
network_content: {
request_body: true,
response_body: false,
header_values: false,
},
event_families: ["network"],
},
events: {
items: events,
retained: 2,
dropped: 0,
omissions: noEventOmissions,
},
});
};
it("normalizes network content anchors and attributes late content to its source step", () => {
const before = networkCapture(3, 0);
const after = networkCapture(90, 1);
expect(
compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
}),
).overall_status,
).toBe("unchanged");
const content = after.events.items[1];
if (content?.kind !== "network-content")
throw new Error("Expected content fixture");
for (const mutation of [
{ transaction_id: "another-request" },
{ source_event_sequence: 91 },
{ phase: "response" },
])
expect(
browserScenarioCaptureSchema.safeParse({
...after,
events: {
...after.events,
items: [after.events.items[0], { ...content, ...mutation }],
},
}).success,
).toBe(false);
});
it("reports content-selection mismatch as unknown instead of a website change", () => {
const withBody = networkCapture(1, 0);
const metadataOnly = browserScenarioCaptureSchema.parse({
...withBody,
scenario: {
...withBody.scenario,
network_content: {
request_body: false,
response_body: false,
header_values: false,
},
},
events: {
...withBody.events,
items: withBody.events.items.filter(
(event) => event.kind !== "network-content",
),
retained: 1,
},
});
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: metadataOnly,
after_scenario: withBody,
}),
);
expect(compared.overall_status).toBe("unknown");
expect(compared.steps.flatMap((step) => step.artifact_diffs)).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "unknown",
reason: expect.stringContaining("content selections differ"),
}),
);
for (const networkContent of [undefined, {}, { request_body: false }])
expect(
compareBrowserScenariosInputSchema.safeParse({
before_scenario: {
...metadataOnly,
scenario: {
...metadataOnly.scenario,
network_content: networkContent,
},
},
after_scenario: withBody,
}).success,
).toBe(false);
});
it("reports event-family selection mismatch as unknown", () => {
const consoleOnly = scenarioCapture({});
const withFamilies = (
capture: BrowserScenarioCapture,
families: BrowserScenarioCapture["scenario"]["event_families"],
) =>
browserScenarioCaptureSchema.parse({
...capture,
scenario: { ...capture.scenario, event_families: families },
});
const compare = (
before: BrowserScenarioCapture,
after: BrowserScenarioCapture,
) =>
compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
}),
);
const eventDiffs = (compared: ReturnType<typeof compare>) =>
compared.steps
.flatMap((step) => step.artifact_diffs)
.filter(({ artifact }) => artifact === "events");
const widened = withFamilies(consoleOnly, ["page-errors", "console"]);
const mismatched = compare(consoleOnly, widened);
expect(mismatched.overall_status).toBe("unknown");
expect(eventDiffs(mismatched)).toContainEqual(
expect.objectContaining({
status: "unknown",
before_state: "captured",
after_state: "captured",
reason: expect.stringContaining(
"Event family selections differ (console before, console, page-errors after)",
),
}),
);
expect(
compare(
withFamilies(consoleOnly, ["console", "page-errors"]),
withFamilies(consoleOnly, ["page-errors", "console"]),
).overall_status,
).toBe("unchanged");
expect(
compareBrowserScenariosInputSchema.safeParse({
before_scenario: {
...consoleOnly,
scenario: { ...consoleOnly.scenario, event_families: ["dialogs"] },
},
after_scenario: consoleOnly,
}).success,
).toBe(false);
const quiet = browserScenarioCaptureSchema.parse({
...withFamilies(consoleOnly, []),
events: { retained: 0, dropped: 0, items: [], omissions: noEventOmissions },
});
const differentEvents = compare(quiet, widened);
expect(differentEvents.overall_status).toBe("unknown");
expect(eventDiffs(differentEvents).map(({ status }) => status)).toEqual([
"unknown",
"unknown",
]);
const both = eventDiffs(compare(networkCapture(1, 0), consoleOnly));
expect(both).toContainEqual(
expect.objectContaining({
reason: expect.stringMatching(
/Event family selections differ.*content selections differ/u,
),
}),
);
});
const concurrentCapture = (
requestOrder: readonly ("a" | "b")[],
statuses: Readonly<Record<"a" | "b", number>>,
responseStep = 0,
responseOrder: readonly ("a" | "b")[] = requestOrder,
redirect = false,
) => {
const base = scenarioCapture({});
const network = (path: "a" | "b") => ({
step_index: 0,
transaction_id: `request-${String(requestOrder.indexOf(path) + 1)}`,
redirected_from_transaction_id:
redirect && path === "b"
? `request-${String(requestOrder.indexOf("a") + 1)}`
: null,
...(!redirect || path !== "b"
? { redirected_from_transaction_state: "root" as const }
: {}),
method: "GET",
url: {
url: `https://app.example.test/${path}.js`,
origin: "https://app.example.test",
query_parameter_names: [],
redacted: false,
},
resource_type: "script",
header_names: [],
failure: null,
});
const request = (path: "a" | "b") => ({
...network(path),
kind: "request",
status: null,
});
const response = (path: "a" | "b") => ({
...network(path),
step_index: responseStep,
kind: "response",
status: statuses[path],
});
const events = (
redirect
? requestOrder.flatMap((path) => [request(path), response(path)])
: [...requestOrder.map(request), ...responseOrder.map(response)]
).map((event, index) => ({ ...event, sequence: index + 1 }));
return browserScenarioCaptureSchema.parse({
...base,
events: {
items: events,
retained: events.length,
dropped: 0,
omissions: noEventOmissions,
},
});
};
const redirectChainCapture = (swapLeaves = false, reverseReceipt = false) => {
const base = scenarioCapture({});
const nodes = [
{ id: "root-a", parent: null, path: "first-a", status: 301 },
{ id: "root-b", parent: null, path: "first-b", status: 302 },
{ id: "middle-a", parent: "root-a", path: "same-middle", status: 307 },
{ id: "middle-b", parent: "root-b", path: "same-middle", status: 307 },
{
id: "leaf-a",
parent: swapLeaves ? "middle-b" : "middle-a",
path: "last-a",
status: 200,
},
{
id: "leaf-b",
parent: swapLeaves ? "middle-a" : "middle-b",
path: "last-b",
status: 200,
},
];
const events = (reverseReceipt ? [...nodes].reverse() : nodes)
.flatMap((node) => {
const network = {
step_index: 0,
transaction_id: (reverseReceipt ? "renamed-" : "") + node.id,
redirected_from_transaction_id:
node.parent === null
? null
: (reverseReceipt ? "renamed-" : "") + node.parent,
...(node.parent === null
? { redirected_from_transaction_state: "root" as const }
: {}),
method: "GET",
url: {
url: `https://app.example.test/${node.path}`,
origin: "https://app.example.test",
query_parameter_names: [],
redacted: false,
},
resource_type: "document",
header_names: [],
failure: null,
};
return [
{ ...network, kind: "request", status: null },
{ ...network, kind: "response", status: node.status },
];
})
.map((event, index) => ({ ...event, sequence: index + 1 }));
return browserScenarioCaptureSchema.parse({
...base,
events: {
items: events,
retained: events.length,
dropped: 0,
omissions: noEventOmissions,
},
});
};
describe("browser scenario network event order", () => {
it("retains multi-hop redirect ancestry while ignoring IDs and concurrent receipt order", () => {
const compare = (after: BrowserScenarioCapture) =>
compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: redirectChainCapture(),
after_scenario: after,
}),
);
expect(compare(redirectChainCapture(false, true)).overall_status).toBe(
"unchanged",
);
expect(compare(redirectChainCapture(true)).overall_status).toBe("changed");
});
it("keeps missing and cyclic redirect ancestry incomparable", () => {
for (const predecessor of ["missing", "middle-a", null]) {
const capture = redirectChainCapture();
const events = capture.events.items.map((event) =>
"transaction_id" in event && event.transaction_id === "root-a"
? {
...event,
redirected_from_transaction_id: predecessor,
redirected_from_transaction_state: undefined,
}
: event,
);
const broken = browserScenarioCaptureSchema.parse({
...capture,
events: { ...capture.events, items: events },
});
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: broken,
after_scenario: broken,
}),
);
expect(compared.overall_status).toBe("unknown");
expect(
compared.steps.flatMap((step) => step.artifact_diffs),
).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "unknown",
reason: expect.stringContaining("Redirect"),
}),
);
}
});
it("keeps contradictory phase ancestry unknown", () => {
const capture = concurrentCapture(["a", "b"], { a: 200, b: 404 });
const events = capture.events.items.map((event) =>
event.kind === "response" && event.transaction_id === "request-2"
? {
...event,
redirected_from_transaction_id: "request-1",
redirected_from_transaction_state: undefined,
}
: event,
);
const malformed = browserScenarioCaptureSchema.parse({
...capture,
events: { ...capture.events, items: events },
});
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: malformed,
after_scenario: malformed,
}),
);
expect(compared.overall_status).toBe("unknown");
expect(
compared.steps.flatMap((step) => step.artifact_diffs),
).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "unknown",
reason: expect.stringContaining("Redirect predecessor"),
}),
);
});
});
describe("browser scenario network comparison stability", () => {
it("keeps normalization away from generated network fingerprints", () => {
const rules = [..."0123456789abcdef"].map((match, index) => ({
rule_id: `remove-fingerprint-hex-${String(index)}`,
artifacts: ["events"] as const,
match,
replacement: "",
}));
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: concurrentCapture(["a", "b"], {
a: 200,
b: 404,
}),
after_scenario: concurrentCapture(["a", "b"], {
a: 201,
b: 404,
}),
normalization: { rules },
}),
);
expect(compared.overall_status).toBe("changed");
});
it("reports changed ordinary events despite incomplete redirect ancestry", () => {
const withGap = (text: string) => {
const base = scenarioCapture({});
const consoleEvent = base.events.items[0];
if (consoleEvent?.kind !== "console")
throw new Error("Expected console fixture");
return browserScenarioCaptureSchema.parse({
...base,
scenario: {
...base.scenario,
event_families: ["console", "network"],
},
events: {
retained: 2,
dropped: 0,
omissions: noEventOmissions,
items: [
{ ...consoleEvent, text },
{
sequence: 2,
step_index: 0,
kind: "request",
transaction_id: "orphan-request",
redirected_from_transaction_id: null,
method: "GET",
url: {
url: "https://app.example.test/orphan",
origin: "https://app.example.test",
query_parameter_names: [],
redacted: false,
},
resource_type: "fetch",
header_names: [],
status: null,
failure: null,
},
],
},
});
};
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: withGap("settings-ready"),
after_scenario: withGap("settings-failed"),
}),
);
expect(compared.overall_status).toBe("changed");
});
it("compares concurrent network transactions without receipt order, receipt step or capture-local IDs", () => {
const compare = (
before: BrowserScenarioCapture,
after: BrowserScenarioCapture,
) =>
compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
}),
).overall_status;
const statuses = { a: 200, b: 404 };
expect(
compare(
concurrentCapture(["a", "b"], statuses),
concurrentCapture(["b", "a"], statuses, 1, ["a", "b"]),
),
).toBe("unchanged");
expect(
compare(
concurrentCapture(["a", "b"], statuses),
concurrentCapture(["b", "a"], { a: 404, b: 200 }),
),
).toBe("changed");
expect(
compare(
concurrentCapture(["a", "b"], statuses, 0, ["a", "b"]),
concurrentCapture(["a", "b"], statuses, 0, ["b", "a"]),
),
).toBe("unchanged");
// Redirect linkage is distinct from independent concurrent transactions.
expect(
compare(
concurrentCapture(["a", "b"], statuses),
concurrentCapture(["a", "b"], statuses, 0, ["a", "b"], true),
),
).toBe("changed");
});
});
describe("scenario comparison recovery guidance", () => {
it("retains network guidance beside a changed console event", () => {
const before = scenarioCapture({});
const afterBase = scenarioCapture({});
const consoleEvent = afterBase.events.items[0];
if (consoleEvent?.kind !== "console")
throw new Error("Expected console fixture");
const after = browserScenarioCaptureSchema.parse({
...afterBase,
scenario: {
...afterBase.scenario,
network_content: {
...afterBase.scenario.network_content,
response_body: true,
},
},
events: {
...afterBase.events,
items: [{ ...consoleEvent, text: "settings-ready-again" }],
},
});
const compare = (left: typeof before, right: typeof before) =>
compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: left,
after_scenario: right,
}),
);
const changed = compare(before, after);
expect(changed.overall_status).toBe("changed");
expect(changed.steps.flatMap((step) => step.artifact_diffs)).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "changed",
reason: expect.stringContaining("Network content selections differ"),
}),
);
const sameSelection = compare(before, scenarioCapture({}));
expect(sameSelection.overall_status).toBe("unchanged");
expect(
sameSelection.steps.flatMap((step) => step.artifact_diffs),
).not.toContainEqual(expect.objectContaining({ artifact: "events" }));
const quietMismatch = compare(
before,
browserScenarioCaptureSchema.parse({ ...after, events: before.events }),
);
expect(
quietMismatch.steps.flatMap((step) => step.artifact_diffs),
).toContainEqual(
expect.objectContaining({
artifact: "events",
status: "unknown",
reason: expect.stringContaining("Network content selections differ"),
}),
);
});
});
describe("browser scenario comparison", () => {
it("records canonical literal normalization and ignores declared volatile fields", () => {
const before = scenarioCapture({
domText: "<main data-build='build-123'>Settings</main>",
elapsedMs: 11,
eventSequence: 3,
});
const after = scenarioCapture({
domText: "<main data-build='build-456'>Settings</main>",
elapsedMs: 900,
eventSequence: 88,
});
const rules = [
{
rule_id: "new-build",
artifacts: ["dom"] as const,
match: "build-456",
replacement: "[BUILD]",
},
{
rule_id: "old-build",
artifacts: ["dom"] as const,
match: "build-123",
replacement: "[BUILD]",
},
];
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
normalization: { rules },
}),
);
const reordered = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
normalization: {
rules: [...rules].reverse().map((rule) => ({
...rule,
artifacts: [...rule.artifacts].reverse(),
})),
},
}),
);
expect(compared.overall_status).toBe("unchanged");
expect(compared.alignment.status).toBe("aligned");
expect(compared.artifact_diffs.total).toBe(0);
expect(compared.normalization).toMatchObject({
rules: [
{ rule_id: "new-build", artifacts: ["dom"] },
{ rule_id: "old-build", artifacts: ["dom"] },
],
});
expect(reordered.normalization.sha256).toBe(compared.normalization.sha256);
});
it("exposes action mismatches and normalized artifact-level changes", () => {
const before = scenarioCapture({
domText: "<main>Settings</main>",
});
const after = scenarioCapture({
action: "dblclick",
domText: "<main>Advanced settings</main>",
});
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
}),
);
const step = compared.steps.find(
({ step_id: stepId }) => stepId === "open-settings",
);
expect(compared.overall_status).toBe("changed");
expect(compared.alignment).toMatchObject({
status: "partial",
failures: [
expect.objectContaining({
code: "action_mismatch",
step_id: "open-settings",
}),
],
});
expect(step).toMatchObject({
status: "changed",
artifact_diffs: [
expect.objectContaining({
artifact: "action_state",
status: "changed",
}),
expect.objectContaining({ artifact: "dom", status: "changed" }),
],
});
expect(compared.artifact_diffs).toEqual({ total: 2 });
});
});
describe("browser scenario comparison text values", () => {
it("accepts long normalization values and comparison diagnostics", () => {
const longText = "x".repeat(5_000);
const ruleId = "r".repeat(100);
const capture = scenarioCapture({});
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: capture,
after_scenario: capture,
normalization: {
rules: [
{
rule_id: ruleId,
artifacts: ["dom"],
match: longText,
replacement: longText,
},
],
},
}),
);
const stepId = "s".repeat(100);
const diagnostic = browserScenarioDiffSchema.parse({
...compared,
alignment: {
...compared.alignment,
before_only: [stepId],
after_only: [stepId],
failures: [
{
code: "missing_before_step",
step_id: stepId,
reason: longText,
},
],
},
steps: compared.steps.map((step) => ({
...step,
step_id: stepId,
artifact_diffs: [
{
artifact: "dom",
status: "unknown",
before_state: "missing",
after_state: "missing",
before_sha256: null,
after_sha256: null,
reason: longText,
},
],
})),
limitations: [longText],
});
expect(diagnostic.normalization.rules[0]).toMatchObject({
rule_id: ruleId,
match: longText,
replacement: longText,
});
expect(diagnostic.alignment.failures[0]?.reason).toBe(longText);
expect(diagnostic.steps[0]?.artifact_diffs[0]?.reason).toBe(longText);
expect(diagnostic.limitations).toEqual([longText]);
});
});
describe("browser scenario comparison validation", () => {
it("does not accept caller limits that would omit difference records", () => {
const capture = scenarioCapture({});
expect(
compareBrowserScenariosInputSchema.safeParse({
before_scenario: capture,
after_scenario: capture,
max_changes: 1,
}).success,
).toBe(false);
});
it("returns every aligned step beyond the former capture ceiling", () => {
const before = multiStepScenarioCapture(130, "before");
const after = multiStepScenarioCapture(130, "after");
const result = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
}),
);
expect(result.alignment).toMatchObject({
status: "aligned",
aligned_steps: 131,
});
expect(result.steps).toHaveLength(131);
expect(result.steps.at(-1)).toMatchObject({
before_step_index: 130,
after_step_index: 130,
status: "changed",
});
expect(result.artifact_diffs.total).toBe(130);
});
it("reports missing aligned steps without claiming equality", () => {
const before = scenarioCapture({});
const after = scenarioCapture({ stepId: "open-profile" });
const compared = compareBrowserScenarios(
compareBrowserScenariosInputSchema.parse({
before_scenario: before,
after_scenario: after,
}),
);
expect(compared.overall_status).toBe("unknown");
expect(compared.alignment).toMatchObject({
status: "partial",
aligned_steps: 1,
before_only: ["open-settings"],
after_only: ["open-profile"],
failures: expect.arrayContaining([
expect.objectContaining({
code: "missing_before_step",
step_id: "open-profile",
}),
expect.objectContaining({
code: "missing_after_step",
step_id: "open-settings",
}),
]),
});
});
it("rejects duplicate normalization rule IDs", () => {
const capture = scenarioCapture({});
expect(
compareBrowserScenariosInputSchema.safeParse({
before_scenario: capture,
after_scenario: capture,
normalization: {
rules: [
{
rule_id: "volatile",
artifacts: ["dom"],
match: "one",
replacement: "[VALUE]",
},
{
rule_id: "volatile",
artifacts: ["url"],
match: "two",
replacement: "[VALUE]",
},
],
},
}).success,
).toBe(false);
});
});
const multiStepScenarioCapture = (
actionCount: number,
content: string,
): BrowserScenarioCapture => {
const capture = scenarioCapture({});
const template = capture.steps[1];
if (template === undefined) throw new Error("Expected scenario action step");
const steps = [
capture.steps[0],
...Array.from({ length: actionCount }, (_, index) => ({
...template,
step_index: index + 1,
step_id: `step-${String(index + 1)}`,
event_sequence_start: index + 1,
event_sequence_end: index + 1,
artifacts: {
...template.artifacts,
dom: {
state: "captured" as const,
value: textArtifact(`${content}-${String(index + 1)}`),
},
},
})),
];
return browserScenarioCaptureSchema.parse({
...capture,
scenario: { ...capture.scenario, action_count: actionCount },
steps,
});
};
interface ScenarioCaptureOptions {
readonly stepId?: string;
readonly action?: string;
readonly domText?: string;
readonly elapsedMs?: number;
readonly eventSequence?: number;
}
const scenarioCapture = (
options: ScenarioCaptureOptions,
): BrowserScenarioCapture => {
const url = {
url: "https://app.example.test/settings",
origin: "https://app.example.test",
query_parameter_names: [],
redacted: false,
};
const completeness = {
status: "complete",
equality_eligible: true,
missing_sections: [],
truncated_sections: [],
};
const artifacts = (text: string) => ({
screenshot: { state: "not_requested" },
dom: { state: "captured", value: textArtifact(text) },
accessibility: { state: "not_requested" },
url: { state: "captured", value: url },
history: { state: "not_requested" },
storage: { state: "not_requested" },
});
const eventSequence = options.eventSequence ?? 1;
return browserScenarioCaptureSchema.parse({
browser: {
mode: "launch",
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
product: "Chromium",
version: "149.0",
},
scenario: {
start_origin: "https://app.example.test",
action_count: 1,
secret_references: [],
network_content: {
request_body: false,
response_body: false,
header_values: false,
},
event_families: ["console"],
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
},
duration_ms: options.elapsedMs ?? 10,
steps: [
{
step_index: 0,
step_id: "scenario_start",
action: "scenario_start",
status: "completed",
elapsed_ms: 0,
before_url: url,
after_url: url,
error: null,
event_sequence_start: 1,
event_sequence_end: 0,
artifacts: artifacts("<main>Ready</main>"),
completeness,
},
{
step_index: 1,
step_id: options.stepId ?? "open-settings",
action: options.action ?? "click",
status: "completed",
elapsed_ms: options.elapsedMs ?? 10,
before_url: url,
after_url: url,
error: null,
event_sequence_start: eventSequence,
event_sequence_end: eventSequence,
artifacts: artifacts(options.domText ?? "<main>Settings</main>"),
completeness,
},
],
events: {
retained: 1,
dropped: 0,
items: [
{
sequence: eventSequence,
step_index: 1,
kind: "console",
level: "info",
text: "settings-ready",
url: null,
},
],
omissions: noEventOmissions,
},
completeness,
limitations: [],
});
};
const textArtifact = (text: string) => {
const bytes = Buffer.from(text);
return {
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.byteLength,
text,
};
};