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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { normalizeAnsiStream } from "./ansi.js";
|
|
3
|
+
export async function captureTerminal(options) {
|
|
4
|
+
const cwd = options.cwd ?? process.cwd();
|
|
5
|
+
const startedAt = performance.now();
|
|
6
|
+
const result = await runShellCommand({
|
|
7
|
+
command: options.command,
|
|
8
|
+
cwd,
|
|
9
|
+
timeoutMs: options.timeoutMs ?? 30_000
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
kind: "terminal",
|
|
13
|
+
command: options.command,
|
|
14
|
+
cwd,
|
|
15
|
+
exitCode: result.exitCode,
|
|
16
|
+
signal: result.signal,
|
|
17
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
18
|
+
stdout: normalizeAnsiStream(result.stdout, options.maskRules),
|
|
19
|
+
stderr: normalizeAnsiStream(result.stderr, options.maskRules)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// Terminate the spawned shell and any commands it started. On POSIX the child
|
|
23
|
+
// is a process-group leader (spawned `detached`), so signalling the negated PID
|
|
24
|
+
// reaches the whole group; on Windows process groups work differently, so fall
|
|
25
|
+
// back to a direct kill (this path is exercised by callers, not the timeout
|
|
26
|
+
// test, which is skipped on win32).
|
|
27
|
+
function killTree(child) {
|
|
28
|
+
if (process.platform === "win32" || child.pid === undefined) {
|
|
29
|
+
child.kill("SIGTERM");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
process.kill(-child.pid, "SIGTERM");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// The group may already be gone (race with normal exit); signal the child
|
|
37
|
+
// directly as a best effort.
|
|
38
|
+
child.kill("SIGTERM");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function runShellCommand(options) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
// `shell: true` means the command runs as a child of a shell process. On
|
|
44
|
+
// its own, `child.kill()` would only signal that shell, leaving the actual
|
|
45
|
+
// command (e.g. a long-running `node`) alive and holding the stdout/stderr
|
|
46
|
+
// pipes open — so `close` never fires until it finishes. Run the child in
|
|
47
|
+
// its own process group (`detached`) so a single signal to the group tears
|
|
48
|
+
// down the shell and everything it spawned.
|
|
49
|
+
const child = spawn(options.command, {
|
|
50
|
+
cwd: options.cwd,
|
|
51
|
+
shell: true,
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
detached: process.platform !== "win32"
|
|
54
|
+
});
|
|
55
|
+
let stdout = "";
|
|
56
|
+
let stderr = "";
|
|
57
|
+
let settled = false;
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
killTree(child);
|
|
60
|
+
}, options.timeoutMs);
|
|
61
|
+
child.stdout.setEncoding("utf8");
|
|
62
|
+
child.stderr.setEncoding("utf8");
|
|
63
|
+
child.stdout.on("data", (chunk) => {
|
|
64
|
+
stdout += chunk;
|
|
65
|
+
});
|
|
66
|
+
child.stderr.on("data", (chunk) => {
|
|
67
|
+
stderr += chunk;
|
|
68
|
+
});
|
|
69
|
+
child.on("error", (error) => {
|
|
70
|
+
if (settled) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
reject(error);
|
|
76
|
+
});
|
|
77
|
+
child.on("close", (exitCode, signal) => {
|
|
78
|
+
if (settled) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
resolve({
|
|
84
|
+
stdout,
|
|
85
|
+
stderr,
|
|
86
|
+
exitCode,
|
|
87
|
+
signal
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
package/dist/tty.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type WriteStream = NodeJS.WriteStream;
|
|
2
|
+
/**
|
|
3
|
+
* Whether ANSI colour escapes should be emitted for the given stream.
|
|
4
|
+
*
|
|
5
|
+
* Honors the de-facto standards: `FORCE_COLOR` wins over everything, then
|
|
6
|
+
* `NO_COLOR` (presence disables, per the no-color.org spec), `TERM=dumb`, and
|
|
7
|
+
* finally whether the stream is an interactive TTY.
|
|
8
|
+
*/
|
|
9
|
+
export declare function colorEnabled(stream?: WriteStream): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Whether unicode/emoji glyphs are safe to print.
|
|
12
|
+
*
|
|
13
|
+
* Non-Windows terminals handle unicode unless they are the bare Linux console.
|
|
14
|
+
* On Windows, only modern hosts (Windows Terminal, VS Code, ConEmu) render
|
|
15
|
+
* emoji reliably, so legacy `cmd.exe` / PowerShell consoles fall back to ASCII.
|
|
16
|
+
* `DUNGBEETLE_ASCII=1` forces ASCII everywhere for testing or troublesome setups.
|
|
17
|
+
*/
|
|
18
|
+
export declare function unicodeEnabled(): boolean;
|
|
19
|
+
export type Palette = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
bold(value: string): string;
|
|
22
|
+
dim(value: string): string;
|
|
23
|
+
red(value: string): string;
|
|
24
|
+
green(value: string): string;
|
|
25
|
+
yellow(value: string): string;
|
|
26
|
+
blue(value: string): string;
|
|
27
|
+
magenta(value: string): string;
|
|
28
|
+
cyan(value: string): string;
|
|
29
|
+
gray(value: string): string;
|
|
30
|
+
};
|
|
31
|
+
/** Build a palette whose functions are no-ops when colour is disabled. */
|
|
32
|
+
export declare function makePalette(enabled: boolean): Palette;
|
|
33
|
+
export type Symbols = {
|
|
34
|
+
passed: string;
|
|
35
|
+
failed: string;
|
|
36
|
+
missing: string;
|
|
37
|
+
updated: string;
|
|
38
|
+
error: string;
|
|
39
|
+
bullet: string;
|
|
40
|
+
arrow: string;
|
|
41
|
+
clock: string;
|
|
42
|
+
};
|
|
43
|
+
/** Status glyphs with an ASCII fallback for terminals without emoji. */
|
|
44
|
+
export declare function makeSymbols(unicode: boolean): Symbols;
|
|
45
|
+
/**
|
|
46
|
+
* A minimal loading spinner.
|
|
47
|
+
*
|
|
48
|
+
* It animates only on an interactive TTY; in CI, pipes, and dumb terminals it
|
|
49
|
+
* is completely silent so it never pollutes logs or report output. It writes
|
|
50
|
+
* to stderr by default to keep stdout free for JSON/HTML report paths.
|
|
51
|
+
*/
|
|
52
|
+
export declare class Spinner {
|
|
53
|
+
private readonly stream;
|
|
54
|
+
private readonly frames;
|
|
55
|
+
private readonly palette;
|
|
56
|
+
private readonly active;
|
|
57
|
+
private readonly intervalMs;
|
|
58
|
+
private timer;
|
|
59
|
+
private frame;
|
|
60
|
+
private text;
|
|
61
|
+
constructor(options?: {
|
|
62
|
+
stream?: WriteStream;
|
|
63
|
+
intervalMs?: number;
|
|
64
|
+
});
|
|
65
|
+
start(text: string): this;
|
|
66
|
+
setText(text: string): this;
|
|
67
|
+
/** Stop the spinner and clear its line, leaving nothing behind. */
|
|
68
|
+
stop(): this;
|
|
69
|
+
private render;
|
|
70
|
+
}
|
|
71
|
+
/** Format a millisecond duration as a short human string (e.g. `1.2s`). */
|
|
72
|
+
export declare function formatDuration(ms: number): string;
|
package/dist/tty.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Small, dependency-free terminal styling helpers.
|
|
2
|
+
//
|
|
3
|
+
// Everything here degrades gracefully so output stays readable in any
|
|
4
|
+
// terminal — including Windows legacy consoles, dumb terminals, piped output,
|
|
5
|
+
// and CI logs. Colour and unicode are detected independently: a terminal may
|
|
6
|
+
// support ANSI colour but not render emoji well, so the two are gated apart.
|
|
7
|
+
function readEnv(name) {
|
|
8
|
+
return process.env[name];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Whether ANSI colour escapes should be emitted for the given stream.
|
|
12
|
+
*
|
|
13
|
+
* Honors the de-facto standards: `FORCE_COLOR` wins over everything, then
|
|
14
|
+
* `NO_COLOR` (presence disables, per the no-color.org spec), `TERM=dumb`, and
|
|
15
|
+
* finally whether the stream is an interactive TTY.
|
|
16
|
+
*/
|
|
17
|
+
export function colorEnabled(stream = process.stdout) {
|
|
18
|
+
const force = readEnv("FORCE_COLOR");
|
|
19
|
+
if (force !== undefined) {
|
|
20
|
+
return force !== "0" && force !== "false" && force !== "";
|
|
21
|
+
}
|
|
22
|
+
if ("NO_COLOR" in process.env) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (readEnv("TERM") === "dumb") {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return Boolean(stream.isTTY);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether unicode/emoji glyphs are safe to print.
|
|
32
|
+
*
|
|
33
|
+
* Non-Windows terminals handle unicode unless they are the bare Linux console.
|
|
34
|
+
* On Windows, only modern hosts (Windows Terminal, VS Code, ConEmu) render
|
|
35
|
+
* emoji reliably, so legacy `cmd.exe` / PowerShell consoles fall back to ASCII.
|
|
36
|
+
* `DUNGBEETLE_ASCII=1` forces ASCII everywhere for testing or troublesome setups.
|
|
37
|
+
*/
|
|
38
|
+
export function unicodeEnabled() {
|
|
39
|
+
if (readEnv("DUNGBEETLE_ASCII") === "1") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (process.platform !== "win32") {
|
|
43
|
+
return readEnv("TERM") !== "linux";
|
|
44
|
+
}
|
|
45
|
+
return Boolean(readEnv("WT_SESSION") || readEnv("TERM_PROGRAM") || readEnv("ConEmuTask") || readEnv("TERM"));
|
|
46
|
+
}
|
|
47
|
+
const ANSI = {
|
|
48
|
+
reset: "[0m",
|
|
49
|
+
bold: "[1m",
|
|
50
|
+
dim: "[2m",
|
|
51
|
+
red: "[31m",
|
|
52
|
+
green: "[32m",
|
|
53
|
+
yellow: "[33m",
|
|
54
|
+
blue: "[34m",
|
|
55
|
+
magenta: "[35m",
|
|
56
|
+
cyan: "[36m",
|
|
57
|
+
gray: "[90m"
|
|
58
|
+
};
|
|
59
|
+
/** Build a palette whose functions are no-ops when colour is disabled. */
|
|
60
|
+
export function makePalette(enabled) {
|
|
61
|
+
const wrap = (code) => (value) => enabled ? `${code}${value}${ANSI.reset}` : value;
|
|
62
|
+
return {
|
|
63
|
+
enabled,
|
|
64
|
+
bold: wrap(ANSI.bold),
|
|
65
|
+
dim: wrap(ANSI.dim),
|
|
66
|
+
red: wrap(ANSI.red),
|
|
67
|
+
green: wrap(ANSI.green),
|
|
68
|
+
yellow: wrap(ANSI.yellow),
|
|
69
|
+
blue: wrap(ANSI.blue),
|
|
70
|
+
magenta: wrap(ANSI.magenta),
|
|
71
|
+
cyan: wrap(ANSI.cyan),
|
|
72
|
+
gray: wrap(ANSI.gray)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Status glyphs with an ASCII fallback for terminals without emoji. */
|
|
76
|
+
export function makeSymbols(unicode) {
|
|
77
|
+
if (unicode) {
|
|
78
|
+
return {
|
|
79
|
+
passed: "✅",
|
|
80
|
+
failed: "❌",
|
|
81
|
+
missing: "⚠️",
|
|
82
|
+
updated: "📝",
|
|
83
|
+
error: "🛑",
|
|
84
|
+
bullet: "•",
|
|
85
|
+
arrow: "›",
|
|
86
|
+
clock: "⏱"
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
passed: "[pass]",
|
|
91
|
+
failed: "[fail]",
|
|
92
|
+
missing: "[miss]",
|
|
93
|
+
updated: "[upd]",
|
|
94
|
+
error: "[err]",
|
|
95
|
+
bullet: "-",
|
|
96
|
+
arrow: ">",
|
|
97
|
+
clock: ""
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const SPINNER_FRAMES = {
|
|
101
|
+
unicode: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
102
|
+
ascii: ["-", "\\", "|", "/"]
|
|
103
|
+
};
|
|
104
|
+
const CLEAR_LINE = "[2K\r";
|
|
105
|
+
/**
|
|
106
|
+
* A minimal loading spinner.
|
|
107
|
+
*
|
|
108
|
+
* It animates only on an interactive TTY; in CI, pipes, and dumb terminals it
|
|
109
|
+
* is completely silent so it never pollutes logs or report output. It writes
|
|
110
|
+
* to stderr by default to keep stdout free for JSON/HTML report paths.
|
|
111
|
+
*/
|
|
112
|
+
export class Spinner {
|
|
113
|
+
stream;
|
|
114
|
+
frames;
|
|
115
|
+
palette;
|
|
116
|
+
active;
|
|
117
|
+
intervalMs;
|
|
118
|
+
timer;
|
|
119
|
+
frame = 0;
|
|
120
|
+
text = "";
|
|
121
|
+
constructor(options = {}) {
|
|
122
|
+
this.stream = options.stream ?? process.stderr;
|
|
123
|
+
this.intervalMs = options.intervalMs ?? 80;
|
|
124
|
+
const unicode = unicodeEnabled();
|
|
125
|
+
this.frames = unicode ? SPINNER_FRAMES.unicode : SPINNER_FRAMES.ascii;
|
|
126
|
+
this.palette = makePalette(colorEnabled(this.stream));
|
|
127
|
+
this.active = Boolean(this.stream.isTTY);
|
|
128
|
+
}
|
|
129
|
+
start(text) {
|
|
130
|
+
this.text = text;
|
|
131
|
+
if (!this.active) {
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
this.render();
|
|
135
|
+
this.timer = setInterval(() => {
|
|
136
|
+
this.frame = (this.frame + 1) % this.frames.length;
|
|
137
|
+
this.render();
|
|
138
|
+
}, this.intervalMs);
|
|
139
|
+
// Don't keep the event loop alive solely for the spinner.
|
|
140
|
+
this.timer.unref?.();
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
setText(text) {
|
|
144
|
+
this.text = text;
|
|
145
|
+
if (this.active && this.timer) {
|
|
146
|
+
this.render();
|
|
147
|
+
}
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
/** Stop the spinner and clear its line, leaving nothing behind. */
|
|
151
|
+
stop() {
|
|
152
|
+
if (this.timer) {
|
|
153
|
+
clearInterval(this.timer);
|
|
154
|
+
this.timer = undefined;
|
|
155
|
+
}
|
|
156
|
+
if (this.active) {
|
|
157
|
+
this.stream.write(CLEAR_LINE);
|
|
158
|
+
}
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
render() {
|
|
162
|
+
const glyph = this.palette.cyan(this.frames[this.frame] ?? "");
|
|
163
|
+
this.stream.write(`${CLEAR_LINE}${glyph} ${this.text}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Format a millisecond duration as a short human string (e.g. `1.2s`). */
|
|
167
|
+
export function formatDuration(ms) {
|
|
168
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
169
|
+
return "0ms";
|
|
170
|
+
}
|
|
171
|
+
if (ms < 1000) {
|
|
172
|
+
return `${Math.round(ms)}ms`;
|
|
173
|
+
}
|
|
174
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MaskRule } from "../config.js";
|
|
2
|
+
export type DomSnapshotNode = {
|
|
3
|
+
type: "element";
|
|
4
|
+
tagName: string;
|
|
5
|
+
attributes: Record<string, string>;
|
|
6
|
+
children: DomSnapshotNode[];
|
|
7
|
+
} | {
|
|
8
|
+
type: "text";
|
|
9
|
+
value: string;
|
|
10
|
+
};
|
|
11
|
+
export type DomSnapshot = {
|
|
12
|
+
kind: "web";
|
|
13
|
+
source: string;
|
|
14
|
+
capturedAt: "masked";
|
|
15
|
+
root: DomSnapshotNode[];
|
|
16
|
+
};
|
|
17
|
+
export type WebFileSnapshot = DomSnapshot & {
|
|
18
|
+
driver: "file";
|
|
19
|
+
screenshot: {
|
|
20
|
+
mimeType: "image/png";
|
|
21
|
+
data: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function createDomSnapshot(html: string, options: {
|
|
25
|
+
source: string;
|
|
26
|
+
maskRules?: MaskRule[];
|
|
27
|
+
}): DomSnapshot;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parse } from "parse5";
|
|
2
|
+
import { applyMaskRules, collapseWhitespace } from "../normalization.js";
|
|
3
|
+
export function createDomSnapshot(html, options) {
|
|
4
|
+
const parsed = parse(html);
|
|
5
|
+
return {
|
|
6
|
+
kind: "web",
|
|
7
|
+
source: options.source,
|
|
8
|
+
capturedAt: "masked",
|
|
9
|
+
root: snapshotChildren(parsed.childNodes ?? [], options.maskRules ?? [])
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function snapshotChildren(nodes, maskRules) {
|
|
13
|
+
return nodes
|
|
14
|
+
.map((node) => snapshotNode(node, maskRules))
|
|
15
|
+
.filter((node) => node !== null);
|
|
16
|
+
}
|
|
17
|
+
function snapshotNode(node, maskRules) {
|
|
18
|
+
if (node.nodeName === "#text") {
|
|
19
|
+
const value = collapseWhitespace(applyMaskRules(node.value ?? "", maskRules));
|
|
20
|
+
if (!value) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
type: "text",
|
|
25
|
+
value
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (node.nodeName?.startsWith("#")) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (!node.tagName) {
|
|
32
|
+
return {
|
|
33
|
+
type: "element",
|
|
34
|
+
tagName: node.nodeName ?? "document",
|
|
35
|
+
attributes: {},
|
|
36
|
+
children: snapshotChildren(node.childNodes ?? [], maskRules)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
type: "element",
|
|
41
|
+
tagName: node.tagName,
|
|
42
|
+
attributes: snapshotAttributes(node.attrs ?? [], maskRules),
|
|
43
|
+
children: snapshotChildren(node.childNodes ?? [], maskRules)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Attributes that are volatile by construction and would make every capture of
|
|
47
|
+
// a CSP-protected page diff against itself: a nonce is a fresh random value per
|
|
48
|
+
// response, exactly the class of dynamic content masks exist for.
|
|
49
|
+
const VOLATILE_ATTRIBUTES = new Set(["nonce"]);
|
|
50
|
+
function snapshotAttributes(attrs, maskRules) {
|
|
51
|
+
return Object.fromEntries(attrs
|
|
52
|
+
.filter((attr) => !VOLATILE_ATTRIBUTES.has(attr.name.toLowerCase()))
|
|
53
|
+
.map((attr) => [attr.name, applyMaskRules(attr.value, maskRules)])
|
|
54
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CaptureTarget, MaskRule } from "../config.js";
|
|
2
|
+
import { type DomSnapshot } from "./domSnapshot.js";
|
|
3
|
+
export type PlaywrightWebSnapshot = DomSnapshot & {
|
|
4
|
+
driver: "playwright";
|
|
5
|
+
accessibility?: unknown;
|
|
6
|
+
screenshot?: {
|
|
7
|
+
mimeType: "image/png";
|
|
8
|
+
data: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export declare function capturePlaywrightWeb(target: Extract<CaptureTarget, {
|
|
12
|
+
kind: "web";
|
|
13
|
+
}>, options: {
|
|
14
|
+
maskRules: MaskRule[];
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
}): Promise<PlaywrightWebSnapshot>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createDomSnapshot } from "./domSnapshot.js";
|
|
2
|
+
export async function capturePlaywrightWeb(target, options) {
|
|
3
|
+
if (!target.url) {
|
|
4
|
+
throw new Error(`Playwright web target "${target.name}" requires a url.`);
|
|
5
|
+
}
|
|
6
|
+
const playwright = await import("playwright-core");
|
|
7
|
+
const browser = await launchBrowser(playwright, target);
|
|
8
|
+
try {
|
|
9
|
+
const context = await browser.newContext({
|
|
10
|
+
viewport: target.viewport ?? {
|
|
11
|
+
width: 1280,
|
|
12
|
+
height: 720
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
const page = await context.newPage();
|
|
16
|
+
await page.goto(target.url, {
|
|
17
|
+
waitUntil: "networkidle",
|
|
18
|
+
timeout: target.timeoutMs ?? options.timeoutMs
|
|
19
|
+
});
|
|
20
|
+
const dom = createDomSnapshot(await page.content(), {
|
|
21
|
+
source: target.url,
|
|
22
|
+
maskRules: options.maskRules
|
|
23
|
+
});
|
|
24
|
+
dom.driver = "playwright";
|
|
25
|
+
if (target.accessibility) {
|
|
26
|
+
dom.accessibility = await captureAccessibility(page);
|
|
27
|
+
}
|
|
28
|
+
if (target.screenshot) {
|
|
29
|
+
dom.screenshot = {
|
|
30
|
+
mimeType: "image/png",
|
|
31
|
+
data: (await page.screenshot({
|
|
32
|
+
fullPage: true,
|
|
33
|
+
type: "png"
|
|
34
|
+
})).toString("base64")
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
await context.close();
|
|
38
|
+
return dom;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await browser.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function launchBrowser(playwright, target) {
|
|
45
|
+
const executablePath = target.browser?.executablePath ?? process.env.DUNGBEETLE_CHROMIUM_EXECUTABLE_PATH;
|
|
46
|
+
return playwright.chromium.launch({
|
|
47
|
+
channel: target.browser?.channel,
|
|
48
|
+
executablePath,
|
|
49
|
+
headless: true
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function captureAccessibility(page) {
|
|
53
|
+
const pageLike = page;
|
|
54
|
+
if (pageLike.accessibility?.snapshot) {
|
|
55
|
+
return pageLike.accessibility.snapshot({
|
|
56
|
+
interestingOnly: false
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const body = pageLike.locator?.("body");
|
|
60
|
+
if (body?.ariaSnapshot) {
|
|
61
|
+
return body.ariaSnapshot();
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dungbeetle",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Web, desktop, terminal, and eventually anything — zero adoption cost and runs anywhere.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"dungbeetle": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"NOTICE"
|
|
17
|
+
],
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"check": "npm run lint && npm run typecheck && npm test",
|
|
23
|
+
"lint": "biome check src test",
|
|
24
|
+
"lint:fix": "biome check --write src test",
|
|
25
|
+
"format": "biome format --write src test",
|
|
26
|
+
"format:check": "biome format src test",
|
|
27
|
+
"check:terms": "bash scripts/check-banned-terms.sh",
|
|
28
|
+
"coverage": "vitest run --coverage",
|
|
29
|
+
"prepare": "npm run build",
|
|
30
|
+
"prepublishOnly": "npm run check",
|
|
31
|
+
"dev": "tsx src/index.ts",
|
|
32
|
+
"p1:ci": "tsx src/index.ts ci --config examples/demo/dungbeetle.config.json --json .dungbeetle/p1/ci-report.json --html .dungbeetle/p1/ci-report.html",
|
|
33
|
+
"p1:ci:json": "tsx src/index.ts ci --config examples/demo/dungbeetle.config.json --json .dungbeetle/p1/ci-report.json --json-only",
|
|
34
|
+
"p1:doctor": "tsx src/index.ts doctor --config examples/demo/dungbeetle.config.json --json .dungbeetle/p1/doctor-report.json",
|
|
35
|
+
"p1:doctor:playwright": "tsx src/index.ts doctor --config examples/p1/playwright/dungbeetle.config.json --json .dungbeetle/p1/playwright-doctor-report.json",
|
|
36
|
+
"p1:test": "tsx src/index.ts test --config examples/demo/dungbeetle.config.json --json .dungbeetle/p1/test-report.json --html .dungbeetle/p1/test-report.html",
|
|
37
|
+
"p1:update": "tsx src/index.ts update --config examples/demo/dungbeetle.config.json --json .dungbeetle/p1/update-report.json --html .dungbeetle/p1/update-report.html",
|
|
38
|
+
"game:doctor": "tsx src/index.ts doctor --config examples/game/dungbeetle.config.json",
|
|
39
|
+
"game:ci": "tsx src/index.ts ci --config examples/game/dungbeetle.config.json",
|
|
40
|
+
"game:update": "tsx src/index.ts update --config examples/game/dungbeetle.config.json",
|
|
41
|
+
"game:flake": "tsx src/index.ts flake --config examples/game/dungbeetle.config.json --repeat 5",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/DungbeetleTech/client.git"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"snapshot-testing",
|
|
51
|
+
"visual-regression",
|
|
52
|
+
"terminal",
|
|
53
|
+
"dom",
|
|
54
|
+
"testing"
|
|
55
|
+
],
|
|
56
|
+
"author": "DungbeetleDev <tech@dungbeetle.dev>",
|
|
57
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/DungbeetleTech/client/issues"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/DungbeetleTech/client#readme",
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"commander": "^15.0.0",
|
|
64
|
+
"parse5": "^8.0.1",
|
|
65
|
+
"playwright-core": "^1.61.1",
|
|
66
|
+
"pngjs": "^7.0.0",
|
|
67
|
+
"zod": "^4.4.3"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@biomejs/biome": "2.5.1",
|
|
71
|
+
"@types/node": "^26.0.1",
|
|
72
|
+
"@types/pngjs": "^6.0.5",
|
|
73
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
74
|
+
"esbuild": "^0.28.1",
|
|
75
|
+
"tsx": "^4.22.4",
|
|
76
|
+
"typescript": "^6.0.3",
|
|
77
|
+
"vitest": "^4.1.9"
|
|
78
|
+
}
|
|
79
|
+
}
|