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/index.js ADDED
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: FSL-1.1-ALv2
3
+ // Dungbeetle CLI — Copyright 2026 DungbeetleDev. See LICENSE.
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { createRequire } from "node:module";
7
+ import { Command } from "commander";
8
+ import { detectLaravelTargets } from "./check/laravel.js";
9
+ import { createDefaultConfig, loadConfig, writeDefaultConfig } from "./config.js";
10
+ import { stableStringify } from "./json.js";
11
+ import { runDoctor } from "./doctor.js";
12
+ import { renderConsoleReport, writeHtmlReport, writeJsonReport } from "./reporters.js";
13
+ import { buildScreenshotComparison, screenshotBuffer, testBaselines, updateBaselines } from "./runner.js";
14
+ import { startManagedLifecycle } from "./lifecycle.js";
15
+ import { Spinner } from "./tty.js";
16
+ import { pushAnonReport, pushBaselines, pushReport } from "./cloud.js";
17
+ import { baselinePathForTarget } from "./baselines.js";
18
+ import { captureTarget } from "./capture.js";
19
+ import { compareSnapshots } from "./compare.js";
20
+ import { canonicalizeSnapshot } from "./snapshot.js";
21
+ import { BRAND_NAME, BRAND_SUBTITLE } from "./brand.js";
22
+ import { stripControlChars } from "./terminal/ansi.js";
23
+ // Read the version from package.json so `dungbeetle --version` always matches the
24
+ // published package. Resolves to ../package.json both from dist/ (built) and
25
+ // from src/ (tsx dev), since package.json sits one level above either.
26
+ const pkg = createRequire(import.meta.url)("../package.json");
27
+ const program = new Command();
28
+ program.name("dungbeetle").description(BRAND_SUBTITLE).version(pkg.version);
29
+ program
30
+ .command("init")
31
+ .description(`Scaffold a ${BRAND_NAME} config file.`)
32
+ .option("-o, --out <path>", "Config path to write", "dungbeetle.config.json")
33
+ .option("--project-name <name>", "Project name for the config")
34
+ .option("-f, --force", "Overwrite an existing config", false)
35
+ .action(async (options) => {
36
+ const outputPath = path.resolve(options.out);
37
+ if (!options.force && (await exists(outputPath))) {
38
+ throw new Error(`Config already exists at ${outputPath}; use --force to overwrite.`);
39
+ }
40
+ const laravelTargets = await detectLaravelTargets(path.dirname(outputPath));
41
+ const writtenPath = await writeDefaultConfig(outputPath, options.projectName ?? path.basename(process.cwd()), laravelTargets ?? []);
42
+ console.log(`Wrote ${writtenPath}`);
43
+ if (laravelTargets) {
44
+ console.log(`Detected a Laravel app — scaffolded ${laravelTargets.length} check targets: ` +
45
+ `${laravelTargets.map((target) => target.name).join(", ")}.`);
46
+ }
47
+ });
48
+ program
49
+ .command("update")
50
+ .description("Capture configured targets and update local baselines.")
51
+ .option("--config <path>", "Config path")
52
+ .option("--cwd <path>", "Project directory", process.cwd())
53
+ .option("--json <path>", "JSON report path")
54
+ .option("--html <path>", "HTML report path")
55
+ .option("--target <name...>", "Only update selected target names")
56
+ .action(async (options) => {
57
+ const cwd = path.resolve(options.cwd);
58
+ const config = await loadConfig(options.config, cwd);
59
+ const report = await runWithSpinner(captureLabel(config, options.target, "Updating baselines for"), () => updateBaselines({ config, cwd, targets: options.target }));
60
+ await emitRunnerReport(report, options.json ?? path.join(config.artifactsDir, "update-report.json"), options.html ?? path.join(config.artifactsDir, "update-report.html"), cwd);
61
+ });
62
+ program
63
+ .command("test")
64
+ .description("Capture configured targets and compare them to local baselines.")
65
+ .option("--config <path>", "Config path")
66
+ .option("--cwd <path>", "Project directory", process.cwd())
67
+ .option("--json <path>", "JSON report path")
68
+ .option("--html <path>", "HTML report path")
69
+ .option("--target <name...>", "Only test selected target names")
70
+ .option("--with-snapshots", "Include candidate snapshots in the report (for cloud promote)", false)
71
+ .action(async (options) => {
72
+ const cwd = path.resolve(options.cwd);
73
+ const config = await loadConfig(options.config, cwd);
74
+ const report = await runWithSpinner(captureLabel(config, options.target, "Testing"), () => testBaselines({
75
+ config,
76
+ cwd,
77
+ targets: options.target,
78
+ includeSnapshots: options.withSnapshots
79
+ }));
80
+ await emitRunnerReport(report, options.json ?? path.join(config.artifactsDir, "test-report.json"), options.html ?? path.join(config.artifactsDir, "test-report.html"), cwd);
81
+ });
82
+ program
83
+ .command("ci")
84
+ .description("Same as `test`, with CI-friendly JSON output.")
85
+ .option("--config <path>", "Config path")
86
+ .option("--cwd <path>", "Project directory", process.cwd())
87
+ .option("--json <path>", "JSON report path")
88
+ .option("--html <path>", "HTML report path")
89
+ .option("--quiet", "Suppress detailed console report", false)
90
+ .option("--json-only", "Only write the JSON report and suppress HTML/console output", false)
91
+ .option("--no-html-report", "Do not write an HTML report")
92
+ .option("--target <name...>", "Only test selected target names")
93
+ .option("--with-snapshots", "Include candidate snapshots in the report (for cloud promote)", false)
94
+ .action(async (options) => {
95
+ const cwd = path.resolve(options.cwd);
96
+ const config = await loadConfig(options.config, cwd);
97
+ const report = await runWithSpinner(captureLabel(config, options.target, "Testing"), () => testBaselines({
98
+ config,
99
+ cwd,
100
+ targets: options.target,
101
+ includeSnapshots: options.withSnapshots
102
+ }));
103
+ await emitRunnerReport(report, options.json ?? path.join(config.artifactsDir, "ci-report.json"), shouldWriteHtmlReport(options)
104
+ ? (options.html ?? path.join(config.artifactsDir, "ci-report.html"))
105
+ : undefined, cwd, {
106
+ quiet: options.quiet || options.jsonOnly
107
+ });
108
+ });
109
+ program
110
+ .command("doctor")
111
+ .description(`Check ${BRAND_NAME} config, paths, targets, and optional browser setup.`)
112
+ .option("--config <path>", "Config path")
113
+ .option("--cwd <path>", "Project directory", process.cwd())
114
+ .option("--json <path>", "JSON report path")
115
+ .action(async (options) => {
116
+ const cwd = path.resolve(options.cwd);
117
+ const config = await loadConfig(options.config, cwd);
118
+ const report = await runDoctor({
119
+ config,
120
+ cwd
121
+ });
122
+ if (options.json) {
123
+ await writeJson(path.resolve(cwd, options.json), report);
124
+ }
125
+ for (const check of report.checks) {
126
+ console.log(`${check.severity.toUpperCase()} ${check.name}${check.target ? `:${check.target}` : ""} - ${check.message}`);
127
+ }
128
+ if (!report.passed) {
129
+ process.exitCode = 1;
130
+ }
131
+ });
132
+ program
133
+ .command("flake")
134
+ .description("Capture targets repeatedly and report run-to-run divergence — no baselines involved.")
135
+ .option("--config <path>", "Config path")
136
+ .option("--cwd <path>", "Project directory", process.cwd())
137
+ .option("--target <name...>", "Only check selected target names")
138
+ .option("--repeat <n>", "Capture repetitions per target", "5")
139
+ .action(async (options) => {
140
+ const cwd = path.resolve(options.cwd);
141
+ const config = await loadConfig(options.config, cwd);
142
+ const repeat = Math.max(2, Number.parseInt(options.repeat, 10) || 5);
143
+ const targets = config.lifecycle.capture.filter((target) => !options.target || options.target.includes(target.name));
144
+ if (targets.length === 0) {
145
+ throw new Error("No matching targets to check.");
146
+ }
147
+ const managed = await startManagedLifecycle(config.lifecycle, cwd);
148
+ let flaky = false;
149
+ try {
150
+ for (const target of targets) {
151
+ // Force visual divergence to count for game targets: flake detection
152
+ // wants EVERY source of instability visible, advisory or not.
153
+ const strictTarget = target.kind === "game" ? { ...target, screenshotMode: "strict" } : target;
154
+ const runs = [];
155
+ const canonicals = [];
156
+ for (let i = 0; i < repeat; i += 1) {
157
+ const snapshot = await captureTarget(strictTarget, { config, cwd });
158
+ const canonical = canonicalizeSnapshot(snapshot);
159
+ canonicals.push(canonical);
160
+ runs.push(stableStringify(canonical));
161
+ }
162
+ const diverged = runs
163
+ .map((run, index) => ({ run, index }))
164
+ .filter(({ run }) => run !== runs[0]);
165
+ if (diverged.length === 0) {
166
+ console.log(`✅ ${target.kind}:${target.name} — ${repeat}/${repeat} runs identical`);
167
+ continue;
168
+ }
169
+ flaky = true;
170
+ console.log(`❌ ${target.kind}:${target.name} — ${diverged.length}/${repeat} runs diverged from run 1`);
171
+ for (const { index } of diverged) {
172
+ const comparison = compareSnapshots(canonicals[0], canonicals[index], {
173
+ comparison: config.comparison,
174
+ target: strictTarget
175
+ });
176
+ console.log(` run ${index + 1}:`);
177
+ for (const line of comparison.rendered.split("\n").filter(Boolean)) {
178
+ console.log(` ${line}`);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ finally {
184
+ await managed.stop();
185
+ }
186
+ if (flaky) {
187
+ process.exitCode = 1;
188
+ }
189
+ });
190
+ program
191
+ .command("push")
192
+ .description(`Upload a run report to a ${BRAND_NAME} cloud server.`)
193
+ .option("--report <path>", "Path to a JSON report produced by test/ci")
194
+ .option("--server <url>", "Server base URL", process.env.DUNGBEETLE_SERVER_URL)
195
+ .option("--client-id <id>", "Repository client id", process.env.DUNGBEETLE_CLIENT_ID)
196
+ .option("--client-secret <secret>", "Repository client secret (prefer DUNGBEETLE_CLIENT_SECRET — flags leak into shell history and process lists)", process.env.DUNGBEETLE_CLIENT_SECRET)
197
+ .option("--branch <name>", "Branch label to attach to the run")
198
+ .option("--commit <sha>", "Commit SHA to attach to the run")
199
+ .action(async (options) => {
200
+ if (!options.server) {
201
+ throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
202
+ }
203
+ const credentials = requireClientCredentials(options);
204
+ if (!options.report) {
205
+ throw new Error("Missing report path; pass --report <path>.");
206
+ }
207
+ const spinner = new Spinner();
208
+ spinner.start("Uploading run…");
209
+ let run;
210
+ try {
211
+ run = await pushReport({
212
+ serverUrl: options.server,
213
+ ...credentials,
214
+ reportPath: path.resolve(options.report),
215
+ branch: options.branch,
216
+ commit: options.commit
217
+ });
218
+ }
219
+ finally {
220
+ spinner.stop();
221
+ }
222
+ console.log(`Uploaded run ${run.id} (${run.status})`);
223
+ console.log(safeUrl(run.url));
224
+ });
225
+ program
226
+ .command("push-baselines")
227
+ .description(`Upload local baselines to a ${BRAND_NAME} cloud server (versioned).`)
228
+ .option("--config <path>", "Config path")
229
+ .option("--cwd <path>", "Project directory", process.cwd())
230
+ .option("--server <url>", "Server base URL", process.env.DUNGBEETLE_SERVER_URL)
231
+ .option("--client-id <id>", "Repository client id", process.env.DUNGBEETLE_CLIENT_ID)
232
+ .option("--client-secret <secret>", "Repository client secret (prefer DUNGBEETLE_CLIENT_SECRET — flags leak into shell history and process lists)", process.env.DUNGBEETLE_CLIENT_SECRET)
233
+ .option("--target <name...>", "Only upload selected target names")
234
+ .action(async (options) => {
235
+ if (!options.server) {
236
+ throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
237
+ }
238
+ const credentials = requireClientCredentials(options);
239
+ const cwd = path.resolve(options.cwd);
240
+ const config = await loadConfig(options.config, cwd);
241
+ const selected = new Set(options.target ?? []);
242
+ const baselines = config.lifecycle.capture
243
+ .filter((target) => selected.size === 0 || selected.has(target.name))
244
+ .map((target) => {
245
+ const snapshotPath = baselinePathForTarget(config.baselinesDir, target.name, cwd);
246
+ return {
247
+ target: target.name,
248
+ kind: target.kind,
249
+ snapshotPath,
250
+ screenshotPath: snapshotPath.replace(/\.json$/, ".png")
251
+ };
252
+ });
253
+ if (baselines.length === 0) {
254
+ throw new Error("No matching capture targets to upload.");
255
+ }
256
+ const spinner = new Spinner();
257
+ spinner.start(`Uploading ${baselines.length} baseline${baselines.length === 1 ? "" : "s"}…`);
258
+ let uploaded;
259
+ try {
260
+ uploaded = await pushBaselines({
261
+ serverUrl: options.server,
262
+ ...credentials,
263
+ baselines
264
+ });
265
+ }
266
+ finally {
267
+ spinner.stop();
268
+ }
269
+ for (const item of uploaded) {
270
+ const note = item.deduped ? "unchanged" : "new version";
271
+ console.log(`${item.target} → v${item.version} (${note})`);
272
+ }
273
+ });
274
+ program
275
+ .command("anon")
276
+ .description(`Capture a target and share it on a ${BRAND_NAME} cloud server — no account needed.`)
277
+ .argument("[url]", "URL to capture (web snapshot)")
278
+ .option("--compare <url>", "Capture a second URL and share the semantic diff between the two")
279
+ .option("--screenshot", "Also capture full-page screenshots (needs a Chrome/Chromium)", false)
280
+ .option("--cmd <command>", "Capture a terminal command's output instead of a URL")
281
+ .option("--name <name>", "Label for the capture")
282
+ .option("--server <url>", "Server base URL", process.env.DUNGBEETLE_SERVER_URL)
283
+ .action(async (url, options) => {
284
+ if (!options.server) {
285
+ throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
286
+ }
287
+ if (!url && !options.cmd) {
288
+ throw new Error('Provide a URL to capture, or --cmd "<command>" for a terminal capture.');
289
+ }
290
+ if (url && options.cmd) {
291
+ throw new Error("Pass either a URL or --cmd, not both.");
292
+ }
293
+ if (options.compare && !url) {
294
+ throw new Error("--compare needs two URLs: pass the first as the argument.");
295
+ }
296
+ if (options.screenshot && !url) {
297
+ throw new Error("--screenshot captures a page, so it needs a URL (not --cmd).");
298
+ }
299
+ const name = options.name ?? (url ? safeHostname(url) : "command");
300
+ if (options.cmd) {
301
+ // The command's output is published to a public share — don't let the
302
+ // spawned shell inherit cloud credentials it could echo.
303
+ delete process.env.DUNGBEETLE_CLIENT_ID;
304
+ delete process.env.DUNGBEETLE_CLIENT_SECRET;
305
+ }
306
+ const target = options.cmd
307
+ ? { kind: "terminal", name, command: options.cmd }
308
+ : { kind: "web", name, url: url };
309
+ const config = createDefaultConfig(name);
310
+ // Capture a URL for the anon flow. With --screenshot the capture goes
311
+ // through the Playwright driver (screenshots need a real browser); if that
312
+ // fails — typically no Chrome — the trial degrades to the fetch driver with
313
+ // a warning instead of dying, so the one-liner stays reliable.
314
+ let screenshotWarned = false;
315
+ const captureUrl = async (targetUrl) => {
316
+ if (options.screenshot) {
317
+ const withShots = {
318
+ kind: "web",
319
+ name,
320
+ url: targetUrl,
321
+ driver: "playwright",
322
+ screenshot: true,
323
+ // The env var wins inside the driver; only suggest the Chrome channel
324
+ // when no explicit executable is configured.
325
+ ...(process.env.DUNGBEETLE_CHROMIUM_EXECUTABLE_PATH
326
+ ? {}
327
+ : { browser: { channel: "chrome" } })
328
+ };
329
+ try {
330
+ return await captureTarget(withShots, { config, cwd: process.cwd() });
331
+ }
332
+ catch (error) {
333
+ if (!screenshotWarned) {
334
+ screenshotWarned = true;
335
+ console.error(`warning: screenshot capture unavailable (${error instanceof Error ? error.message : String(error)}); continuing without screenshots.`);
336
+ }
337
+ }
338
+ }
339
+ return captureTarget({ kind: "web", name, url: targetUrl }, { config, cwd: process.cwd() });
340
+ };
341
+ const spinner = new Spinner();
342
+ spinner.start(options.compare ? "Capturing, comparing, uploading…" : "Capturing and uploading…");
343
+ let result;
344
+ let equal;
345
+ try {
346
+ const startedAt = new Date().toISOString();
347
+ const snapshot = url
348
+ ? await captureUrl(url)
349
+ : await captureTarget(target, { config, cwd: process.cwd() });
350
+ const canonical = canonicalizeSnapshot(snapshot);
351
+ const baselineShot = screenshotBuffer(snapshot);
352
+ // With --compare the first capture is the baseline and the second the
353
+ // candidate: the shared page leads with the real semantic diff, exactly
354
+ // what `test` would report in CI. Without it the single capture is
355
+ // "missing" (nothing to compare against).
356
+ let results;
357
+ if (options.compare) {
358
+ const candidateArtifact = await captureUrl(options.compare);
359
+ const candidate = canonicalizeSnapshot(candidateArtifact);
360
+ const candidateShot = screenshotBuffer(candidateArtifact);
361
+ const comparison = compareSnapshots(canonical, candidate, {
362
+ comparison: config.comparison,
363
+ baselineScreenshot: baselineShot,
364
+ candidateScreenshot: candidateShot
365
+ });
366
+ equal = comparison.equal;
367
+ const screenshot = candidateShot
368
+ ? buildScreenshotComparison(baselineShot, candidateShot, config.comparison.pixelTolerance, true, comparison.screenshotImages)
369
+ : undefined;
370
+ results = [
371
+ {
372
+ name,
373
+ kind: target.kind,
374
+ status: comparison.equal ? "passed" : "failed",
375
+ ...(comparison.rendered ? { diff: comparison.rendered } : {}),
376
+ ...(screenshot ? { screenshot } : {}),
377
+ snapshot: stableStringify(candidate)
378
+ }
379
+ ];
380
+ }
381
+ else {
382
+ results = [
383
+ {
384
+ name,
385
+ kind: target.kind,
386
+ status: "missing",
387
+ ...(baselineShot ? { screenshot: { candidate: baselineShot.toString("base64") } } : {}),
388
+ snapshot: stableStringify(canonical)
389
+ }
390
+ ];
391
+ }
392
+ const report = {
393
+ mode: "test",
394
+ passed: equal ?? true,
395
+ project: name,
396
+ startedAt,
397
+ finishedAt: new Date().toISOString(),
398
+ results
399
+ };
400
+ result = await pushAnonReport({ serverUrl: options.server, report });
401
+ }
402
+ finally {
403
+ spinner.stop();
404
+ }
405
+ if (equal !== undefined) {
406
+ console.log(equal ? "No changes between the two captures." : "Changes found — see the diff:");
407
+ }
408
+ console.log(`Shared anonymously → ${safeUrl(result.url)}`);
409
+ console.log("Public and read-only; deleted after 24 hours.");
410
+ });
411
+ // A server can be pointed anywhere, so treat the URL it hands back as untrusted:
412
+ // strip control characters (terminal-escape injection) and only echo it when it
413
+ // parses as an http(s) URL.
414
+ function safeUrl(url) {
415
+ const cleaned = stripControlChars(url);
416
+ try {
417
+ const parsed = new URL(cleaned);
418
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
419
+ return cleaned;
420
+ }
421
+ }
422
+ catch {
423
+ // fall through
424
+ }
425
+ return "(server returned an invalid URL)";
426
+ }
427
+ // Best-effort label for a web capture from its URL host; falls back if the URL
428
+ // is malformed (captureTarget will surface the real error).
429
+ function safeHostname(url) {
430
+ try {
431
+ return new URL(url).hostname;
432
+ }
433
+ catch {
434
+ return "web";
435
+ }
436
+ }
437
+ program.parseAsync(process.argv).catch((error) => {
438
+ console.error(error instanceof Error ? error.message : String(error));
439
+ process.exitCode = 1;
440
+ });
441
+ // Both upload commands need the repository's client credentials; resolve them
442
+ // from flags or the DUNGBEETLE_CLIENT_ID / DUNGBEETLE_CLIENT_SECRET environment vars.
443
+ function requireClientCredentials(options) {
444
+ if (!options.clientId) {
445
+ throw new Error("Missing client id; pass --client-id or set DUNGBEETLE_CLIENT_ID.");
446
+ }
447
+ if (!options.clientSecret) {
448
+ throw new Error("Missing client secret; pass --client-secret or set DUNGBEETLE_CLIENT_SECRET.");
449
+ }
450
+ return { clientId: options.clientId, clientSecret: options.clientSecret };
451
+ }
452
+ async function exists(filePath) {
453
+ try {
454
+ await readFile(filePath);
455
+ return true;
456
+ }
457
+ catch {
458
+ return false;
459
+ }
460
+ }
461
+ async function writeJson(filePath, value) {
462
+ await writeText(filePath, `${stableStringify(value)}\n`);
463
+ }
464
+ async function writeText(filePath, value) {
465
+ const resolvedPath = path.resolve(filePath);
466
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
467
+ await writeFile(resolvedPath, value, "utf8");
468
+ }
469
+ function captureLabel(config, targets, verb) {
470
+ const all = config.lifecycle?.capture?.map((target) => target.name) ?? [];
471
+ const selected = targets && targets.length > 0 ? targets : all;
472
+ const count = selected.length;
473
+ return `${verb} ${count} target${count === 1 ? "" : "s"}…`;
474
+ }
475
+ async function runWithSpinner(label, run) {
476
+ const spinner = new Spinner();
477
+ spinner.start(label);
478
+ try {
479
+ return await run();
480
+ }
481
+ finally {
482
+ spinner.stop();
483
+ }
484
+ }
485
+ async function emitRunnerReport(report, reportPath, htmlReportPath, cwd, options = {}) {
486
+ const resolvedReportPath = path.resolve(cwd, reportPath);
487
+ await writeJsonReport(resolvedReportPath, report);
488
+ if (htmlReportPath) {
489
+ await writeHtmlReport(path.resolve(cwd, htmlReportPath), report);
490
+ }
491
+ if (!options.quiet) {
492
+ console.log(renderConsoleReport(report));
493
+ console.log(`Wrote JSON report at ${resolvedReportPath}`);
494
+ if (htmlReportPath) {
495
+ console.log(`Wrote HTML report at ${path.resolve(cwd, htmlReportPath)}`);
496
+ }
497
+ }
498
+ if (!report.passed) {
499
+ process.exitCode = 1;
500
+ }
501
+ }
502
+ function shouldWriteHtmlReport(options) {
503
+ return options.jsonOnly ? false : options.htmlReport !== false;
504
+ }
package/dist/json.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function parseJsonFile(raw: string, filePath: string, onError?: (message: string) => never): unknown;
2
+ export declare function stableStringify(value: unknown): string;
package/dist/json.js ADDED
@@ -0,0 +1,40 @@
1
+ // Parse JSON that came from a file (or other named source), attaching the file
2
+ // path to a syntax error so callers get a clear "Could not parse JSON at <path>"
3
+ // instead of a bare `SyntaxError` with no context. An optional `onError` hook
4
+ // lets a caller throw its own error type (e.g. a domain ValidationError) while
5
+ // still receiving the formatted message.
6
+ export function parseJsonFile(raw, filePath, onError) {
7
+ try {
8
+ return JSON.parse(raw);
9
+ }
10
+ catch (error) {
11
+ const detail = error instanceof Error ? error.message : String(error);
12
+ const message = `Could not parse JSON at ${filePath}: ${detail}`;
13
+ if (onError) {
14
+ onError(message);
15
+ }
16
+ throw new Error(message);
17
+ }
18
+ }
19
+ // Deterministic JSON serialization: object keys are sorted recursively so a
20
+ // canonicalized snapshot (or report) serializes byte-identically across runs —
21
+ // the basis for stable baselines and dedupe digests.
22
+ export function stableStringify(value) {
23
+ return JSON.stringify(sortKeys(value), null, 2);
24
+ }
25
+ function sortKeys(value) {
26
+ if (Array.isArray(value)) {
27
+ return value.map(sortKeys);
28
+ }
29
+ if (value && typeof value === "object") {
30
+ return Object.fromEntries(Object.entries(value)
31
+ // Compare by UTF-16 code unit, not localeCompare: collation order is
32
+ // ICU/locale-dependent (LC_ALL/LANG), which would let the same canonical
33
+ // data serialize with different key order across machines and break the
34
+ // "byte-identical across runs" guarantee that baselines and dedupe digests
35
+ // rely on. Code-unit order is stable everywhere.
36
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
37
+ .map(([key, nested]) => [key, sortKeys(nested)]));
38
+ }
39
+ return value;
40
+ }
@@ -0,0 +1,14 @@
1
+ import type { LifecycleConfig } from "./config.js";
2
+ export type LifecycleRun = {
3
+ phase: "setup" | "start" | "wait" | "teardown";
4
+ command: string;
5
+ exitCode: number | null;
6
+ durationMs: number;
7
+ pid?: number;
8
+ };
9
+ export type ManagedLifecycle = {
10
+ runs: LifecycleRun[];
11
+ stop: () => Promise<LifecycleRun[]>;
12
+ };
13
+ export declare function runLifecycleCommands(lifecycle: LifecycleConfig, cwd?: string): Promise<LifecycleRun[]>;
14
+ export declare function startManagedLifecycle(lifecycle: LifecycleConfig, cwd?: string): Promise<ManagedLifecycle>;