dungbeetle 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +19 -0
  3. package/README.md +139 -0
  4. package/dist/api/capture.d.ts +24 -0
  5. package/dist/api/capture.js +61 -0
  6. package/dist/baselines.d.ts +7 -0
  7. package/dist/baselines.js +38 -0
  8. package/dist/brand.d.ts +2 -0
  9. package/dist/brand.js +9 -0
  10. package/dist/capture.d.ts +15 -0
  11. package/dist/capture.js +7 -0
  12. package/dist/captures/api.d.ts +2 -0
  13. package/dist/captures/api.js +114 -0
  14. package/dist/captures/check.d.ts +2 -0
  15. package/dist/captures/check.js +116 -0
  16. package/dist/captures/desktop.d.ts +2 -0
  17. package/dist/captures/desktop.js +97 -0
  18. package/dist/captures/game.d.ts +4 -0
  19. package/dist/captures/game.js +266 -0
  20. package/dist/captures/performance.d.ts +2 -0
  21. package/dist/captures/performance.js +47 -0
  22. package/dist/captures/registry.d.ts +4 -0
  23. package/dist/captures/registry.js +23 -0
  24. package/dist/captures/terminal.d.ts +2 -0
  25. package/dist/captures/terminal.js +65 -0
  26. package/dist/captures/types.d.ts +18 -0
  27. package/dist/captures/types.js +1 -0
  28. package/dist/captures/web.d.ts +3 -0
  29. package/dist/captures/web.js +248 -0
  30. package/dist/check/capture.d.ts +15 -0
  31. package/dist/check/capture.js +76 -0
  32. package/dist/check/junit.d.ts +9 -0
  33. package/dist/check/junit.js +51 -0
  34. package/dist/check/laravel.d.ts +2 -0
  35. package/dist/check/laravel.js +44 -0
  36. package/dist/check/parsers.d.ts +12 -0
  37. package/dist/check/parsers.js +278 -0
  38. package/dist/check/schema.d.ts +2 -0
  39. package/dist/check/schema.js +114 -0
  40. package/dist/cloud.d.ts +42 -0
  41. package/dist/cloud.js +334 -0
  42. package/dist/compare/shared.d.ts +42 -0
  43. package/dist/compare/shared.js +115 -0
  44. package/dist/compare.d.ts +3 -0
  45. package/dist/compare.js +33 -0
  46. package/dist/config.d.ts +146 -0
  47. package/dist/config.js +382 -0
  48. package/dist/desktop/a11y.d.ts +18 -0
  49. package/dist/desktop/a11y.js +74 -0
  50. package/dist/desktop/capture.d.ts +13 -0
  51. package/dist/desktop/capture.js +80 -0
  52. package/dist/desktop/macos.d.ts +8 -0
  53. package/dist/desktop/macos.js +98 -0
  54. package/dist/desktop/ocr.d.ts +17 -0
  55. package/dist/desktop/ocr.js +99 -0
  56. package/dist/diff/lcs.d.ts +5 -0
  57. package/dist/diff/lcs.js +42 -0
  58. package/dist/diff/numeric.d.ts +6 -0
  59. package/dist/diff/numeric.js +24 -0
  60. package/dist/diff/pixel.d.ts +23 -0
  61. package/dist/diff/pixel.js +97 -0
  62. package/dist/diff/structural.d.ts +11 -0
  63. package/dist/diff/structural.js +38 -0
  64. package/dist/diff/text.d.ts +7 -0
  65. package/dist/diff/text.js +64 -0
  66. package/dist/diff/tree.d.ts +46 -0
  67. package/dist/diff/tree.js +188 -0
  68. package/dist/doctor.d.ts +18 -0
  69. package/dist/doctor.js +57 -0
  70. package/dist/game/capture.d.ts +24 -0
  71. package/dist/game/capture.js +51 -0
  72. package/dist/game/protocol.d.ts +30 -0
  73. package/dist/game/protocol.js +146 -0
  74. package/dist/game/walkthrough.d.ts +45 -0
  75. package/dist/game/walkthrough.js +85 -0
  76. package/dist/guards.d.ts +2 -0
  77. package/dist/guards.js +15 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.js +504 -0
  80. package/dist/json.d.ts +2 -0
  81. package/dist/json.js +40 -0
  82. package/dist/lifecycle.d.ts +14 -0
  83. package/dist/lifecycle.js +190 -0
  84. package/dist/normalization.d.ts +4 -0
  85. package/dist/normalization.js +27 -0
  86. package/dist/perf/ab.d.ts +6 -0
  87. package/dist/perf/ab.js +89 -0
  88. package/dist/perf/autocannon.d.ts +6 -0
  89. package/dist/perf/autocannon.js +101 -0
  90. package/dist/perf/capture.d.ts +7 -0
  91. package/dist/perf/capture.js +6 -0
  92. package/dist/perf/k6.d.ts +9 -0
  93. package/dist/perf/k6.js +44 -0
  94. package/dist/perf/parsers.d.ts +15 -0
  95. package/dist/perf/parsers.js +69 -0
  96. package/dist/perf/run.d.ts +8 -0
  97. package/dist/perf/run.js +45 -0
  98. package/dist/perf/toolOutput.d.ts +3 -0
  99. package/dist/perf/toolOutput.js +24 -0
  100. package/dist/reporters.d.ts +11 -0
  101. package/dist/reporters.js +314 -0
  102. package/dist/runner.d.ts +48 -0
  103. package/dist/runner.js +352 -0
  104. package/dist/snapshot.d.ts +48 -0
  105. package/dist/snapshot.js +37 -0
  106. package/dist/terminal/ansi.d.ts +21 -0
  107. package/dist/terminal/ansi.js +144 -0
  108. package/dist/terminal/capture.d.ts +30 -0
  109. package/dist/terminal/capture.js +91 -0
  110. package/dist/tty.d.ts +72 -0
  111. package/dist/tty.js +175 -0
  112. package/dist/web/domSnapshot.d.ts +27 -0
  113. package/dist/web/domSnapshot.js +55 -0
  114. package/dist/web/playwrightCapture.d.ts +16 -0
  115. package/dist/web/playwrightCapture.js +64 -0
  116. package/package.json +79 -0
@@ -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,2 @@
1
+ import type { CaptureType } from "./types.js";
2
+ export declare const desktop: CaptureType;
@@ -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,4 @@
1
+ import type { PixelTolerance } from "../diff/pixel.js";
2
+ import type { CaptureType } from "./types.js";
3
+ export declare const GAME_PIXEL_TOLERANCE: PixelTolerance;
4
+ export declare const game: CaptureType;
@@ -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,2 @@
1
+ import type { CaptureType } from "./types.js";
2
+ export declare const performance: CaptureType;
@@ -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,4 @@
1
+ import type { CaptureTarget } from "../config.js";
2
+ import type { CaptureType } from "./types.js";
3
+ export declare const captureTypes: Record<CaptureTarget["kind"], CaptureType>;
4
+ export declare function isCaptureKind(value: unknown): value is CaptureTarget["kind"];
@@ -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,2 @@
1
+ import type { CaptureType } from "./types.js";
2
+ export declare const terminal: CaptureType;
@@ -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 {};
@@ -0,0 +1,3 @@
1
+ import type { CaptureType } from "./types.js";
2
+ export declare function digestScreenshot(screenshot: Record<string, unknown>): unknown;
3
+ export declare const web: CaptureType;