Files
rea/tests/boundary/cli/environmentCompareCli.test.ts
Wenyu Chiou f5cfe09c07 fix(environment): disclose truncated comparison differences (#7673)
Preserve the twenty-entry diagnostic prefix and report the full count,
omitted count and truncation fact at the owning report projection. Document
the recovery limitation and cover the public HAR-to-comparison workflow.

Fixes #7621

Validation: 121 source tests; three selected CLI regressions (five other
cases unselected); nine README conformance cases; full --noEmit typecheck;
documentation facts/metadata/schema/licenses; module boundaries and
architecture guards. VitePress rendering is blocked by Windows EPERM on its
worktree-local Vue symlink. Full aggregate coverage remains for Draft CI.

Independent code review: APPROVE, relayed by parent task
01a17716-6299-75fe-87e9-b0398b8c9019. Trigger: >=50 changed lines / >=3 files.
Reviewed tree: ba964efcd6
Reviewed full-index patch SHA256:
972dcfc219dd7b9606175b1848068f92210fa3f7d0a8a244c1ac7ce296ae32d9
2026-11-03 14:48:54 +08:00

753 lines
23 KiB
TypeScript

import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import type { CompareReport } from "../../../src/application/environment/environmentCompare.js";
import {
analysisViewCaptureEvidence,
environmentManifestCaptureFixture,
timedCaptureEvidence,
type TimedCaptureRow,
} from "../../fixtures/analysisView.js";
import { environmentStateServeFixture } from "../../fixtures/environmentServe.js";
import {
BOOKMARK_ROWS,
bookmark,
TAG_ROWS,
} from "../../fixtures/environmentStateDerivedValues.js";
import {
BOOK_NOT_FOUND,
BOOK_ROWS,
FIRST_BOOK,
SECOND_BOOK,
THIRD_BOOK,
book,
numberedUuid,
renamedBook,
} from "../../fixtures/environmentStateWrites.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
describe("rea environment-compare difference coverage", () => {
cliTest(
"discloses the bounded differences in ordinary and full output",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-compare-coverage-",
);
const counts = [0, 19, 20, 21, 25];
const originals = new Map<string, string>();
for (const [name, value] of [
["capture", 0],
["probe", 1],
] as const) {
const entries = counts.map((count, index) => {
const text = JSON.stringify(
Object.fromEntries(
Array.from({ length: count }, (_, field) => [
`field${String(field).padStart(2, "0")}`,
value,
]),
),
);
return {
startedDateTime: new Date(
Date.parse("2026-10-27T20:00:00Z") + index * 1000,
).toISOString(),
time: 0,
cache: {},
timings: { send: 0, wait: 0, receive: 0 },
request: {
method: "GET",
url: `https://app.example/api/config${String(count)}`,
httpVersion: "HTTP/1.1",
headers: [],
queryString: [],
cookies: [],
headersSize: -1,
bodySize: 0,
},
response: {
status: 200,
statusText: "OK",
httpVersion: "HTTP/1.1",
headers: [],
cookies: [],
redirectURL: "",
headersSize: -1,
bodySize: -1,
content: {
mimeType: "application/json",
size: Buffer.byteLength(text),
text,
},
},
};
});
const har = join(root, `${name}.har`);
const text = JSON.stringify({
log: {
version: "1.2",
creator: { name: "coverage-test", version: "1" },
entries,
},
});
await writeFile(har, text);
originals.set(har, text);
const captured = await cli.run({
arguments: ["inspect-web-network-capture", har, "har", "--json"],
});
expect(captured.exitCode, captured.stderr).toBe(0);
const path = join(root, `${name}.json`);
const evidence = JSON.stringify(captured.json);
await writeFile(path, evidence);
originals.set(path, evidence);
}
const view = join(root, "view.json");
await writeFile(
view,
JSON.stringify({
source: {
kind: "inline",
evidence: JSON.parse(originals.get(join(root, "capture.json"))!),
},
view: { kind: "environment-manifest" },
}),
);
const projected = await cli.run({
arguments: ["inspect-analysis-view", view, "--json"],
});
expect(projected.exitCode, projected.stderr).toBe(0);
const manifest = join(root, "manifest.json");
const manifestText = JSON.stringify(projected.json);
await writeFile(manifest, manifestText);
originals.set(manifest, manifestText);
const args = [
"environment-compare",
manifest,
"--evidence",
join(root, "capture.json"),
"--probe",
join(root, "probe.json"),
"--json",
];
const result = await cli.run({ arguments: args });
expect(result.exitCode, result.stderr).toBe(0);
const report = result.json as CompareReport;
expect(report).toMatchObject({
status: "complete",
summary: { steps: 5, equal: 1, categories: { value: 4 } },
});
expect(report.probes[0]!.results).toHaveLength(counts.length);
for (const [index, count] of counts.entries()) {
const step = report.probes[0]!.results[index]!;
expect(step).toMatchObject({
equal: count === 0,
equal_ignoring_order: count === 0,
categories: count === 0 ? [] : ["value"],
differences_total: count,
differences_omitted: Math.max(0, count - 20),
differences_truncated: count > 20,
});
expect(step.differences).toHaveLength(Math.min(count, 20));
if (count >= 20)
expect(step.differences.at(-1)?.pointer).toBe("/field19");
}
const full = await cli.run({ arguments: [...args, "--full-output"] });
expect(full.exitCode, full.stderr).toBe(0);
expect(full.json).toMatchObject({ data: report });
for (const [path, text] of originals)
expect(await readFile(path, "utf8")).toBe(text);
},
);
});
/** The books environment and a probe of the application re-seeded under other ids, written as Evidence files. */
const writeCompareFiles = async (root: string) => {
const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS));
const first = book(101, "First Book", "2026-10-20T09:00:00Z");
const second = book(102, "Second Book", "2026-10-20T10:00:00Z");
const probe = timedCaptureEvidence([
["GET", "/api/books", [first, second]],
["DELETE", "/api/books/102", "", 204],
["GET", "/api/books/102", BOOK_NOT_FOUND, 404],
]);
const paths = {
manifest: join(root, "manifest.json"),
capture: join(root, "capture.json"),
probe: join(root, "probe.json"),
};
await writeFile(paths.manifest, JSON.stringify(fixture.manifest));
await writeFile(paths.capture, JSON.stringify(fixture.capture));
await writeFile(paths.probe, JSON.stringify(probe));
return paths;
};
/** The exact chronology and clock-format probes share one unchanged source capture. */
const writeExactClockFiles = async (
root: string,
): Promise<Map<string, string>> => {
const capture = timedCaptureEvidence([
["GET", "/api/books", [FIRST_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["DELETE", "/api/books/1", "", 204],
["GET", "/api/books/1", BOOK_NOT_FOUND, 404],
]);
const fixture = environmentStateServeFixture(capture);
const chronologyProbe = (later: string, earlier: string) =>
analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
{
startedDateTime: later,
request: { method: "GET", url: "http://app.example/api/books/1" },
response: {
status: 404,
content: {
mimeType: "application/json",
text: JSON.stringify(BOOK_NOT_FOUND),
},
},
},
{
startedDateTime: earlier,
request: {
method: "DELETE",
url: "http://app.example/api/books/1",
},
response: {
status: 204,
content: { mimeType: "application/json", text: "" },
},
},
]),
);
const ordered = chronologyProbe(
"0001-01-01T00:00:00.0000000002Z",
"0001-01-01T01:00:00.0000000001+01:00",
);
const legacyOrdered = chronologyProbe(
"2026-10-20 10:00:00.0002+0100",
"2026-10-20 09:00:00.0001+0000",
);
const clocks = timedCaptureEvidence([
[
"GET",
"/api/books/1",
{ ...FIRST_BOOK, created_at: "0001-01-01 00:00:00.000Z" },
],
]);
const originals = new Map<string, string>();
for (const [name, value] of [
["manifest.json", fixture.manifest],
["capture.json", capture],
["order.json", ordered],
["legacy-order.json", legacyOrdered],
["clocks.json", clocks],
] as const) {
const path = join(root, name);
const text = JSON.stringify(value);
originals.set(path, text);
await writeFile(path, text);
}
return originals;
};
/** Capture-confirmed generated writes and literal/clock probes, retained as unchanged Evidence files. */
const writeValueContextFiles = async (
root: string,
): Promise<Map<string, string>> => {
const recorded = {
samples: [1700000000],
nested_samples: [[1700000000]],
scalar_samples: 1700000000,
sku: "product1",
catalog: ["product1"],
serial: numberedUuid(1),
updated_at: [1700000000],
nested: { updated_at: 1700000000 },
fixed_samples: [1700000000],
};
const clocks = {
...recorded,
updated_at: [1800000000],
nested: { updated_at: 1800000000 },
};
const changed = {
...clocks,
samples: [1800000000],
nested_samples: [[1800000000]],
scalar_samples: 1800000000,
sku: "product2",
catalog: ["product2"],
serial: numberedUuid(2),
};
const first = { ...FIRST_BOOK, ref: numberedUuid(1) };
const second = { ...SECOND_BOOK, ref: numberedUuid(2) };
const third = { ...THIRD_BOOK, ref: numberedUuid(3) };
const renamed = {
...renamedBook(first, "Renamed Book", 6),
ref: numberedUuid(4),
};
const missing = { error: "missing", ref: numberedUuid(99) };
const rows: TimedCaptureRow[] = [
["GET", "/api/config", recorded],
["GET", "/api/books", [first, second]],
["GET", "/api/books/1", first],
["GET", "/api/books/2", second],
["POST", "/api/books", third, 201, { name: "Third Book" }],
["GET", "/api/books/3", third],
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", renamed],
["GET", "/api/books/99", missing, 404],
];
const capture = timedCaptureEvidence(rows);
const fixture = environmentStateServeFixture(capture);
expect(fixture.state.normalized_result).toMatchObject({
writes: expect.arrayContaining([
expect.objectContaining({
tool: "post_books",
served: true,
response: { ordinal: 4, shape: "entity", pointer: "" },
rules: expect.arrayContaining([{ field: "ref", rule: "unique" }]),
}),
expect.objectContaining({
tool: "patch_books_by_id",
served: true,
response: { ordinal: 6, shape: "entity", pointer: "" },
rules: expect.arrayContaining([{ field: "ref", rule: "unique" }]),
}),
]),
});
const generated = {
...book(101, "Third Book", "2026-11-01T12:00:00Z"),
ref: numberedUuid(101),
};
const originals = new Map<string, string>();
for (const [name, value] of [
["manifest.json", fixture.manifest],
["capture.json", capture],
["changed.json", timedCaptureEvidence([["GET", "/api/config", changed]])],
["clocks.json", timedCaptureEvidence([["GET", "/api/config", clocks]])],
[
"generated.json",
timedCaptureEvidence([
["POST", "/api/books", generated, 201, { name: "Third Book" }],
[
"PATCH",
"/api/books/1",
{ ...renamed, ref: numberedUuid(102) },
200,
{ name: "Renamed Book" },
],
]),
],
[
"missing.json",
timedCaptureEvidence([
[
"PATCH",
"/api/books/99",
{ ...missing, ref: numberedUuid(100) },
404,
{ name: "Ghost" },
],
]),
],
] as const) {
const path = join(root, name);
const text = JSON.stringify(value);
originals.set(path, text);
await writeFile(path, text);
}
return originals;
};
describe("rea environment-compare value context", () => {
cliTest(
"keeps business values and numeric measurements literal while normalizing confirmed generation and clocks",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-value-context-",
);
const originals = await writeValueContextFiles(root);
const result = await cli.run({
arguments: [
"environment-compare",
join(root, "manifest.json"),
"--evidence",
join(root, "capture.json"),
"--probe",
join(root, "changed.json"),
"--probe",
join(root, "clocks.json"),
"--probe",
join(root, "generated.json"),
"--probe",
join(root, "missing.json"),
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
summary: {
probes: 4,
steps: 5,
equal: 3,
agreement: 0.6,
normalised: { generated: 2 },
},
probes: [
{
label: "changed.json",
normalised: { generated: 0, clocks: 2, ids: 0 },
results: [
{
equal: false,
categories: ["value"],
differences: [
{
category: "value",
pointer: "/samples/0",
live: "1800000000",
env: "1700000000",
},
{
category: "value",
pointer: "/nested_samples/0/0",
live: "1800000000",
env: "1700000000",
},
{
category: "value",
pointer: "/scalar_samples",
live: "1800000000",
env: "1700000000",
},
{
category: "value",
pointer: "/sku",
live: '"product2"',
env: '"product1"',
},
{
category: "value",
pointer: "/catalog/0",
live: '"product2"',
env: '"product1"',
},
{
category: "value",
pointer: "/serial",
live: JSON.stringify(numberedUuid(2)),
env: JSON.stringify(numberedUuid(1)),
},
],
},
],
},
{
label: "clocks.json",
normalised: { clocks: 2, generated: 0 },
results: [{ equal: true, differences: [] }],
},
{
label: "generated.json",
normalised: { generated: 2, ids: 1 },
results: [
{ equal: true, differences: [] },
{ equal: true, differences: [] },
],
},
{
label: "missing.json",
normalised: { generated: 0 },
results: [
{
tool: "patch_books_by_id",
live_status: 404,
env_status: 404,
equal: false,
differences: [
{
category: "value",
pointer: "/ref",
live: JSON.stringify(numberedUuid(100)),
env: JSON.stringify(numberedUuid(99)),
},
],
},
],
},
],
});
for (const [path, text] of originals)
expect(await readFile(path, "utf8")).toBe(text);
},
);
});
describe("rea environment-compare recorded clock spelling", () => {
for (const scenario of [
{
name: "variable fractional widths under a confirmed trimmed write plan",
rows: TAG_ROWS,
probe: [
[
"POST",
"/api/tags",
{
id: numberedUuid(20),
name: "Spare",
createdAt: "2026-10-20T10:00:00.12345678Z",
updatedAt: "2026-10-20T10:00:00.12345678Z",
},
201,
{ name: "Spare" },
],
] as const,
generated: 0,
},
{
name: "a composed bookmark URL through its creation clock",
rows: BOOKMARK_ROWS,
probe: [
[
"POST",
"/api/bookmarks",
bookmark(
20,
"https://pier.example.test/inspection",
"Pier",
"2026-10-20T10:00:00Z",
),
201,
{ url: "https://pier.example.test/inspection", title: "Pier" },
],
] as const,
generated: 1,
},
]) {
cliTest(`accepts ${scenario.name}`, async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-derived-compare-",
);
const capture = timedCaptureEvidence(scenario.rows);
const fixture = environmentStateServeFixture(capture);
const probe = timedCaptureEvidence(scenario.probe);
const originals = new Map<string, string>();
for (const [name, value] of [
["manifest.json", fixture.manifest],
["capture.json", capture],
["probe.json", probe],
] as const) {
const path = join(root, name);
const text = JSON.stringify(value);
originals.set(path, text);
await writeFile(path, text);
}
const result = await cli.run({
arguments: [
"environment-compare",
join(root, "manifest.json"),
"--evidence",
join(root, "capture.json"),
"--probe",
join(root, "probe.json"),
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
summary: { probes: 1, steps: 1, equal: 1, agreement: 1 },
probes: [
{
normalised: { clocks: 2, generated: scenario.generated },
results: [{ method: "POST", equal: true, differences: [] }],
},
],
});
for (const [path, text] of originals)
expect(await readFile(path, "utf8")).toBe(text);
});
}
cliTest(
"replays compact-offset submillisecond probe timestamps in exact order",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-legacy-chronology-",
);
const originals = await writeExactClockFiles(root);
const result = await cli.run({
arguments: [
"environment-compare",
join(root, "manifest.json"),
"--evidence",
join(root, "capture.json"),
"--probe",
join(root, "legacy-order.json"),
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
environment: { state: "served" },
summary: { probes: 1, steps: 2, equal: 2, agreement: 1 },
probes: [
{
label: "legacy-order.json",
results: [
{
ordinal: 1,
method: "DELETE",
live_status: 204,
env_status: 204,
equal: true,
},
{
ordinal: 0,
method: "GET",
live_status: 404,
env_status: 404,
equal: true,
},
],
},
],
});
for (const [path, text] of originals)
expect(await readFile(path, "utf8")).toBe(text);
},
);
});
describe("rea environment-compare replay and chronology", () => {
cliTest(
"preserves exact probe chronology and first-century clock-format differences",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-exact-clock-",
);
const originals = await writeExactClockFiles(root);
const result = await cli.run({
arguments: [
"environment-compare",
join(root, "manifest.json"),
"--evidence",
join(root, "capture.json"),
"--probe",
join(root, "order.json"),
"--probe",
join(root, "clocks.json"),
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
environment: { state: "served" },
summary: { probes: 2, steps: 3, equal: 2 },
probes: [
{
label: "order.json",
results: [
{
ordinal: 1,
method: "DELETE",
live_status: 204,
env_status: 204,
equal: true,
},
{
ordinal: 0,
method: "GET",
live_status: 404,
env_status: 404,
equal: true,
},
],
},
{
label: "clocks.json",
results: [
{
categories: ["clock-format"],
differences: [
{
category: "clock-format",
pointer: "/created_at",
live: '"0001-01-01 00:00:00.000Z"',
},
],
},
],
},
],
});
for (const [path, text] of originals)
expect(await readFile(path, "utf8")).toBe(text);
},
);
cliTest(
"compares the served environment with a probe of the re-seeded application and prints the report",
async ({ cli }) => {
const files = await writeCompareFiles(
await createTestTempDirectory("rea-environment-compare-cli-"),
);
const result = await cli.run({
arguments: [
"environment-compare",
files.manifest,
"--evidence",
files.capture,
"--probe",
files.probe,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
environment: { state: "served" },
summary: { probes: 1, steps: 3, equal: 3, agreement: 1 },
probes: [{ label: "probe.json", ids_mapped: 2 }],
});
},
);
cliTest(
"refuses a probe that is not capture Evidence and names it",
async ({ cli }) => {
const files = await writeCompareFiles(
await createTestTempDirectory("rea-environment-compare-refusal-cli-"),
);
const result = await cli.run({
arguments: [
"environment-compare",
files.manifest,
"--evidence",
files.capture,
"--probe",
files.manifest,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-compare",
issues: [{ path: ["probe", 0] }],
},
});
},
);
});