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/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
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>;
|