rea/tests/composition/runtime-observation/browserScenarioDiff.test.ts

283 lines
7.6 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
compareBrowserScenarios,
compareBrowserScenariosInputSchema,
} from "../../../src/domain/browserScenarioDiff.js";
import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "../../../src/domain/browserScenarioCapture.js";
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,
retained: 2,
omitted: 0,
});
});
});
describe("browser scenario comparison validation", () => {
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 ambiguous input pairs and 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);
});
});
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({
schema_version: 1,
browser: {
mode: "launch",
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
product: "Chromium",
version: "149.0",
},
scenario: {
start_origin: "https://app.example.test",
allowed_origins: ["https://app.example.test"],
action_count: 1,
secret_references: [],
},
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,
},
],
},
completeness,
limitations: [],
});
};
const textArtifact = (text: string) => {
const bytes = Buffer.from(text);
return {
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.byteLength,
text,
};
};