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.
- package/LICENSE +105 -0
- package/NOTICE +19 -0
- package/README.md +139 -0
- package/dist/api/capture.d.ts +24 -0
- package/dist/api/capture.js +61 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +38 -0
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +9 -0
- package/dist/capture.d.ts +15 -0
- package/dist/capture.js +7 -0
- package/dist/captures/api.d.ts +2 -0
- package/dist/captures/api.js +114 -0
- package/dist/captures/check.d.ts +2 -0
- package/dist/captures/check.js +116 -0
- package/dist/captures/desktop.d.ts +2 -0
- package/dist/captures/desktop.js +97 -0
- package/dist/captures/game.d.ts +4 -0
- package/dist/captures/game.js +266 -0
- package/dist/captures/performance.d.ts +2 -0
- package/dist/captures/performance.js +47 -0
- package/dist/captures/registry.d.ts +4 -0
- package/dist/captures/registry.js +23 -0
- package/dist/captures/terminal.d.ts +2 -0
- package/dist/captures/terminal.js +65 -0
- package/dist/captures/types.d.ts +18 -0
- package/dist/captures/types.js +1 -0
- package/dist/captures/web.d.ts +3 -0
- package/dist/captures/web.js +248 -0
- package/dist/check/capture.d.ts +15 -0
- package/dist/check/capture.js +76 -0
- package/dist/check/junit.d.ts +9 -0
- package/dist/check/junit.js +51 -0
- package/dist/check/laravel.d.ts +2 -0
- package/dist/check/laravel.js +44 -0
- package/dist/check/parsers.d.ts +12 -0
- package/dist/check/parsers.js +278 -0
- package/dist/check/schema.d.ts +2 -0
- package/dist/check/schema.js +114 -0
- package/dist/cloud.d.ts +42 -0
- package/dist/cloud.js +334 -0
- package/dist/compare/shared.d.ts +42 -0
- package/dist/compare/shared.js +115 -0
- package/dist/compare.d.ts +3 -0
- package/dist/compare.js +33 -0
- package/dist/config.d.ts +146 -0
- package/dist/config.js +382 -0
- package/dist/desktop/a11y.d.ts +18 -0
- package/dist/desktop/a11y.js +74 -0
- package/dist/desktop/capture.d.ts +13 -0
- package/dist/desktop/capture.js +80 -0
- package/dist/desktop/macos.d.ts +8 -0
- package/dist/desktop/macos.js +98 -0
- package/dist/desktop/ocr.d.ts +17 -0
- package/dist/desktop/ocr.js +99 -0
- package/dist/diff/lcs.d.ts +5 -0
- package/dist/diff/lcs.js +42 -0
- package/dist/diff/numeric.d.ts +6 -0
- package/dist/diff/numeric.js +24 -0
- package/dist/diff/pixel.d.ts +23 -0
- package/dist/diff/pixel.js +97 -0
- package/dist/diff/structural.d.ts +11 -0
- package/dist/diff/structural.js +38 -0
- package/dist/diff/text.d.ts +7 -0
- package/dist/diff/text.js +64 -0
- package/dist/diff/tree.d.ts +46 -0
- package/dist/diff/tree.js +188 -0
- package/dist/doctor.d.ts +18 -0
- package/dist/doctor.js +57 -0
- package/dist/game/capture.d.ts +24 -0
- package/dist/game/capture.js +51 -0
- package/dist/game/protocol.d.ts +30 -0
- package/dist/game/protocol.js +146 -0
- package/dist/game/walkthrough.d.ts +45 -0
- package/dist/game/walkthrough.js +85 -0
- package/dist/guards.d.ts +2 -0
- package/dist/guards.js +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +504 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +40 -0
- package/dist/lifecycle.d.ts +14 -0
- package/dist/lifecycle.js +190 -0
- package/dist/normalization.d.ts +4 -0
- package/dist/normalization.js +27 -0
- package/dist/perf/ab.d.ts +6 -0
- package/dist/perf/ab.js +89 -0
- package/dist/perf/autocannon.d.ts +6 -0
- package/dist/perf/autocannon.js +101 -0
- package/dist/perf/capture.d.ts +7 -0
- package/dist/perf/capture.js +6 -0
- package/dist/perf/k6.d.ts +9 -0
- package/dist/perf/k6.js +44 -0
- package/dist/perf/parsers.d.ts +15 -0
- package/dist/perf/parsers.js +69 -0
- package/dist/perf/run.d.ts +8 -0
- package/dist/perf/run.js +45 -0
- package/dist/perf/toolOutput.d.ts +3 -0
- package/dist/perf/toolOutput.js +24 -0
- package/dist/reporters.d.ts +11 -0
- package/dist/reporters.js +314 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +352 -0
- package/dist/snapshot.d.ts +48 -0
- package/dist/snapshot.js +37 -0
- package/dist/terminal/ansi.d.ts +21 -0
- package/dist/terminal/ansi.js +144 -0
- package/dist/terminal/capture.d.ts +30 -0
- package/dist/terminal/capture.js +91 -0
- package/dist/tty.d.ts +72 -0
- package/dist/tty.js +175 -0
- package/dist/web/domSnapshot.d.ts +27 -0
- package/dist/web/domSnapshot.js +55 -0
- package/dist/web/playwrightCapture.d.ts +16 -0
- package/dist/web/playwrightCapture.js +64 -0
- 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;
|
package/dist/snapshot.js
ADDED
|
@@ -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>;
|