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,116 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { coerceSnapshot, render } from "../compare/shared.js";
|
|
4
|
+
import { captureCheck, checkParserFor } from "../check/capture.js";
|
|
5
|
+
import { checkParsers } from "../check/parsers.js";
|
|
6
|
+
import { structuralChanges } from "../diff/structural.js";
|
|
7
|
+
import { canonicalizeSnapshot } from "../snapshot.js";
|
|
8
|
+
// Check snapshots hold keyed records (entry identity → attributes), so the
|
|
9
|
+
// structural diff names exactly the added/removed/changed entry — e.g.
|
|
10
|
+
// `~ data["GET|HEAD /users"]: … → undefined` for a removed route.
|
|
11
|
+
function compareCheck(baseline, candidate, options) {
|
|
12
|
+
const sections = [];
|
|
13
|
+
if (baseline.tool !== candidate.tool) {
|
|
14
|
+
sections.push(`~ tool: ${String(baseline.tool)} → ${String(candidate.tool)}`);
|
|
15
|
+
}
|
|
16
|
+
const changes = structuralChanges({ data: baseline.data }, { data: candidate.data }, { numericTolerance: options.comparison.numericTolerance });
|
|
17
|
+
for (const change of changes) {
|
|
18
|
+
sections.push(`~ ${change.path}: ${render(change.before)} → ${render(change.after)}`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
equal: sections.length === 0,
|
|
22
|
+
rendered: sections.join("\n")
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function doctorCheckFor(target) {
|
|
26
|
+
if (!Object.hasOwn(checkParsers, target.tool)) {
|
|
27
|
+
return {
|
|
28
|
+
name: "check-target",
|
|
29
|
+
severity: "fail",
|
|
30
|
+
target: target.name,
|
|
31
|
+
message: `Check target "${target.name}" uses unknown tool "${target.tool}". ` +
|
|
32
|
+
`Available: ${Object.keys(checkParsers).join(", ")}.`
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const parser = checkParserFor(target);
|
|
36
|
+
return {
|
|
37
|
+
name: "check-target",
|
|
38
|
+
severity: "pass",
|
|
39
|
+
target: target.name,
|
|
40
|
+
message: describeCheckTarget(target, parser.tool, parser.defaultCommand, parser.defaultOutput)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function describeCheckTarget(target, tool, defaultCommand, defaultOutput) {
|
|
44
|
+
const command = target.command ?? (target.output ? undefined : defaultCommand);
|
|
45
|
+
const output = target.output ?? (target.command ? undefined : defaultOutput);
|
|
46
|
+
if (!command) {
|
|
47
|
+
return `Check target "${target.name}" ingests ${String(output)} as ${tool}.`;
|
|
48
|
+
}
|
|
49
|
+
return output
|
|
50
|
+
? `Check target "${target.name}" runs \`${command}\` and reads ${output}.`
|
|
51
|
+
: `Check target "${target.name}" runs \`${command}\`.`;
|
|
52
|
+
}
|
|
53
|
+
// The wrapped binary must exist before the command can work: `vendor/bin/x`
|
|
54
|
+
// commands need `composer install`, `php artisan …` needs an artisan file.
|
|
55
|
+
// Bare binaries resolved via PATH (e.g. `php` itself) are left to runtime.
|
|
56
|
+
async function binaryCheck(target, cwd) {
|
|
57
|
+
const parser = checkParsers[target.tool];
|
|
58
|
+
if (!parser) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const command = target.command ?? (target.output ? undefined : parser.defaultCommand);
|
|
62
|
+
if (!command) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const targetCwd = path.resolve(cwd, target.cwd ?? ".");
|
|
66
|
+
const first = command.split(" ")[0];
|
|
67
|
+
const requirement = first.includes("/")
|
|
68
|
+
? { file: first, hint: "run `composer install`?" }
|
|
69
|
+
: first === "php" && /\bartisan\b/.test(command)
|
|
70
|
+
? { file: "artisan", hint: "is this a Laravel app directory?" }
|
|
71
|
+
: undefined;
|
|
72
|
+
if (!requirement) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const present = await access(path.resolve(targetCwd, requirement.file)).then(() => true, () => false);
|
|
76
|
+
return {
|
|
77
|
+
name: "check-binary",
|
|
78
|
+
severity: present ? "pass" : "fail",
|
|
79
|
+
target: target.name,
|
|
80
|
+
message: present
|
|
81
|
+
? `Check target "${target.name}" found ${requirement.file}.`
|
|
82
|
+
: `Check target "${target.name}" needs ${requirement.file}, which is missing — ${requirement.hint}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export const check = {
|
|
86
|
+
kind: "check",
|
|
87
|
+
capture: (target, { config, cwd }) => {
|
|
88
|
+
const checkTarget = target;
|
|
89
|
+
return captureCheck(checkTarget, {
|
|
90
|
+
cwd: path.resolve(cwd, checkTarget.cwd ?? "."),
|
|
91
|
+
timeoutMs: checkTarget.timeoutMs ?? config.lifecycle.wait.timeoutMs
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
canonicalize: (value) => ({
|
|
95
|
+
kind: value.kind,
|
|
96
|
+
tool: value.tool,
|
|
97
|
+
data: canonicalizeSnapshot(value.data)
|
|
98
|
+
}),
|
|
99
|
+
compare: (baseline, candidate, options) => compareCheck(coerceSnapshot(baseline), coerceSnapshot(candidate), options),
|
|
100
|
+
validateConfig: (target, { label, issues }) => {
|
|
101
|
+
const checkTarget = target;
|
|
102
|
+
if (!checkTarget.tool || typeof checkTarget.tool !== "string") {
|
|
103
|
+
issues.push(`${label} (check "${checkTarget.name}") must have a "tool".`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!Object.hasOwn(checkParsers, checkTarget.tool)) {
|
|
107
|
+
issues.push(`${label} (check "${checkTarget.name}") uses unknown tool "${checkTarget.tool}". ` +
|
|
108
|
+
`Available: ${Object.keys(checkParsers).join(", ")}.`);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
doctorChecks: async (target, cwd) => {
|
|
112
|
+
const checkTarget = target;
|
|
113
|
+
const binary = await binaryCheck(checkTarget, cwd);
|
|
114
|
+
return binary ? [doctorCheckFor(checkTarget), binary] : [doctorCheckFor(checkTarget)];
|
|
115
|
+
}
|
|
116
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { a11yToDomNode, coerceSnapshot } from "../compare/shared.js";
|
|
2
|
+
import { diffDomTrees, renderTreeChanges } from "../diff/tree.js";
|
|
3
|
+
import { captureDesktop } from "../desktop/capture.js";
|
|
4
|
+
import { canonicalizeSnapshot } from "../snapshot.js";
|
|
5
|
+
function captureDesktopTarget(target, { config, cwd }) {
|
|
6
|
+
return captureDesktop(target, {
|
|
7
|
+
cwd,
|
|
8
|
+
timeoutMs: target.timeoutMs ?? config.lifecycle.wait.timeoutMs,
|
|
9
|
+
maskRules: config.normalization.masks
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
// Desktop accessibility trees reuse the structural DOM tree diff: each a11y
|
|
13
|
+
// node maps to an element whose tag is the role and whose attributes carry the
|
|
14
|
+
// name/value/description/state. Alignment is by role, so a renamed control
|
|
15
|
+
// surfaces as a changed @name rather than a remove + add.
|
|
16
|
+
function compareDesktop(baseline, candidate) {
|
|
17
|
+
const diff = diffDomTrees([a11yToDomNode(baseline.root)], [a11yToDomNode(candidate.root)]);
|
|
18
|
+
return {
|
|
19
|
+
equal: diff.equal,
|
|
20
|
+
rendered: diff.equal ? "" : renderTreeChanges(diff.changes)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function validateDesktopTarget(target) {
|
|
24
|
+
if (target.driver === "macos-ax") {
|
|
25
|
+
if (!target.app) {
|
|
26
|
+
return {
|
|
27
|
+
name: "desktop-target",
|
|
28
|
+
severity: "fail",
|
|
29
|
+
target: target.name,
|
|
30
|
+
message: `Desktop target "${target.name}" uses the macos-ax driver and must set "app".`
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
name: "desktop-target",
|
|
35
|
+
severity: process.platform === "darwin" ? "pass" : "warn",
|
|
36
|
+
target: target.name,
|
|
37
|
+
message: process.platform === "darwin"
|
|
38
|
+
? `Desktop target "${target.name}" captures "${target.app}" via macos-ax (needs Accessibility permission).`
|
|
39
|
+
: `Desktop target "${target.name}" uses the macos-ax driver, which only runs on macOS (current platform: ${process.platform}).`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return target.tree || target.command
|
|
43
|
+
? {
|
|
44
|
+
name: "desktop-target",
|
|
45
|
+
severity: "pass",
|
|
46
|
+
target: target.name,
|
|
47
|
+
message: `Desktop target "${target.name}" has a ${target.tree ? "tree file" : "command"}.`
|
|
48
|
+
}
|
|
49
|
+
: {
|
|
50
|
+
name: "desktop-target",
|
|
51
|
+
severity: "fail",
|
|
52
|
+
target: target.name,
|
|
53
|
+
message: `Desktop target "${target.name}" must set "driver", "tree", or "command".`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export const desktop = {
|
|
57
|
+
kind: "desktop",
|
|
58
|
+
capture: (target, ctx) => captureDesktopTarget(target, ctx),
|
|
59
|
+
canonicalize: (value) => {
|
|
60
|
+
const canonical = {
|
|
61
|
+
kind: value.kind,
|
|
62
|
+
root: canonicalizeSnapshot(value.root)
|
|
63
|
+
};
|
|
64
|
+
if (value.tool !== undefined) {
|
|
65
|
+
canonical.tool = value.tool;
|
|
66
|
+
}
|
|
67
|
+
return canonical;
|
|
68
|
+
},
|
|
69
|
+
compare: (baseline, candidate) => compareDesktop(coerceSnapshot(baseline), coerceSnapshot(candidate)),
|
|
70
|
+
validateConfig: (target, { label, issues }) => {
|
|
71
|
+
const desktopTarget = target;
|
|
72
|
+
const hasImageSource = Boolean(desktopTarget.screenshot || desktopTarget.screenshotCommand);
|
|
73
|
+
if (desktopTarget.driver &&
|
|
74
|
+
desktopTarget.driver !== "macos-ax" &&
|
|
75
|
+
desktopTarget.driver !== "ocr") {
|
|
76
|
+
issues.push(`${label} (desktop "${desktopTarget.name}") has unknown driver "${desktopTarget.driver}".`);
|
|
77
|
+
}
|
|
78
|
+
else if (desktopTarget.driver === "macos-ax") {
|
|
79
|
+
if (!desktopTarget.app) {
|
|
80
|
+
issues.push(`${label} (desktop "${desktopTarget.name}") uses the macos-ax driver and must set "app".`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (desktopTarget.driver === "ocr") {
|
|
84
|
+
if (!hasImageSource) {
|
|
85
|
+
issues.push(`${label} (desktop "${desktopTarget.name}") uses the ocr driver and must set "screenshot" or "screenshotCommand".`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (!desktopTarget.tree && !desktopTarget.command) {
|
|
89
|
+
issues.push(`${label} (desktop "${desktopTarget.name}") must set "driver", "tree", or "command".`);
|
|
90
|
+
}
|
|
91
|
+
// The OCR fallback needs an image source regardless of the primary driver.
|
|
92
|
+
if (desktopTarget.ocrFallback && !hasImageSource) {
|
|
93
|
+
issues.push(`${label} (desktop "${desktopTarget.name}") sets "ocrFallback" but no "screenshot" or "screenshotCommand" to capture.`);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
doctorChecks: (target) => [validateDesktopTarget(target)]
|
|
97
|
+
};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { coerceSnapshot, compareScreenshotPair, digestOf, render } from "../compare/shared.js";
|
|
6
|
+
import { structuralChanges } from "../diff/structural.js";
|
|
7
|
+
import { captureGame, resolveEnginePath } from "../game/capture.js";
|
|
8
|
+
import { PROTOCOL_VERSION } from "../game/protocol.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
import { walkthroughIssues } from "../game/walkthrough.js";
|
|
11
|
+
import { isRecord } from "../guards.js";
|
|
12
|
+
import { canonicalizeSnapshot } from "../snapshot.js";
|
|
13
|
+
import { digestScreenshot } from "./web.js";
|
|
14
|
+
function canonicalizeGame(value) {
|
|
15
|
+
const canonical = {
|
|
16
|
+
kind: value.kind,
|
|
17
|
+
engine: value.engine,
|
|
18
|
+
markers: canonicalizeSnapshot(value.markers)
|
|
19
|
+
};
|
|
20
|
+
// engineVersion/adapterVersion/ticks are runtime metadata: an engine patch
|
|
21
|
+
// release must not diff every baseline. Version compat is doctor's job.
|
|
22
|
+
if (isRecord(value.screenshots)) {
|
|
23
|
+
canonical.screenshots = Object.fromEntries(Object.entries(value.screenshots).map(([marker, screenshot]) => [
|
|
24
|
+
marker,
|
|
25
|
+
isRecord(screenshot) ? digestScreenshot(screenshot) : screenshot
|
|
26
|
+
]));
|
|
27
|
+
}
|
|
28
|
+
return canonical;
|
|
29
|
+
}
|
|
30
|
+
// Semantic marker state is the gate; screenshot digests are compared as
|
|
31
|
+
// named-marker lines. Pixel-diff fallback and the review-UI wiring for the
|
|
32
|
+
// Non-zero visual default for the game kind: GPU/driver rasterization varies
|
|
33
|
+
// across machines even when the simulation is byte-identical (measured zero
|
|
34
|
+
// variance same-platform, so this only absorbs cross-machine noise).
|
|
35
|
+
export const GAME_PIXEL_TOLERANCE = {
|
|
36
|
+
maxChangedRatio: 0.002,
|
|
37
|
+
perChannelThreshold: 3
|
|
38
|
+
};
|
|
39
|
+
// Per-marker tolerance resolution: marker override → target → explicit global
|
|
40
|
+
// comparison config → the game kind default.
|
|
41
|
+
function markerTolerance(target, options, marker) {
|
|
42
|
+
const override = target?.markers?.[marker]?.pixelTolerance;
|
|
43
|
+
if (override) {
|
|
44
|
+
return override;
|
|
45
|
+
}
|
|
46
|
+
if (target?.pixelTolerance) {
|
|
47
|
+
return target.pixelTolerance;
|
|
48
|
+
}
|
|
49
|
+
const global = options.comparison.pixelTolerance;
|
|
50
|
+
if (global.maxChangedRatio !== undefined || global.perChannelThreshold !== undefined) {
|
|
51
|
+
return global;
|
|
52
|
+
}
|
|
53
|
+
return GAME_PIXEL_TOLERANCE;
|
|
54
|
+
}
|
|
55
|
+
// Semantic marker state is the gate. Screenshot changes are ADVISORY by
|
|
56
|
+
// default — reported in the diff but not failing the run — because rendering
|
|
57
|
+
// varies across machines while the simulation doesn't. `screenshotMode:
|
|
58
|
+
// "strict"` opts a target into visual changes gating like any other diff.
|
|
59
|
+
function compareGame(baseline, candidate, options) {
|
|
60
|
+
const gameTarget = options.target?.kind === "game" ? options.target : undefined;
|
|
61
|
+
const strict = gameTarget?.screenshotMode === "strict";
|
|
62
|
+
const sections = [];
|
|
63
|
+
const advisory = [];
|
|
64
|
+
const changes = structuralChanges({ engine: baseline.engine, markers: baseline.markers }, { engine: candidate.engine, markers: candidate.markers }, { numericTolerance: options.comparison.numericTolerance });
|
|
65
|
+
for (const change of changes) {
|
|
66
|
+
sections.push(`~ ${change.path}: ${render(change.before)} → ${render(change.after)}`);
|
|
67
|
+
}
|
|
68
|
+
const markerDigests = (value) => isRecord(value.screenshots) ? value.screenshots : {};
|
|
69
|
+
const baselineShots = markerDigests(baseline);
|
|
70
|
+
const candidateShots = markerDigests(candidate);
|
|
71
|
+
const imagesByMarker = {};
|
|
72
|
+
for (const marker of new Set([...Object.keys(baselineShots), ...Object.keys(candidateShots)])) {
|
|
73
|
+
const shot = compareScreenshotPair(digestOf(baselineShots[marker]), digestOf(candidateShots[marker]), options.baselineScreenshots?.[marker], options.candidateScreenshots?.[marker], markerTolerance(gameTarget, options, marker), `screenshot[${marker}]`);
|
|
74
|
+
if (shot.line) {
|
|
75
|
+
if (strict) {
|
|
76
|
+
sections.push(shot.line);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
advisory.push(`${shot.line} (advisory — semantic state is the gate)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (shot.images) {
|
|
83
|
+
imagesByMarker[marker] = shot.images;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
equal: sections.length === 0,
|
|
88
|
+
rendered: [...sections, ...advisory].join("\n"),
|
|
89
|
+
...(Object.keys(imagesByMarker).length > 0 ? { screenshotImagesByMarker: imagesByMarker } : {})
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function gameDoctorChecks(target, cwd) {
|
|
93
|
+
const checks = [];
|
|
94
|
+
const project = path.resolve(cwd, target.project);
|
|
95
|
+
try {
|
|
96
|
+
await access(path.join(project, "project.godot"));
|
|
97
|
+
checks.push({
|
|
98
|
+
name: "game-project",
|
|
99
|
+
severity: "pass",
|
|
100
|
+
target: target.name,
|
|
101
|
+
message: `Godot project exists at ${project}.`
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
checks.push({
|
|
106
|
+
name: "game-project",
|
|
107
|
+
severity: "fail",
|
|
108
|
+
target: target.name,
|
|
109
|
+
message: `No project.godot found at ${project}.`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const walkthroughPath = path.resolve(cwd, target.walkthrough);
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(await readFile(walkthroughPath, "utf8"));
|
|
115
|
+
const issues = walkthroughIssues(parsed);
|
|
116
|
+
checks.push(issues.length === 0
|
|
117
|
+
? {
|
|
118
|
+
name: "game-walkthrough",
|
|
119
|
+
severity: "pass",
|
|
120
|
+
target: target.name,
|
|
121
|
+
message: `Walkthrough is valid at ${walkthroughPath}.`
|
|
122
|
+
}
|
|
123
|
+
: {
|
|
124
|
+
name: "game-walkthrough",
|
|
125
|
+
severity: "fail",
|
|
126
|
+
target: target.name,
|
|
127
|
+
message: `Walkthrough at ${walkthroughPath} is invalid: ${issues.join(" ")}`
|
|
128
|
+
});
|
|
129
|
+
// Per-marker overrides that don't match any walkthrough marker are dead
|
|
130
|
+
// config — almost always a typo.
|
|
131
|
+
if (issues.length === 0 && target.markers) {
|
|
132
|
+
const known = new Set(parsed.steps.flatMap((step) => typeof step.screenshot === "string" ? [step.screenshot] : []));
|
|
133
|
+
const unknown = Object.keys(target.markers).filter((marker) => !known.has(marker));
|
|
134
|
+
if (unknown.length > 0) {
|
|
135
|
+
checks.push({
|
|
136
|
+
name: "game-marker-overrides",
|
|
137
|
+
severity: "warn",
|
|
138
|
+
target: target.name,
|
|
139
|
+
message: `Marker override(s) ${unknown.map((m) => `"${m}"`).join(", ")} do not match any walkthrough marker (${[...known].join(", ") || "none"}).`
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
checks.push({
|
|
146
|
+
name: "game-walkthrough",
|
|
147
|
+
severity: "fail",
|
|
148
|
+
target: target.name,
|
|
149
|
+
message: `Walkthrough at ${walkthroughPath} could not be read: ${error.message}`
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (target.mode === "visual") {
|
|
153
|
+
checks.push({
|
|
154
|
+
name: "game-screenshots",
|
|
155
|
+
severity: "warn",
|
|
156
|
+
target: target.name,
|
|
157
|
+
message: `Game target "${target.name}" uses visual mode (one screenshot per marker). Anonymous pushes cap reports at 25MB — push with an API key or keep marker count and viewport modest.`
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Determinism is adapter-enforced, not opt-in — surface the effective knobs
|
|
161
|
+
// so a reviewer can see what a "deterministic run" means for this target.
|
|
162
|
+
checks.push({
|
|
163
|
+
name: "game-determinism",
|
|
164
|
+
severity: "pass",
|
|
165
|
+
target: target.name,
|
|
166
|
+
message: `Deterministic run enforced: seed ${target.seed ?? 0}, ${target.physicsFps ?? 60} physics ticks/s, vsync off, action-level input injection. Screenshots are ${target.screenshotMode === "strict" ? "strict (visual changes gate the run)" : "advisory (semantic state is the gate)"}.`
|
|
167
|
+
});
|
|
168
|
+
// Adapter presence + protocol compatibility, from the addon source — support
|
|
169
|
+
// triage starts from facts, not from a hung handshake.
|
|
170
|
+
checks.push(await validateAdapterInstall(target, project));
|
|
171
|
+
const engine = resolveEnginePath(target);
|
|
172
|
+
checks.push(await validateEngineBinary(target, engine));
|
|
173
|
+
return checks;
|
|
174
|
+
}
|
|
175
|
+
async function validateAdapterInstall(target, project) {
|
|
176
|
+
const adapterPath = path.join(project, "addons", "dungbeetle", "dungbeetle.gd");
|
|
177
|
+
let source;
|
|
178
|
+
try {
|
|
179
|
+
source = await readFile(adapterPath, "utf8");
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return {
|
|
183
|
+
name: "game-adapter",
|
|
184
|
+
severity: "fail",
|
|
185
|
+
target: target.name,
|
|
186
|
+
message: `Dungbeetle adapter not found at ${adapterPath}. Copy addons/dungbeetle/ from the adapter package into the project and register the autoload.`
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const version = source.match(/ADAPTER_VERSION\s*:?=\s*"([^"]+)"/)?.[1] ?? "unknown";
|
|
190
|
+
const min = Number(source.match(/PROTOCOL_MIN\s*:?=\s*(\d+)/)?.[1] ?? Number.NaN);
|
|
191
|
+
const max = Number(source.match(/PROTOCOL_MAX\s*:?=\s*(\d+)/)?.[1] ?? Number.NaN);
|
|
192
|
+
const compatible = min <= PROTOCOL_VERSION && PROTOCOL_VERSION <= max;
|
|
193
|
+
return compatible
|
|
194
|
+
? {
|
|
195
|
+
name: "game-adapter",
|
|
196
|
+
severity: "pass",
|
|
197
|
+
target: target.name,
|
|
198
|
+
message: `Adapter ${version} installed (protocol v${min}–v${max}, CLI speaks v${PROTOCOL_VERSION}).`
|
|
199
|
+
}
|
|
200
|
+
: {
|
|
201
|
+
name: "game-adapter",
|
|
202
|
+
severity: "fail",
|
|
203
|
+
target: target.name,
|
|
204
|
+
message: `Adapter ${version} supports protocol v${min}–v${max}, but this CLI speaks v${PROTOCOL_VERSION}. Upgrade the ${Number.isFinite(min) && min > PROTOCOL_VERSION ? "CLI" : "adapter"}.`
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async function validateEngineBinary(target, engine) {
|
|
208
|
+
if (!path.isAbsolute(engine)) {
|
|
209
|
+
return {
|
|
210
|
+
name: "game-engine",
|
|
211
|
+
severity: "warn",
|
|
212
|
+
target: target.name,
|
|
213
|
+
message: `Engine resolves to "${engine}" on PATH. Set "enginePath" or DUNGBEETLE_GODOT_PATH for a pinned binary.`
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const { stdout } = await execFileAsync(engine, ["--version"], { timeout: 15_000 });
|
|
218
|
+
const version = stdout.trim().split("\n")[0] ?? "unknown";
|
|
219
|
+
return {
|
|
220
|
+
name: "game-engine",
|
|
221
|
+
severity: "pass",
|
|
222
|
+
target: target.name,
|
|
223
|
+
message: `Engine binary at ${engine} reports version ${version}.`
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return {
|
|
228
|
+
name: "game-engine",
|
|
229
|
+
severity: "fail",
|
|
230
|
+
target: target.name,
|
|
231
|
+
message: `Engine binary at ${engine} could not be executed (ran "--version").`
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export const game = {
|
|
236
|
+
kind: "game",
|
|
237
|
+
// Each run owns a child engine process (and the display, in visual mode) —
|
|
238
|
+
// never batched into the parallel pool.
|
|
239
|
+
capture: (target, ctx) => captureGame(target, {
|
|
240
|
+
cwd: ctx.cwd,
|
|
241
|
+
timeoutMs: ctx.config.lifecycle.wait.timeoutMs
|
|
242
|
+
}),
|
|
243
|
+
canonicalize: canonicalizeGame,
|
|
244
|
+
compare: (baseline, candidate, options) => compareGame(coerceSnapshot(baseline), coerceSnapshot(candidate), options),
|
|
245
|
+
validateConfig: (target, { label, issues }) => {
|
|
246
|
+
const gameTarget = target;
|
|
247
|
+
if (!gameTarget.project) {
|
|
248
|
+
issues.push(`${label} (game "${gameTarget.name}") must set "project".`);
|
|
249
|
+
}
|
|
250
|
+
if (!gameTarget.walkthrough) {
|
|
251
|
+
issues.push(`${label} (game "${gameTarget.name}") must set "walkthrough".`);
|
|
252
|
+
}
|
|
253
|
+
if (gameTarget.engine !== "godot") {
|
|
254
|
+
issues.push(`${label} (game "${gameTarget.name}") has unknown engine "${String(gameTarget.engine)}" — "godot" is the only supported engine.`);
|
|
255
|
+
}
|
|
256
|
+
if (gameTarget.mode && gameTarget.mode !== "semantic" && gameTarget.mode !== "visual") {
|
|
257
|
+
issues.push(`${label} (game "${gameTarget.name}") has unknown mode "${String(gameTarget.mode)}".`);
|
|
258
|
+
}
|
|
259
|
+
if (gameTarget.screenshotMode &&
|
|
260
|
+
gameTarget.screenshotMode !== "advisory" &&
|
|
261
|
+
gameTarget.screenshotMode !== "strict") {
|
|
262
|
+
issues.push(`${label} (game "${gameTarget.name}") has unknown screenshotMode "${String(gameTarget.screenshotMode)}" — use "advisory" or "strict".`);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
doctorChecks: (target, cwd) => gameDoctorChecks(target, cwd)
|
|
266
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { coerceSnapshot, renderPerformanceChange } from "../compare/shared.js";
|
|
3
|
+
import { structuralChanges } from "../diff/structural.js";
|
|
4
|
+
import { capturePerformance } from "../perf/capture.js";
|
|
5
|
+
import { perfParserFor, perfParsers } from "../perf/parsers.js";
|
|
6
|
+
import { canonicalizeRecord } from "../snapshot.js";
|
|
7
|
+
function capturePerformanceTarget(target, { config, cwd }) {
|
|
8
|
+
return capturePerformance(target, {
|
|
9
|
+
cwd: path.resolve(cwd, target.cwd ?? "."),
|
|
10
|
+
timeoutMs: target.timeoutMs ?? config.lifecycle.wait.timeoutMs
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
// Performance snapshots compare numerically (within the configured tolerance)
|
|
14
|
+
// and render each changed metric with its percentage delta — what a reviewer
|
|
15
|
+
// reading a perf regression actually cares about.
|
|
16
|
+
function comparePerformance(baseline, candidate, options) {
|
|
17
|
+
const changes = structuralChanges(baseline, candidate, {
|
|
18
|
+
numericTolerance: options.comparison.numericTolerance
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
equal: changes.length === 0,
|
|
22
|
+
rendered: changes.map(renderPerformanceChange).join("\n")
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export const performance = {
|
|
26
|
+
kind: "performance",
|
|
27
|
+
capture: (target, ctx) => capturePerformanceTarget(target, ctx),
|
|
28
|
+
// Performance snapshots have no dedicated canonical shape — they use the same
|
|
29
|
+
// generic record canonicalization (recurse, drop runtime metadata) as plain
|
|
30
|
+
// objects.
|
|
31
|
+
canonicalize: (value) => canonicalizeRecord(value),
|
|
32
|
+
compare: (baseline, candidate, options) => comparePerformance(coerceSnapshot(baseline), coerceSnapshot(candidate), options),
|
|
33
|
+
validateConfig: (target, { label, issues }) => {
|
|
34
|
+
const performanceTarget = target;
|
|
35
|
+
const tool = performanceTarget.tool ?? "k6";
|
|
36
|
+
const parser = perfParsers[tool];
|
|
37
|
+
if (!parser) {
|
|
38
|
+
issues.push(`${label} (performance "${performanceTarget.name}") uses unknown tool "${tool}". ` +
|
|
39
|
+
`Available: ${Object.keys(perfParsers).join(", ")}.`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const issue of parser.validate(performanceTarget)) {
|
|
43
|
+
issues.push(`${label} (performance "${performanceTarget.name}") ${issue}`);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
doctorChecks: (target) => perfParserFor(target).doctorChecks(target)
|
|
47
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { api } from "./api.js";
|
|
2
|
+
import { check } from "./check.js";
|
|
3
|
+
import { desktop } from "./desktop.js";
|
|
4
|
+
import { game } from "./game.js";
|
|
5
|
+
import { performance } from "./performance.js";
|
|
6
|
+
import { terminal } from "./terminal.js";
|
|
7
|
+
import { web } from "./web.js";
|
|
8
|
+
// The single source of truth for capture kinds. Adding a capture type is one new
|
|
9
|
+
// `src/captures/<kind>.ts` module exporting a `CaptureType`, plus one line here —
|
|
10
|
+
// every dispatcher (capture, compare, canonicalize, doctor, config) routes
|
|
11
|
+
// through this registry, so there is no per-kind branching to update.
|
|
12
|
+
export const captureTypes = {
|
|
13
|
+
api,
|
|
14
|
+
check,
|
|
15
|
+
terminal,
|
|
16
|
+
web,
|
|
17
|
+
performance,
|
|
18
|
+
desktop,
|
|
19
|
+
game
|
|
20
|
+
};
|
|
21
|
+
export function isCaptureKind(value) {
|
|
22
|
+
return typeof value === "string" && Object.hasOwn(captureTypes, value);
|
|
23
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { coerceSnapshot, compareStream } from "../compare/shared.js";
|
|
3
|
+
import { canonicalizeSnapshot } from "../snapshot.js";
|
|
4
|
+
import { captureTerminal } from "../terminal/capture.js";
|
|
5
|
+
function captureTerminalTarget(target, { config, cwd }) {
|
|
6
|
+
return captureTerminal({
|
|
7
|
+
command: target.command,
|
|
8
|
+
cwd: path.resolve(cwd, target.cwd ?? "."),
|
|
9
|
+
timeoutMs: target.timeoutMs ?? config.lifecycle.wait.timeoutMs,
|
|
10
|
+
maskRules: config.normalization.masks
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function compareTerminal(baseline, candidate, options) {
|
|
14
|
+
const sections = [];
|
|
15
|
+
if (baseline.exitCode !== candidate.exitCode) {
|
|
16
|
+
sections.push(`~ exitCode: ${String(baseline.exitCode)} → ${String(candidate.exitCode)}`);
|
|
17
|
+
}
|
|
18
|
+
if (baseline.signal !== candidate.signal) {
|
|
19
|
+
sections.push(`~ signal: ${String(baseline.signal)} → ${String(candidate.signal)}`);
|
|
20
|
+
}
|
|
21
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
22
|
+
const change = compareStream(stream, baseline[stream], candidate[stream], options);
|
|
23
|
+
if (change) {
|
|
24
|
+
sections.push(change);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
equal: sections.length === 0,
|
|
29
|
+
rendered: sections.join("\n")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function validateTerminalTarget(target) {
|
|
33
|
+
return target.command.trim()
|
|
34
|
+
? {
|
|
35
|
+
name: "terminal-target",
|
|
36
|
+
severity: "pass",
|
|
37
|
+
target: target.name,
|
|
38
|
+
message: `Terminal target "${target.name}" has a command.`
|
|
39
|
+
}
|
|
40
|
+
: {
|
|
41
|
+
name: "terminal-target",
|
|
42
|
+
severity: "fail",
|
|
43
|
+
target: target.name,
|
|
44
|
+
message: `Terminal target "${target.name}" is missing a command.`
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export const terminal = {
|
|
48
|
+
kind: "terminal",
|
|
49
|
+
capture: (target, ctx) => captureTerminalTarget(target, ctx),
|
|
50
|
+
canonicalize: (value) => ({
|
|
51
|
+
kind: value.kind,
|
|
52
|
+
exitCode: value.exitCode,
|
|
53
|
+
signal: value.signal,
|
|
54
|
+
stdout: canonicalizeSnapshot(value.stdout),
|
|
55
|
+
stderr: canonicalizeSnapshot(value.stderr)
|
|
56
|
+
}),
|
|
57
|
+
compare: (baseline, candidate, options) => compareTerminal(coerceSnapshot(baseline), coerceSnapshot(candidate), options),
|
|
58
|
+
validateConfig: (target, { label, issues }) => {
|
|
59
|
+
const terminalTarget = target;
|
|
60
|
+
if (!terminalTarget.command || typeof terminalTarget.command !== "string") {
|
|
61
|
+
issues.push(`${label} (terminal "${terminalTarget.name}") must have a "command".`);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
doctorChecks: (target) => [validateTerminalTarget(target)]
|
|
65
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CaptureContext, SnapshotArtifact } from "../capture.js";
|
|
2
|
+
import type { CompareOptions, SnapshotComparison } from "../compare/shared.js";
|
|
3
|
+
import type { CaptureTarget } from "../config.js";
|
|
4
|
+
import type { DoctorCheck } from "../doctor.js";
|
|
5
|
+
export type ValidateConfigContext = {
|
|
6
|
+
label: string;
|
|
7
|
+
issues: string[];
|
|
8
|
+
};
|
|
9
|
+
export interface CaptureType {
|
|
10
|
+
kind: CaptureTarget["kind"];
|
|
11
|
+
hasScreenshots?: boolean;
|
|
12
|
+
parallelSafe?: boolean;
|
|
13
|
+
capture(target: CaptureTarget, ctx: CaptureContext): Promise<SnapshotArtifact>;
|
|
14
|
+
canonicalize(value: Record<string, unknown>): unknown;
|
|
15
|
+
compare(baseline: unknown, candidate: unknown, options: CompareOptions): SnapshotComparison;
|
|
16
|
+
validateConfig(target: CaptureTarget, ctx: ValidateConfigContext): void;
|
|
17
|
+
doctorChecks(target: CaptureTarget, cwd: string): DoctorCheck[] | Promise<DoctorCheck[]>;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|