dungbeetle 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +19 -0
  3. package/README.md +139 -0
  4. package/dist/api/capture.d.ts +24 -0
  5. package/dist/api/capture.js +61 -0
  6. package/dist/baselines.d.ts +7 -0
  7. package/dist/baselines.js +38 -0
  8. package/dist/brand.d.ts +2 -0
  9. package/dist/brand.js +9 -0
  10. package/dist/capture.d.ts +15 -0
  11. package/dist/capture.js +7 -0
  12. package/dist/captures/api.d.ts +2 -0
  13. package/dist/captures/api.js +114 -0
  14. package/dist/captures/check.d.ts +2 -0
  15. package/dist/captures/check.js +116 -0
  16. package/dist/captures/desktop.d.ts +2 -0
  17. package/dist/captures/desktop.js +97 -0
  18. package/dist/captures/game.d.ts +4 -0
  19. package/dist/captures/game.js +266 -0
  20. package/dist/captures/performance.d.ts +2 -0
  21. package/dist/captures/performance.js +47 -0
  22. package/dist/captures/registry.d.ts +4 -0
  23. package/dist/captures/registry.js +23 -0
  24. package/dist/captures/terminal.d.ts +2 -0
  25. package/dist/captures/terminal.js +65 -0
  26. package/dist/captures/types.d.ts +18 -0
  27. package/dist/captures/types.js +1 -0
  28. package/dist/captures/web.d.ts +3 -0
  29. package/dist/captures/web.js +248 -0
  30. package/dist/check/capture.d.ts +15 -0
  31. package/dist/check/capture.js +76 -0
  32. package/dist/check/junit.d.ts +9 -0
  33. package/dist/check/junit.js +51 -0
  34. package/dist/check/laravel.d.ts +2 -0
  35. package/dist/check/laravel.js +44 -0
  36. package/dist/check/parsers.d.ts +12 -0
  37. package/dist/check/parsers.js +278 -0
  38. package/dist/check/schema.d.ts +2 -0
  39. package/dist/check/schema.js +114 -0
  40. package/dist/cloud.d.ts +42 -0
  41. package/dist/cloud.js +334 -0
  42. package/dist/compare/shared.d.ts +42 -0
  43. package/dist/compare/shared.js +115 -0
  44. package/dist/compare.d.ts +3 -0
  45. package/dist/compare.js +33 -0
  46. package/dist/config.d.ts +146 -0
  47. package/dist/config.js +382 -0
  48. package/dist/desktop/a11y.d.ts +18 -0
  49. package/dist/desktop/a11y.js +74 -0
  50. package/dist/desktop/capture.d.ts +13 -0
  51. package/dist/desktop/capture.js +80 -0
  52. package/dist/desktop/macos.d.ts +8 -0
  53. package/dist/desktop/macos.js +98 -0
  54. package/dist/desktop/ocr.d.ts +17 -0
  55. package/dist/desktop/ocr.js +99 -0
  56. package/dist/diff/lcs.d.ts +5 -0
  57. package/dist/diff/lcs.js +42 -0
  58. package/dist/diff/numeric.d.ts +6 -0
  59. package/dist/diff/numeric.js +24 -0
  60. package/dist/diff/pixel.d.ts +23 -0
  61. package/dist/diff/pixel.js +97 -0
  62. package/dist/diff/structural.d.ts +11 -0
  63. package/dist/diff/structural.js +38 -0
  64. package/dist/diff/text.d.ts +7 -0
  65. package/dist/diff/text.js +64 -0
  66. package/dist/diff/tree.d.ts +46 -0
  67. package/dist/diff/tree.js +188 -0
  68. package/dist/doctor.d.ts +18 -0
  69. package/dist/doctor.js +57 -0
  70. package/dist/game/capture.d.ts +24 -0
  71. package/dist/game/capture.js +51 -0
  72. package/dist/game/protocol.d.ts +30 -0
  73. package/dist/game/protocol.js +146 -0
  74. package/dist/game/walkthrough.d.ts +45 -0
  75. package/dist/game/walkthrough.js +85 -0
  76. package/dist/guards.d.ts +2 -0
  77. package/dist/guards.js +15 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.js +504 -0
  80. package/dist/json.d.ts +2 -0
  81. package/dist/json.js +40 -0
  82. package/dist/lifecycle.d.ts +14 -0
  83. package/dist/lifecycle.js +190 -0
  84. package/dist/normalization.d.ts +4 -0
  85. package/dist/normalization.js +27 -0
  86. package/dist/perf/ab.d.ts +6 -0
  87. package/dist/perf/ab.js +89 -0
  88. package/dist/perf/autocannon.d.ts +6 -0
  89. package/dist/perf/autocannon.js +101 -0
  90. package/dist/perf/capture.d.ts +7 -0
  91. package/dist/perf/capture.js +6 -0
  92. package/dist/perf/k6.d.ts +9 -0
  93. package/dist/perf/k6.js +44 -0
  94. package/dist/perf/parsers.d.ts +15 -0
  95. package/dist/perf/parsers.js +69 -0
  96. package/dist/perf/run.d.ts +8 -0
  97. package/dist/perf/run.js +45 -0
  98. package/dist/perf/toolOutput.d.ts +3 -0
  99. package/dist/perf/toolOutput.js +24 -0
  100. package/dist/reporters.d.ts +11 -0
  101. package/dist/reporters.js +314 -0
  102. package/dist/runner.d.ts +48 -0
  103. package/dist/runner.js +352 -0
  104. package/dist/snapshot.d.ts +48 -0
  105. package/dist/snapshot.js +37 -0
  106. package/dist/terminal/ansi.d.ts +21 -0
  107. package/dist/terminal/ansi.js +144 -0
  108. package/dist/terminal/capture.d.ts +30 -0
  109. package/dist/terminal/capture.js +91 -0
  110. package/dist/tty.d.ts +72 -0
  111. package/dist/tty.js +175 -0
  112. package/dist/web/domSnapshot.d.ts +27 -0
  113. package/dist/web/domSnapshot.js +55 -0
  114. package/dist/web/playwrightCapture.d.ts +16 -0
  115. package/dist/web/playwrightCapture.js +64 -0
  116. package/package.json +79 -0
package/dist/runner.js ADDED
@@ -0,0 +1,352 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { compareSnapshots } from "./compare.js";
4
+ import { decodePng, renderDiffImage } from "./diff/pixel.js";
5
+ import { stableStringify } from "./json.js";
6
+ import { startManagedLifecycle } from "./lifecycle.js";
7
+ import { baselinePathForTarget, MissingBaselineError, readBaseline, writeBaseline } from "./baselines.js";
8
+ import { captureTarget } from "./capture.js";
9
+ import { captureTypes } from "./captures/registry.js";
10
+ import { canonicalizeSnapshot } from "./snapshot.js";
11
+ export async function updateBaselines(options) {
12
+ return runSnapshots({
13
+ ...options,
14
+ mode: "update"
15
+ });
16
+ }
17
+ export async function testBaselines(options) {
18
+ return runSnapshots({
19
+ ...options,
20
+ mode: "test"
21
+ });
22
+ }
23
+ async function runSnapshots(options) {
24
+ const cwd = path.resolve(options.cwd ?? process.cwd());
25
+ const startedAt = new Date();
26
+ const lifecycle = [];
27
+ const results = [];
28
+ const managedLifecycle = await startManagedLifecycle(options.config.lifecycle, cwd);
29
+ lifecycle.push(...managedLifecycle.runs);
30
+ try {
31
+ const targets = filterTargets(options.config.lifecycle.capture, options.targets);
32
+ const ordered = new Array(targets.length);
33
+ const run = (target) => runTarget(target, options.config, cwd, options.mode, options.includeSnapshots ?? false);
34
+ // Capture in original order, but batch consecutive parallel-safe targets
35
+ // (web) into a bounded concurrent pool — their browser launches/fetches no
36
+ // longer serialize. Non-parallel-safe kinds (performance, terminal, desktop)
37
+ // are barriers: each runs alone so, e.g., a k6 measurement isn't skewed by a
38
+ // concurrent capture. Results are written by index, so the report order is
39
+ // unchanged regardless of completion order.
40
+ let i = 0;
41
+ while (i < targets.length) {
42
+ const target = targets[i];
43
+ if (captureTypes[target.kind].parallelSafe) {
44
+ const batch = [];
45
+ while (i < targets.length &&
46
+ captureTypes[targets[i].kind].parallelSafe) {
47
+ batch.push(i);
48
+ i += 1;
49
+ }
50
+ await mapWithConcurrency(batch, CAPTURE_CONCURRENCY, async (index) => {
51
+ ordered[index] = await run(targets[index]);
52
+ });
53
+ }
54
+ else {
55
+ ordered[i] = await run(target);
56
+ i += 1;
57
+ }
58
+ }
59
+ results.push(...ordered);
60
+ }
61
+ finally {
62
+ lifecycle.push(...(await managedLifecycle.stop()));
63
+ }
64
+ const lifecyclePassed = lifecycle.every((run) => {
65
+ return run.exitCode === 0 || (run.phase === "start" && run.exitCode === null);
66
+ });
67
+ const snapshotsPassed = results.length > 0 &&
68
+ results.every((result) => {
69
+ return options.mode === "update" ? result.status === "updated" : result.status === "passed";
70
+ });
71
+ return {
72
+ kind: "dungbeetle-run",
73
+ mode: options.mode,
74
+ passed: lifecyclePassed && snapshotsPassed,
75
+ project: options.config.project.name,
76
+ startedAt: startedAt.toISOString(),
77
+ finishedAt: new Date().toISOString(),
78
+ lifecycle,
79
+ results
80
+ };
81
+ }
82
+ async function runTarget(target, config, cwd, mode, includeSnapshots) {
83
+ const baselinePath = baselinePathForTarget(config.baselinesDir, target.name, cwd);
84
+ // Canonical candidate snapshot + screenshot, kept in scope so they can be
85
+ // attached even when the baseline is missing (so a reviewer can see the
86
+ // capture and promote it to a first version).
87
+ let candidateJson;
88
+ let candidateScreenshot;
89
+ let candidateScreenshots = {};
90
+ try {
91
+ const snapshot = await captureTarget(target, {
92
+ config,
93
+ cwd
94
+ });
95
+ const canonical = canonicalizeForBaseline(snapshot);
96
+ candidateJson = includeSnapshots ? stableStringify(canonical) : undefined;
97
+ candidateScreenshot = screenshotBuffer(snapshot);
98
+ candidateScreenshots = namedScreenshotBuffers(snapshot);
99
+ if (mode === "update") {
100
+ // Read the previous screenshot before overwriting so an update can still
101
+ // show before/after of the baseline we are replacing.
102
+ const previousScreenshot = await readScreenshotArtifact(baselinePath);
103
+ const previousScreenshots = await readNamedScreenshotArtifacts(baselinePath, Object.keys(candidateScreenshots));
104
+ await writeBaseline(baselinePath, canonical);
105
+ await writeScreenshotArtifact(baselinePath, snapshot);
106
+ await writeNamedScreenshotArtifacts(baselinePath, candidateScreenshots);
107
+ const result = {
108
+ name: target.name,
109
+ kind: target.kind,
110
+ status: "updated",
111
+ baselinePath,
112
+ diff: "",
113
+ ...(candidateJson ? { snapshot: candidateJson } : {})
114
+ };
115
+ attachScreenshot(result, target, candidateScreenshot, previousScreenshot, config, {
116
+ includeDiff: Boolean(previousScreenshot)
117
+ });
118
+ attachNamedScreenshots(result, candidateScreenshots, previousScreenshots, config, {
119
+ includeDiff: true
120
+ });
121
+ return result;
122
+ }
123
+ const baseline = await readBaseline(baselinePath);
124
+ const baselineScreenshot = await readScreenshotArtifact(baselinePath);
125
+ const baselineScreenshots = await readNamedScreenshotArtifacts(baselinePath, unionMarkers(baseline, candidateScreenshots));
126
+ const comparison = compareSnapshots(baseline, canonical, {
127
+ comparison: config.comparison,
128
+ target,
129
+ baselineScreenshot,
130
+ candidateScreenshot,
131
+ baselineScreenshots,
132
+ candidateScreenshots
133
+ });
134
+ const status = comparison.equal ? "passed" : "failed";
135
+ const result = {
136
+ name: target.name,
137
+ kind: target.kind,
138
+ status,
139
+ baselinePath,
140
+ diff: comparison.rendered,
141
+ ...(comparison.pixel ? { pixel: comparison.pixel } : {}),
142
+ ...(candidateJson ? { snapshot: candidateJson } : {})
143
+ };
144
+ // Always surface before/after screenshots for web targets when we have them,
145
+ // so passing and failing runs both render the captured view. The red diff
146
+ // overlay is only meaningful — and only included — when the run failed.
147
+ attachScreenshot(result, target, candidateScreenshot, baselineScreenshot, config, {
148
+ includeDiff: status === "failed",
149
+ images: comparison.screenshotImages
150
+ });
151
+ attachNamedScreenshots(result, candidateScreenshots, baselineScreenshots, config, {
152
+ includeDiff: status === "failed",
153
+ imagesByMarker: comparison.screenshotImagesByMarker
154
+ });
155
+ return result;
156
+ }
157
+ catch (error) {
158
+ if (error instanceof MissingBaselineError) {
159
+ const result = {
160
+ name: target.name,
161
+ kind: target.kind,
162
+ status: "missing",
163
+ baselinePath,
164
+ diff: "",
165
+ ...(candidateJson ? { snapshot: candidateJson } : {}),
166
+ error: error.message
167
+ };
168
+ // No baseline to compare against, but we still captured the candidate —
169
+ // surface it so the report shows what the target looks like now.
170
+ attachScreenshot(result, target, candidateScreenshot, undefined, config, {
171
+ includeDiff: false
172
+ });
173
+ attachNamedScreenshots(result, candidateScreenshots, {}, config, {
174
+ includeDiff: false
175
+ });
176
+ return result;
177
+ }
178
+ return {
179
+ name: target.name,
180
+ kind: target.kind,
181
+ status: "error",
182
+ baselinePath,
183
+ diff: "",
184
+ error: error instanceof Error ? error.message : String(error)
185
+ };
186
+ }
187
+ }
188
+ function canonicalizeForBaseline(snapshot) {
189
+ return canonicalizeSnapshot(snapshot);
190
+ }
191
+ // The baseline JSON only stores a screenshot digest, so write the actual PNG
192
+ // next to the baseline (e.g. dungbeetle.snapshots/home.png) for human inspection
193
+ // and to enable tolerant pixel comparison on later runs.
194
+ async function writeScreenshotArtifact(baselinePath, snapshot) {
195
+ const buffer = screenshotBuffer(snapshot);
196
+ if (!buffer) {
197
+ return;
198
+ }
199
+ const pngPath = screenshotPathFor(baselinePath);
200
+ await mkdir(path.dirname(pngPath), { recursive: true });
201
+ await writeFile(pngPath, buffer);
202
+ }
203
+ async function readScreenshotArtifact(baselinePath) {
204
+ try {
205
+ return await readFile(screenshotPathFor(baselinePath));
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ // Named per-marker screenshots (game): the artifact carries a
212
+ // `screenshots: {marker: {data}}` map; each marker's PNG is written alongside
213
+ // the baseline as `<name>.<marker>.png`, mirroring web's single `<name>.png`.
214
+ export function namedScreenshotBuffers(snapshot) {
215
+ const screenshots = snapshot.screenshots;
216
+ const buffers = {};
217
+ for (const [marker, shot] of Object.entries(screenshots ?? {})) {
218
+ if (typeof shot?.data === "string") {
219
+ buffers[marker] = Buffer.from(shot.data, "base64");
220
+ }
221
+ }
222
+ return buffers;
223
+ }
224
+ function markerScreenshotPathFor(baselinePath, marker) {
225
+ const base = baselinePath.endsWith(".json")
226
+ ? baselinePath.slice(0, -".json".length)
227
+ : baselinePath;
228
+ return `${base}.${marker}.png`;
229
+ }
230
+ async function writeNamedScreenshotArtifacts(baselinePath, buffers) {
231
+ for (const [marker, buffer] of Object.entries(buffers)) {
232
+ const pngPath = markerScreenshotPathFor(baselinePath, marker);
233
+ await mkdir(path.dirname(pngPath), { recursive: true });
234
+ await writeFile(pngPath, buffer);
235
+ }
236
+ }
237
+ async function readNamedScreenshotArtifacts(baselinePath, markers) {
238
+ const buffers = {};
239
+ for (const marker of markers) {
240
+ try {
241
+ buffers[marker] = await readFile(markerScreenshotPathFor(baselinePath, marker));
242
+ }
243
+ catch {
244
+ // No stored PNG for this marker — digest-only comparison still applies.
245
+ }
246
+ }
247
+ return buffers;
248
+ }
249
+ // Markers to read baseline PNGs for: whatever the candidate captured plus
250
+ // whatever the canonical baseline says it had (so removals still render).
251
+ function unionMarkers(baseline, candidateScreenshots) {
252
+ const markers = new Set(Object.keys(candidateScreenshots));
253
+ const stored = baseline?.screenshots;
254
+ if (stored && typeof stored === "object") {
255
+ for (const marker of Object.keys(stored)) {
256
+ markers.add(marker);
257
+ }
258
+ }
259
+ return [...markers];
260
+ }
261
+ function attachNamedScreenshots(result, candidates, baselines, config, options) {
262
+ const markers = new Set([...Object.keys(candidates), ...Object.keys(baselines)]);
263
+ const screenshots = {};
264
+ for (const marker of markers) {
265
+ const candidate = candidates[marker];
266
+ if (!candidate) {
267
+ // Marker removed: surface the baseline image alone so the report shows
268
+ // what disappeared.
269
+ const baseline = baselines[marker];
270
+ if (baseline) {
271
+ screenshots[marker] = { baseline: baseline.toString("base64") };
272
+ }
273
+ continue;
274
+ }
275
+ const baseline = baselines[marker];
276
+ const comparison = buildScreenshotComparison(baseline, candidate, config.comparison.pixelTolerance,
277
+ // Unlike the single-screenshot path, only changed markers get a diff
278
+ // overlay — a failed game run shouldn't render red overlays for the
279
+ // markers that still match.
280
+ options.includeDiff && Boolean(baseline) && !baseline?.equals(candidate), options.imagesByMarker?.[marker]);
281
+ if (comparison) {
282
+ screenshots[marker] = comparison;
283
+ }
284
+ }
285
+ if (Object.keys(screenshots).length > 0) {
286
+ result.screenshots = screenshots;
287
+ }
288
+ }
289
+ // Attach a before/after (and optional diff) screenshot to a web result when a
290
+ // candidate image is available, mutating the result in place.
291
+ function attachScreenshot(result, target, candidate, baseline, config, options) {
292
+ if (!captureTypes[target.kind].hasScreenshots || !candidate) {
293
+ return;
294
+ }
295
+ const screenshot = buildScreenshotComparison(baseline, candidate, config.comparison.pixelTolerance, options.includeDiff, options.images);
296
+ if (screenshot) {
297
+ result.screenshot = screenshot;
298
+ }
299
+ }
300
+ export function buildScreenshotComparison(baseline, candidate, pixelTolerance, includeDiff,
301
+ // Decoded images reused from the comparison so the diff path decodes each PNG
302
+ // once. Absent on the update/missing paths, which decode on demand here.
303
+ images) {
304
+ const comparison = {
305
+ candidate: candidate.toString("base64")
306
+ };
307
+ if (baseline) {
308
+ comparison.baseline = baseline.toString("base64");
309
+ if (includeDiff) {
310
+ const before = images?.baseline ?? decodePng(baseline);
311
+ const after = images?.candidate ?? decodePng(candidate);
312
+ const diff = renderDiffImage(before, after, pixelTolerance);
313
+ if (diff) {
314
+ comparison.diff = diff.toString("base64");
315
+ }
316
+ }
317
+ }
318
+ return comparison;
319
+ }
320
+ export function screenshotBuffer(snapshot) {
321
+ const screenshot = snapshot.screenshot;
322
+ if (!screenshot || typeof screenshot.data !== "string") {
323
+ return undefined;
324
+ }
325
+ return Buffer.from(screenshot.data, "base64");
326
+ }
327
+ function screenshotPathFor(baselinePath) {
328
+ return baselinePath.replace(/\.json$/, ".png");
329
+ }
330
+ // Max parallel-safe captures in flight at once. Bounded so a suite with many web
331
+ // targets doesn't launch an unbounded number of browsers or flood a dev server.
332
+ const CAPTURE_CONCURRENCY = 4;
333
+ // Run `fn` over `items` with at most `limit` in flight, awaiting all. `runTarget`
334
+ // catches its own errors (returning an error result), so a worker never rejects.
335
+ async function mapWithConcurrency(items, limit, fn) {
336
+ let cursor = 0;
337
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
338
+ while (cursor < items.length) {
339
+ const index = cursor;
340
+ cursor += 1;
341
+ await fn(items[index]);
342
+ }
343
+ });
344
+ await Promise.all(workers);
345
+ }
346
+ function filterTargets(targets, selectedNames) {
347
+ if (!selectedNames || selectedNames.length === 0) {
348
+ return targets;
349
+ }
350
+ const selected = new Set(selectedNames);
351
+ return targets.filter((target) => selected.has(target.name));
352
+ }
@@ -0,0 +1,48 @@
1
+ import type { CaptureTarget } from "./config.js";
2
+ export type SnapshotKind = CaptureTarget["kind"];
3
+ export type CanonicalTerminal = {
4
+ kind: "terminal";
5
+ exitCode?: number | null;
6
+ signal?: string | null;
7
+ stdout?: unknown;
8
+ stderr?: unknown;
9
+ };
10
+ export type CanonicalWeb = {
11
+ kind: "web";
12
+ root?: unknown;
13
+ driver?: unknown;
14
+ accessibility?: unknown;
15
+ screenshot?: unknown;
16
+ };
17
+ export type CanonicalPerformance = {
18
+ kind: "performance";
19
+ tool?: unknown;
20
+ metrics?: unknown;
21
+ };
22
+ export type CanonicalDesktop = {
23
+ kind: "desktop";
24
+ root?: unknown;
25
+ tool?: unknown;
26
+ };
27
+ export type CanonicalCheck = {
28
+ kind: "check";
29
+ tool?: unknown;
30
+ data?: unknown;
31
+ };
32
+ export type CanonicalApi = {
33
+ kind: "api";
34
+ status?: unknown;
35
+ headers?: unknown;
36
+ bodyType?: unknown;
37
+ body?: unknown;
38
+ };
39
+ export type CanonicalGame = {
40
+ kind: "game";
41
+ engine?: unknown;
42
+ markers?: unknown;
43
+ screenshots?: unknown;
44
+ };
45
+ export type CanonicalSnapshot = CanonicalApi | CanonicalCheck | CanonicalTerminal | CanonicalWeb | CanonicalPerformance | CanonicalDesktop | CanonicalGame;
46
+ export declare function snapshotKind(value: unknown): SnapshotKind | undefined;
47
+ export declare function canonicalizeSnapshot(value: unknown): unknown;
48
+ export declare function canonicalizeRecord(value: Record<string, unknown>): unknown;
@@ -0,0 +1,37 @@
1
+ import { captureTypes, isCaptureKind } from "./captures/registry.js";
2
+ import { isRecord } from "./guards.js";
3
+ // Single source of truth for "is this a tagged snapshot, and of which kind?".
4
+ // Returns undefined for plain objects and unknown kinds (which fall back to the
5
+ // generic structural comparison). The known-kind set is derived from the capture
6
+ // registry.
7
+ export function snapshotKind(value) {
8
+ if (!isRecord(value)) {
9
+ return undefined;
10
+ }
11
+ return isCaptureKind(value.kind) ? value.kind : undefined;
12
+ }
13
+ export function canonicalizeSnapshot(value) {
14
+ if (Array.isArray(value)) {
15
+ return value.map(canonicalizeSnapshot);
16
+ }
17
+ if (!isRecord(value)) {
18
+ return value;
19
+ }
20
+ // Known capture kinds canonicalize through their registered handler; anything
21
+ // else (plain nested objects, unknown kinds) gets the generic record handling.
22
+ if (isCaptureKind(value.kind)) {
23
+ return captureTypes[value.kind].canonicalize(value);
24
+ }
25
+ return canonicalizeRecord(value);
26
+ }
27
+ // Generic record canonicalization: recurse into nested values and drop volatile
28
+ // runtime metadata. Shared by the generic fallback above and by capture kinds
29
+ // (e.g. performance) that have no dedicated canonical shape.
30
+ export function canonicalizeRecord(value) {
31
+ return Object.fromEntries(Object.entries(value)
32
+ .filter(([key]) => !isRuntimeMetadataKey(key))
33
+ .map(([key, nested]) => [key, canonicalizeSnapshot(nested)]));
34
+ }
35
+ function isRuntimeMetadataKey(key) {
36
+ return key === "durationMs" || key === "cwd" || key === "source";
37
+ }
@@ -0,0 +1,21 @@
1
+ import type { MaskRule } from "../config.js";
2
+ export type AnsiStyle = {
3
+ bold?: boolean;
4
+ dim?: boolean;
5
+ italic?: boolean;
6
+ underline?: boolean;
7
+ inverse?: boolean;
8
+ foreground?: string;
9
+ background?: string;
10
+ };
11
+ export type TerminalSegment = {
12
+ text: string;
13
+ style: AnsiStyle;
14
+ };
15
+ export type NormalizedTerminalStream = {
16
+ text: string;
17
+ segments: TerminalSegment[];
18
+ };
19
+ export declare function stripAnsi(value: string): string;
20
+ export declare function stripControlChars(value: string): string;
21
+ export declare function normalizeAnsiStream(value: string, maskRules?: MaskRule[]): NormalizedTerminalStream;
@@ -0,0 +1,144 @@
1
+ import { applyMaskRules, normalizeLineEndings } from "../normalization.js";
2
+ const foregroundColors = new Map([
3
+ [30, "black"],
4
+ [31, "red"],
5
+ [32, "green"],
6
+ [33, "yellow"],
7
+ [34, "blue"],
8
+ [35, "magenta"],
9
+ [36, "cyan"],
10
+ [37, "white"],
11
+ [90, "bright-black"],
12
+ [91, "bright-red"],
13
+ [92, "bright-green"],
14
+ [93, "bright-yellow"],
15
+ [94, "bright-blue"],
16
+ [95, "bright-magenta"],
17
+ [96, "bright-cyan"],
18
+ [97, "bright-white"]
19
+ ]);
20
+ const backgroundColors = new Map([
21
+ [40, "black"],
22
+ [41, "red"],
23
+ [42, "green"],
24
+ [43, "yellow"],
25
+ [44, "blue"],
26
+ [45, "magenta"],
27
+ [46, "cyan"],
28
+ [47, "white"],
29
+ [100, "bright-black"],
30
+ [101, "bright-red"],
31
+ [102, "bright-green"],
32
+ [103, "bright-yellow"],
33
+ [104, "bright-blue"],
34
+ [105, "bright-magenta"],
35
+ [106, "bright-cyan"],
36
+ [107, "bright-white"]
37
+ ]);
38
+ export function stripAnsi(value) {
39
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences requires the ESC (U+001B) control character.
40
+ return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
41
+ }
42
+ // Neutralize C0/C1 control characters (keeping tab and newline) before printing
43
+ // content that originates from a stored baseline, a tool's output, or a server
44
+ // response. Capture-time normalization only strips freshly captured streams;
45
+ // content read back from disk or the network reaches the reviewer's terminal
46
+ // verbatim, so raw escapes (CSI, OSC clipboard writes, title spoofing, CR line
47
+ // overwrites that hide a regression) would otherwise pass straight through.
48
+ export function stripControlChars(value) {
49
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control chars to remove them.
50
+ return value.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, "");
51
+ }
52
+ export function normalizeAnsiStream(value, maskRules = []) {
53
+ const normalized = normalizeLineEndings(value);
54
+ const segments = [];
55
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI SGR sequences requires the ESC (U+001B) control character.
56
+ const ansiPattern = /\u001b\[([0-9;]*)m/g;
57
+ let style = {};
58
+ let cursor = 0;
59
+ let text = "";
60
+ let match = ansiPattern.exec(normalized);
61
+ while (match !== null) {
62
+ appendSegment(normalized.slice(cursor, match.index));
63
+ style = applySgrCodes(style, parseCodes(match[1] ?? ""));
64
+ cursor = match.index + match[0].length;
65
+ match = ansiPattern.exec(normalized);
66
+ }
67
+ appendSegment(normalized.slice(cursor));
68
+ return {
69
+ text: applyMaskRules(text, maskRules),
70
+ segments: segments.map((segment) => ({
71
+ ...segment,
72
+ text: applyMaskRules(segment.text, maskRules)
73
+ }))
74
+ };
75
+ function appendSegment(raw) {
76
+ if (!raw) {
77
+ return;
78
+ }
79
+ const stripped = stripAnsi(raw);
80
+ text += stripped;
81
+ segments.push({
82
+ text: stripped,
83
+ style: { ...style }
84
+ });
85
+ }
86
+ }
87
+ function parseCodes(raw) {
88
+ if (!raw) {
89
+ return [0];
90
+ }
91
+ return raw.split(";").map((code) => {
92
+ const parsed = Number.parseInt(code, 10);
93
+ return Number.isNaN(parsed) ? 0 : parsed;
94
+ });
95
+ }
96
+ function applySgrCodes(style, codes) {
97
+ let next = { ...style };
98
+ for (const code of codes) {
99
+ if (code === 0) {
100
+ next = {};
101
+ }
102
+ else if (code === 1) {
103
+ next.bold = true;
104
+ }
105
+ else if (code === 2) {
106
+ next.dim = true;
107
+ }
108
+ else if (code === 3) {
109
+ next.italic = true;
110
+ }
111
+ else if (code === 4) {
112
+ next.underline = true;
113
+ }
114
+ else if (code === 7) {
115
+ next.inverse = true;
116
+ }
117
+ else if (code === 22) {
118
+ delete next.bold;
119
+ delete next.dim;
120
+ }
121
+ else if (code === 23) {
122
+ delete next.italic;
123
+ }
124
+ else if (code === 24) {
125
+ delete next.underline;
126
+ }
127
+ else if (code === 27) {
128
+ delete next.inverse;
129
+ }
130
+ else if (code === 39) {
131
+ delete next.foreground;
132
+ }
133
+ else if (code === 49) {
134
+ delete next.background;
135
+ }
136
+ else if (foregroundColors.has(code)) {
137
+ next.foreground = foregroundColors.get(code);
138
+ }
139
+ else if (backgroundColors.has(code)) {
140
+ next.background = backgroundColors.get(code);
141
+ }
142
+ }
143
+ return next;
144
+ }
@@ -0,0 +1,30 @@
1
+ import type { MaskRule } from "../config.js";
2
+ import { type NormalizedTerminalStream } from "./ansi.js";
3
+ export type TerminalCaptureOptions = {
4
+ command: string;
5
+ cwd?: string;
6
+ timeoutMs?: number;
7
+ maskRules?: MaskRule[];
8
+ };
9
+ export type TerminalCapture = {
10
+ kind: "terminal";
11
+ command: string;
12
+ cwd: string;
13
+ exitCode: number | null;
14
+ signal: NodeJS.Signals | null;
15
+ durationMs: number;
16
+ stdout: NormalizedTerminalStream;
17
+ stderr: NormalizedTerminalStream;
18
+ };
19
+ export declare function captureTerminal(options: TerminalCaptureOptions): Promise<TerminalCapture>;
20
+ export type ShellResult = {
21
+ stdout: string;
22
+ stderr: string;
23
+ exitCode: number | null;
24
+ signal: NodeJS.Signals | null;
25
+ };
26
+ export declare function runShellCommand(options: {
27
+ command: string;
28
+ cwd: string;
29
+ timeoutMs: number;
30
+ }): Promise<ShellResult>;