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/cloud.js ADDED
@@ -0,0 +1,334 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { isErrno, isRecord } from "./guards.js";
4
+ import { BRAND_NAME } from "./brand.js";
5
+ // The API major version this CLI speaks. The server echoes its own version in the
6
+ // `Dungbeetle-Api-Version` header on every API response; a mismatch means the CLI and
7
+ // server are incompatible, so we fail with a clear message rather than confusing
8
+ // 404s or validation errors. Bump in lockstep with a breaking server change.
9
+ const CLIENT_API_VERSION = 1;
10
+ const API_BASE = `/api/v${CLIENT_API_VERSION}`;
11
+ function assertApiVersion(response, origin) {
12
+ const serverVersion = response.headers.get("dungbeetle-api-version");
13
+ if (serverVersion !== null && serverVersion !== String(CLIENT_API_VERSION)) {
14
+ throw new Error(`${BRAND_NAME} API version mismatch: this CLI speaks v${CLIENT_API_VERSION} but ${origin} ` +
15
+ `speaks v${serverVersion}. Upgrade the CLI or the server so they match.`);
16
+ }
17
+ }
18
+ // Upload a JSON report (as written by `dungbeetle test`/`ci`) to a Dungbeetle server.
19
+ // Keeps "how tests are written" unchanged: this consumes the existing report
20
+ // artifact rather than re-running capture.
21
+ export async function pushReport(options) {
22
+ let report;
23
+ try {
24
+ report = JSON.parse(await readFile(options.reportPath, "utf8"));
25
+ }
26
+ catch (error) {
27
+ if (isErrno(error, "ENOENT")) {
28
+ throw new Error(`Report not found at ${options.reportPath}; run \`dungbeetle test\` first.`);
29
+ }
30
+ throw new Error(`Could not read report at ${options.reportPath}: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ // Upload screenshots as discrete artifacts so the run POST carries refs, not
33
+ // base64. Best-effort: on failure the report keeps its inline screenshots and
34
+ // the server offloads them on ingest (no hard cutover).
35
+ await offloadScreenshots(report, options);
36
+ const body = await postJson(options.serverUrl, `${API_BASE}/runs`, options, {
37
+ report,
38
+ branch: options.branch,
39
+ commit: options.commit
40
+ });
41
+ // Validate the wire shape before handing it back: a malformed but 2xx response
42
+ // (e.g. an HTML error page) would otherwise surface as a confusing TypeError
43
+ // when a caller reads a field.
44
+ const run = isRecord(body) ? body.run : undefined;
45
+ if (!isRecord(run) ||
46
+ typeof run.id !== "string" ||
47
+ typeof run.repositoryId !== "string" ||
48
+ typeof run.status !== "string" ||
49
+ typeof run.url !== "string" ||
50
+ !isRecord(run.counts)) {
51
+ throw new Error("Server response did not include a valid run.");
52
+ }
53
+ return {
54
+ id: run.id,
55
+ repositoryId: run.repositoryId,
56
+ status: run.status,
57
+ counts: run.counts,
58
+ url: run.url
59
+ };
60
+ }
61
+ // Roles whose screenshot bytes are offloaded out of the report.
62
+ const SCREENSHOT_ROLES = ["baseline", "candidate", "diff"];
63
+ // Move each result's inline base64 screenshots into content-addressed artifacts:
64
+ // probe which digests the server lacks, upload only those, then rewrite the
65
+ // report's results to reference them by id. Mutates `report` in place — but only
66
+ // after every upload succeeds, so a failure leaves the inline bytes untouched for
67
+ // the server to offload instead. Unchanged screenshots upload zero bytes.
68
+ async function offloadScreenshots(report, options) {
69
+ if (!isRecord(report) || !Array.isArray(report.results)) {
70
+ return;
71
+ }
72
+ const artifacts = new Map();
73
+ const plans = [];
74
+ const namedPlans = [];
75
+ const collectRefs = (shot) => {
76
+ const refs = {};
77
+ for (const role of SCREENSHOT_ROLES) {
78
+ const encoded = shot[role];
79
+ if (typeof encoded !== "string" || encoded.length === 0) {
80
+ continue;
81
+ }
82
+ const buf = Buffer.from(encoded, "base64");
83
+ if (buf.length === 0) {
84
+ continue;
85
+ }
86
+ const digest = createHash("sha256").update(buf).digest("hex");
87
+ if (!artifacts.has(digest)) {
88
+ artifacts.set(digest, buf);
89
+ }
90
+ refs[role] = digest;
91
+ }
92
+ return refs;
93
+ };
94
+ for (const result of report.results) {
95
+ if (!isRecord(result)) {
96
+ continue;
97
+ }
98
+ if (isRecord(result.screenshot)) {
99
+ const refs = collectRefs(result.screenshot);
100
+ if (Object.keys(refs).length > 0) {
101
+ plans.push({ result, refs });
102
+ }
103
+ }
104
+ // Named per-marker screenshots (game targets) offload the same way, one
105
+ // ref set per marker.
106
+ if (isRecord(result.screenshots)) {
107
+ const refsByMarker = {};
108
+ for (const [marker, shot] of Object.entries(result.screenshots)) {
109
+ if (!isRecord(shot)) {
110
+ continue;
111
+ }
112
+ const refs = collectRefs(shot);
113
+ if (Object.keys(refs).length > 0) {
114
+ refsByMarker[marker] = refs;
115
+ }
116
+ }
117
+ if (Object.keys(refsByMarker).length > 0) {
118
+ namedPlans.push({ result, refs: refsByMarker });
119
+ }
120
+ }
121
+ }
122
+ if (artifacts.size === 0) {
123
+ return;
124
+ }
125
+ const digests = [...artifacts.keys()];
126
+ try {
127
+ const probe = (await postJson(options.serverUrl, `${API_BASE}/screenshots/probe`, options, {
128
+ digests
129
+ }));
130
+ const missing = new Set(Array.isArray(probe.missing)
131
+ ? probe.missing.filter((d) => typeof d === "string")
132
+ : digests);
133
+ for (const digest of digests) {
134
+ if (missing.has(digest)) {
135
+ await putArtifact(options, digest, artifacts.get(digest));
136
+ }
137
+ }
138
+ }
139
+ catch (error) {
140
+ // Leave inline screenshots in place; the server still offloads them on ingest.
141
+ process.stderr.write(`warning: screenshot pre-upload failed (${error instanceof Error ? error.message : String(error)}); sending them inline instead.\n`);
142
+ return;
143
+ }
144
+ // All artifacts are stored — swap inline bytes for refs.
145
+ for (const { result, refs } of plans) {
146
+ result.screenshotRefs = refs;
147
+ delete result.screenshot;
148
+ }
149
+ for (const { result, refs } of namedPlans) {
150
+ result.screenshotsRefs = refs;
151
+ delete result.screenshots;
152
+ }
153
+ }
154
+ // Upload local baseline snapshots (and any screenshot artifact) to the server,
155
+ // which versions them. Sends the snapshot file verbatim so its digest is stable.
156
+ export async function pushBaselines(options) {
157
+ const uploaded = [];
158
+ for (const source of options.baselines) {
159
+ const snapshot = await readBaselineSnapshot(source);
160
+ const screenshot = await readScreenshot(source.screenshotPath);
161
+ const body = await postJson(options.serverUrl, `${API_BASE}/baselines`, options, {
162
+ target: source.target,
163
+ kind: source.kind,
164
+ snapshot,
165
+ screenshot
166
+ });
167
+ if (!isRecord(body) || !isRecord(body.baseline) || typeof body.baseline.version !== "number") {
168
+ throw new Error(`Server response for baseline "${source.target}" was malformed (missing baseline.version).`);
169
+ }
170
+ uploaded.push({
171
+ target: source.target,
172
+ version: body.baseline.version,
173
+ deduped: body.deduped === true
174
+ });
175
+ }
176
+ return uploaded;
177
+ }
178
+ // Client credentials are sent as HTTP Basic, so over plaintext http:// they are
179
+ // exposed to anyone on-path. Warn loudly for non-loopback http targets; loopback
180
+ // (local dev) stays quiet. Localhost over http is fine; a remote http server is
181
+ // a real credential-exposure risk that deserves a heads-up.
182
+ function warnIfInsecure(endpoint) {
183
+ if (endpoint.protocol === "https:") {
184
+ return;
185
+ }
186
+ const host = endpoint.hostname;
187
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
188
+ if (!isLoopback) {
189
+ process.stderr.write(`warning: sending client credentials over plaintext ${endpoint.protocol}// to ${endpoint.host}. ` +
190
+ `Use https:// so they are not exposed on the network.\n`);
191
+ }
192
+ }
193
+ async function readBaselineSnapshot(source) {
194
+ try {
195
+ return await readFile(source.snapshotPath, "utf8");
196
+ }
197
+ catch (error) {
198
+ if (isErrno(error, "ENOENT")) {
199
+ throw new Error(`No baseline for "${source.target}" at ${source.snapshotPath}; run \`dungbeetle update\` first.`);
200
+ }
201
+ throw error;
202
+ }
203
+ }
204
+ async function readScreenshot(screenshotPath) {
205
+ if (!screenshotPath) {
206
+ return undefined;
207
+ }
208
+ try {
209
+ return (await readFile(screenshotPath)).toString("base64");
210
+ }
211
+ catch (error) {
212
+ if (isErrno(error, "ENOENT")) {
213
+ return undefined;
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ function basicAuthHeader(credentials) {
219
+ const basic = Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`, "utf8").toString("base64");
220
+ return `Basic ${basic}`;
221
+ }
222
+ async function postJson(serverUrl, pathname, credentials, body) {
223
+ const endpoint = new URL(pathname, serverUrl);
224
+ warnIfInsecure(endpoint);
225
+ let response;
226
+ try {
227
+ response = await fetch(endpoint, {
228
+ method: "POST",
229
+ headers: {
230
+ "content-type": "application/json",
231
+ authorization: basicAuthHeader(credentials)
232
+ },
233
+ body: JSON.stringify(body)
234
+ });
235
+ }
236
+ catch (error) {
237
+ throw new Error(`Could not reach ${BRAND_NAME} server at ${endpoint.origin}: ${error instanceof Error ? error.message : String(error)}`);
238
+ }
239
+ assertApiVersion(response, endpoint.origin);
240
+ if (!response.ok) {
241
+ const detail = await safeErrorMessage(response);
242
+ throw new Error(`Upload failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}.`);
243
+ }
244
+ return readJsonCapped(response);
245
+ }
246
+ // Share a capture anonymously: POST the report to the public /anon/runs endpoint
247
+ // (no credentials, not API-versioned) and return the public web link. Deliberately
248
+ // separate from pushReport — no auth header, no version handshake.
249
+ export async function pushAnonReport(options) {
250
+ const endpoint = new URL("/anon/runs", options.serverUrl);
251
+ warnIfInsecure(endpoint);
252
+ let response;
253
+ try {
254
+ response = await fetch(endpoint, {
255
+ method: "POST",
256
+ headers: { "content-type": "application/json" },
257
+ body: JSON.stringify({ report: options.report })
258
+ });
259
+ }
260
+ catch (error) {
261
+ throw new Error(`Could not reach ${BRAND_NAME} server at ${endpoint.origin}: ${error instanceof Error ? error.message : String(error)}`);
262
+ }
263
+ if (!response.ok) {
264
+ const detail = await safeErrorMessage(response);
265
+ throw new Error(`Upload failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}.`);
266
+ }
267
+ const body = await readJsonCapped(response);
268
+ const run = isRecord(body) ? body.run : undefined;
269
+ if (!isRecord(run) || typeof run.id !== "string" || typeof run.url !== "string") {
270
+ throw new Error("Server response did not include a valid anonymous run.");
271
+ }
272
+ return { id: run.id, url: run.url };
273
+ }
274
+ // Upload one screenshot artifact as raw bytes, content-addressed by `digest`.
275
+ async function putArtifact(options, digest, data) {
276
+ const endpoint = new URL(`${API_BASE}/screenshots/${digest}`, options.serverUrl);
277
+ let response;
278
+ try {
279
+ response = await fetch(endpoint, {
280
+ method: "PUT",
281
+ headers: {
282
+ "content-type": "application/octet-stream",
283
+ authorization: basicAuthHeader(options)
284
+ },
285
+ body: new Uint8Array(data)
286
+ });
287
+ }
288
+ catch (error) {
289
+ throw new Error(`Could not reach ${BRAND_NAME} server at ${endpoint.origin}: ${error instanceof Error ? error.message : String(error)}`);
290
+ }
291
+ assertApiVersion(response, endpoint.origin);
292
+ if (!response.ok) {
293
+ const detail = await safeErrorMessage(response);
294
+ throw new Error(`Screenshot upload failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}.`);
295
+ }
296
+ }
297
+ // The CLI can be pointed at any server, so cap the response body before parsing:
298
+ // a malicious or compromised endpoint could otherwise stream an unbounded body
299
+ // and exhaust memory. Run metadata responses are a few KB; 8 MiB is generous.
300
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
301
+ async function readJsonCapped(response) {
302
+ const declared = Number(response.headers.get("content-length"));
303
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
304
+ throw new Error("Server response exceeded the maximum allowed size.");
305
+ }
306
+ const reader = response.body?.getReader();
307
+ if (!reader) {
308
+ return JSON.parse(await response.text());
309
+ }
310
+ const chunks = [];
311
+ let total = 0;
312
+ for (;;) {
313
+ const { done, value } = await reader.read();
314
+ if (done) {
315
+ break;
316
+ }
317
+ total += value.length;
318
+ if (total > MAX_RESPONSE_BYTES) {
319
+ await reader.cancel();
320
+ throw new Error("Server response exceeded the maximum allowed size.");
321
+ }
322
+ chunks.push(value);
323
+ }
324
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
325
+ }
326
+ async function safeErrorMessage(response) {
327
+ try {
328
+ const body = (await readJsonCapped(response));
329
+ return isRecord(body) && typeof body.error === "string" ? body.error : undefined;
330
+ }
331
+ catch {
332
+ return undefined;
333
+ }
334
+ }
@@ -0,0 +1,42 @@
1
+ import type { CaptureTarget, ComparisonConfig } from "../config.js";
2
+ import type { CanonicalSnapshot, CanonicalWeb } from "../snapshot.js";
3
+ import { type DecodedImage, type PixelDiffResult, type PixelTolerance } from "../diff/pixel.js";
4
+ import type { DomSnapshotNode } from "../web/domSnapshot.js";
5
+ export type ScreenshotImages = {
6
+ baseline: DecodedImage;
7
+ candidate: DecodedImage;
8
+ };
9
+ export type SnapshotComparison = {
10
+ equal: boolean;
11
+ rendered: string;
12
+ pixel?: PixelDiffResult;
13
+ screenshotImages?: ScreenshotImages;
14
+ screenshotImagesByMarker?: Record<string, ScreenshotImages>;
15
+ };
16
+ export type CompareOptions = {
17
+ comparison: ComparisonConfig;
18
+ target?: CaptureTarget;
19
+ baselineScreenshot?: Buffer;
20
+ candidateScreenshot?: Buffer;
21
+ baselineScreenshots?: Record<string, Buffer>;
22
+ candidateScreenshots?: Record<string, Buffer>;
23
+ };
24
+ export declare function coerceSnapshot<T extends CanonicalSnapshot>(value: unknown): T;
25
+ export declare function compareScreenshot(baseline: CanonicalWeb, candidate: CanonicalWeb, options: CompareOptions, tolerance?: PixelTolerance): ScreenshotPairComparison;
26
+ export type ScreenshotPairComparison = {
27
+ line?: string;
28
+ pixel?: PixelDiffResult;
29
+ images?: ScreenshotImages;
30
+ };
31
+ export declare function compareScreenshotPair(baselineDigest: string | undefined, candidateDigest: string | undefined, baselineImage: Buffer | undefined, candidateImage: Buffer | undefined, tolerance: PixelTolerance, label: string): ScreenshotPairComparison;
32
+ export declare function compareStream(name: string, baseline: unknown, candidate: unknown, options: CompareOptions): string | undefined;
33
+ export declare function a11yToDomNode(value: unknown): DomSnapshotNode;
34
+ export declare function renderPerformanceChange(change: {
35
+ path: string;
36
+ before: unknown;
37
+ after: unknown;
38
+ }): string;
39
+ export declare function asNodes(value: unknown): DomSnapshotNode[];
40
+ export declare function digestOf(value: unknown): string | undefined;
41
+ export declare function textOf(value: unknown): string;
42
+ export declare function render(value: unknown): string;
@@ -0,0 +1,115 @@
1
+ import { isRecord } from "../guards.js";
2
+ import { structuralChanges } from "../diff/structural.js";
3
+ import { diffWords, renderInline } from "../diff/text.js";
4
+ import { decodePng, diffImages } from "../diff/pixel.js";
5
+ // One centralized, invariant-documented narrowing: callers reach this only after
6
+ // `snapshotKind` has confirmed the discriminant, so the assertion is safe. The
7
+ // target type is inferred from the comparer's parameter, so there are no scattered
8
+ // `as Record<string, unknown>` casts at the call sites.
9
+ export function coerceSnapshot(value) {
10
+ return value;
11
+ }
12
+ export function compareScreenshot(baseline, candidate, options,
13
+ // Per-target tolerance override (web targets); defaults to the global one.
14
+ tolerance = options.comparison.pixelTolerance) {
15
+ return compareScreenshotPair(digestOf(baseline.screenshot), digestOf(candidate.screenshot), options.baselineScreenshot, options.candidateScreenshot, tolerance, "screenshot");
16
+ }
17
+ // The digest-then-pixels comparison shared by every screenshot-carrying kind:
18
+ // equal digests are free, differing digests fall back to a tolerant pixel diff
19
+ // when the actual images are available. `label` names the screenshot in the
20
+ // rendered line ("screenshot" for web, "screenshot[marker]" for game).
21
+ export function compareScreenshotPair(baselineDigest, candidateDigest, baselineImage, candidateImage, tolerance, label) {
22
+ if (!baselineDigest && !candidateDigest) {
23
+ return {};
24
+ }
25
+ if (Boolean(baselineDigest) !== Boolean(candidateDigest)) {
26
+ return { line: candidateDigest ? `+ ${label} added` : `- ${label} removed` };
27
+ }
28
+ if (baselineDigest === candidateDigest) {
29
+ return {};
30
+ }
31
+ if (baselineImage && candidateImage) {
32
+ const images = {
33
+ baseline: decodePng(baselineImage),
34
+ candidate: decodePng(candidateImage)
35
+ };
36
+ const pixel = diffImages(images.baseline, images.candidate, tolerance);
37
+ if (pixel.withinTolerance) {
38
+ return { pixel, images };
39
+ }
40
+ const summary = pixel.dimensionsMatch
41
+ ? `${pixel.changedPixels}/${pixel.totalPixels} pixels changed (${(pixel.changedRatio * 100).toFixed(2)}%)`
42
+ : `dimensions changed to ${pixel.width}x${pixel.height}`;
43
+ return { line: `~ ${label}: ${summary}`, pixel, images };
44
+ }
45
+ return { line: `~ ${label} changed (no baseline image available for pixel diff)` };
46
+ }
47
+ export function compareStream(name, baseline, candidate, options) {
48
+ const structural = structuralChanges(baseline, candidate, {
49
+ numericTolerance: options.comparison.numericTolerance
50
+ });
51
+ if (structural.length === 0) {
52
+ return undefined;
53
+ }
54
+ const baselineText = textOf(baseline);
55
+ const candidateText = textOf(candidate);
56
+ if (baselineText !== candidateText) {
57
+ return `~ ${name}: ${renderInline(diffWords(baselineText, candidateText))}`;
58
+ }
59
+ // Text matched but styling/segments differ.
60
+ return `~ ${name} styling changed`;
61
+ }
62
+ // Desktop accessibility trees reuse the structural DOM tree diff: each a11y
63
+ // node maps to an element whose tag is the role and whose attributes carry the
64
+ // name/value/description/state. Alignment is by role, so a renamed control
65
+ // surfaces as a changed @name rather than a remove + add.
66
+ export function a11yToDomNode(value) {
67
+ const node = isRecord(value) ? value : {};
68
+ const attributes = {};
69
+ if (typeof node.name === "string") {
70
+ attributes.name = node.name;
71
+ }
72
+ if (typeof node.value === "string") {
73
+ attributes.value = node.value;
74
+ }
75
+ if (typeof node.description === "string") {
76
+ attributes.description = node.description;
77
+ }
78
+ if (Array.isArray(node.state) && node.state.length > 0) {
79
+ attributes.state = node.state.filter((flag) => typeof flag === "string").join(",");
80
+ }
81
+ return {
82
+ type: "element",
83
+ tagName: typeof node.role === "string" ? node.role : "unknown",
84
+ attributes,
85
+ children: Array.isArray(node.children) ? node.children.map(a11yToDomNode) : []
86
+ };
87
+ }
88
+ export function renderPerformanceChange(change) {
89
+ const metric = change.path.replace(/^\$\.(metrics\.)?/, "");
90
+ if (typeof change.before === "number" && typeof change.after === "number") {
91
+ const delta = percentDelta(change.before, change.after);
92
+ return `~ ${metric}: ${change.before} → ${change.after}${delta}`;
93
+ }
94
+ return `~ ${metric}: ${render(change.before)} → ${render(change.after)}`;
95
+ }
96
+ function percentDelta(before, after) {
97
+ if (before === 0) {
98
+ return after === 0 ? "" : " (new)";
99
+ }
100
+ const change = ((after - before) / Math.abs(before)) * 100;
101
+ const sign = change >= 0 ? "+" : "";
102
+ return ` (${sign}${change.toFixed(1)}%)`;
103
+ }
104
+ export function asNodes(value) {
105
+ return Array.isArray(value) ? value : [];
106
+ }
107
+ export function digestOf(value) {
108
+ return isRecord(value) && typeof value.sha256 === "string" ? value.sha256 : undefined;
109
+ }
110
+ export function textOf(value) {
111
+ return isRecord(value) && typeof value.text === "string" ? value.text : "";
112
+ }
113
+ export function render(value) {
114
+ return typeof value === "string" ? `"${value}"` : JSON.stringify(value);
115
+ }
@@ -0,0 +1,3 @@
1
+ import { type CompareOptions, type SnapshotComparison } from "./compare/shared.js";
2
+ export type { CompareOptions, ScreenshotImages, SnapshotComparison } from "./compare/shared.js";
3
+ export declare function compareSnapshots(baseline: unknown, candidate: unknown, options: CompareOptions): SnapshotComparison;
@@ -0,0 +1,33 @@
1
+ import { captureTypes } from "./captures/registry.js";
2
+ import { render } from "./compare/shared.js";
3
+ import { structuralChanges } from "./diff/structural.js";
4
+ import { snapshotKind } from "./snapshot.js";
5
+ // Top-level, snapshot-aware comparison used by the runner. Narrows both sides to
6
+ // the canonical snapshot union once via `snapshotKind`, then routes to the
7
+ // per-kind comparer in the registry — producing a readable, semantic diff
8
+ // instead of a raw JSON line diff.
9
+ export function compareSnapshots(baseline, candidate, options) {
10
+ const baselineKind = snapshotKind(baseline);
11
+ const candidateKind = snapshotKind(candidate);
12
+ // Both sides are the same known capture type → typed, per-kind comparison.
13
+ if (baselineKind && baselineKind === candidateKind) {
14
+ return captureTypes[baselineKind].compare(baseline, candidate, options);
15
+ }
16
+ // A target that switched capture type (e.g. terminal → web) is a meaningful change
17
+ // in its own right, not a noisy field-by-field structural diff.
18
+ if (baselineKind && candidateKind && baselineKind !== candidateKind) {
19
+ return { equal: false, rendered: `~ kind: ${baselineKind} → ${candidateKind}` };
20
+ }
21
+ return compareGeneric(baseline, candidate, options);
22
+ }
23
+ function compareGeneric(baseline, candidate, options) {
24
+ const changes = structuralChanges(baseline, candidate, {
25
+ numericTolerance: options.comparison.numericTolerance
26
+ });
27
+ return {
28
+ equal: changes.length === 0,
29
+ rendered: changes
30
+ .map((change) => `~ ${change.path}: ${render(change.before)} → ${render(change.after)}`)
31
+ .join("\n")
32
+ };
33
+ }
@@ -0,0 +1,146 @@
1
+ import type { NumericTolerance } from "./diff/numeric.js";
2
+ import type { PixelTolerance } from "./diff/pixel.js";
3
+ export type MaskRule = {
4
+ name: string;
5
+ pattern: string;
6
+ replacement: string;
7
+ };
8
+ export type CaptureTarget = {
9
+ kind: "api";
10
+ name: string;
11
+ url: string;
12
+ method?: string;
13
+ headers?: Record<string, string>;
14
+ body?: string;
15
+ query?: string;
16
+ variables?: Record<string, unknown>;
17
+ includeHeaders?: string[];
18
+ timeoutMs?: number;
19
+ } | {
20
+ kind: "terminal";
21
+ name: string;
22
+ command: string;
23
+ cwd?: string;
24
+ timeoutMs?: number;
25
+ } | {
26
+ kind: "check";
27
+ name: string;
28
+ tool: string;
29
+ command?: string;
30
+ output?: string;
31
+ cwd?: string;
32
+ timeoutMs?: number;
33
+ } | {
34
+ kind: "web";
35
+ name: string;
36
+ driver?: "fetch" | "playwright";
37
+ url?: string;
38
+ html?: string;
39
+ screenshotFile?: string;
40
+ screenshotMode?: "strict" | "advisory";
41
+ pixelTolerance?: {
42
+ maxChangedRatio?: number;
43
+ perChannelThreshold?: number;
44
+ };
45
+ accessibility?: boolean;
46
+ screenshot?: boolean;
47
+ browser?: {
48
+ channel?: string;
49
+ executablePath?: string;
50
+ };
51
+ viewport?: {
52
+ width: number;
53
+ height: number;
54
+ };
55
+ timeoutMs?: number;
56
+ } | {
57
+ kind: "performance";
58
+ name: string;
59
+ tool?: string;
60
+ script?: string;
61
+ command?: string;
62
+ summary?: string;
63
+ metrics?: string[];
64
+ k6Path?: string;
65
+ env?: Record<string, string>;
66
+ cwd?: string;
67
+ timeoutMs?: number;
68
+ } | {
69
+ kind: "game";
70
+ name: string;
71
+ engine: "godot";
72
+ project: string;
73
+ walkthrough: string;
74
+ mode?: "semantic" | "visual";
75
+ enginePath?: string;
76
+ seed?: number;
77
+ physicsFps?: number;
78
+ pixelTolerance?: {
79
+ maxChangedRatio?: number;
80
+ perChannelThreshold?: number;
81
+ };
82
+ screenshotMode?: "advisory" | "strict";
83
+ markers?: Record<string, {
84
+ pixelTolerance?: {
85
+ maxChangedRatio?: number;
86
+ perChannelThreshold?: number;
87
+ };
88
+ }>;
89
+ timeoutMs?: number;
90
+ } | {
91
+ kind: "desktop";
92
+ name: string;
93
+ driver?: "macos-ax" | "ocr";
94
+ app?: string;
95
+ tree?: string;
96
+ command?: string;
97
+ maxDepth?: number;
98
+ ocrFallback?: boolean;
99
+ screenshot?: string;
100
+ screenshotCommand?: string;
101
+ ocrCommand?: string;
102
+ cwd?: string;
103
+ timeoutMs?: number;
104
+ };
105
+ export type LifecycleConfig = {
106
+ setup: string[];
107
+ start: string[];
108
+ wait: {
109
+ command?: string;
110
+ url?: string;
111
+ timeoutMs: number;
112
+ };
113
+ capture: CaptureTarget[];
114
+ teardown: string[];
115
+ };
116
+ export type NormalizationConfig = {
117
+ ansi: "semantic" | "strip";
118
+ masks: MaskRule[];
119
+ };
120
+ export type ComparisonConfig = {
121
+ numericTolerance: NumericTolerance;
122
+ pixelTolerance: PixelTolerance;
123
+ };
124
+ export type DungbeetleConfig = {
125
+ version: 1;
126
+ project: {
127
+ name: string;
128
+ };
129
+ baselinesDir: string;
130
+ artifactsDir: string;
131
+ lifecycle: LifecycleConfig;
132
+ normalization: NormalizationConfig;
133
+ comparison: ComparisonConfig;
134
+ };
135
+ export declare const CONFIG_FILE_NAME = "dungbeetle.config.json";
136
+ export declare const defaultMaskRules: MaskRule[];
137
+ export declare function createDefaultConfig(projectName?: string): DungbeetleConfig;
138
+ export declare function findConfigPath(cwd?: string): Promise<string | null>;
139
+ export declare function loadConfig(configPath?: string, cwd?: string): Promise<DungbeetleConfig>;
140
+ export declare class ConfigValidationError extends Error {
141
+ readonly issues: string[];
142
+ readonly configPath?: string | undefined;
143
+ constructor(issues: string[], configPath?: string | undefined);
144
+ }
145
+ export declare function validateConfig(config: DungbeetleConfig, configPath?: string): void;
146
+ export declare function writeDefaultConfig(outputPath?: string, projectName?: string, targets?: CaptureTarget[]): Promise<string>;