dungbeetle 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/NOTICE +19 -0
- package/README.md +139 -0
- package/dist/api/capture.d.ts +24 -0
- package/dist/api/capture.js +61 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +38 -0
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +9 -0
- package/dist/capture.d.ts +15 -0
- package/dist/capture.js +7 -0
- package/dist/captures/api.d.ts +2 -0
- package/dist/captures/api.js +114 -0
- package/dist/captures/check.d.ts +2 -0
- package/dist/captures/check.js +116 -0
- package/dist/captures/desktop.d.ts +2 -0
- package/dist/captures/desktop.js +97 -0
- package/dist/captures/game.d.ts +4 -0
- package/dist/captures/game.js +266 -0
- package/dist/captures/performance.d.ts +2 -0
- package/dist/captures/performance.js +47 -0
- package/dist/captures/registry.d.ts +4 -0
- package/dist/captures/registry.js +23 -0
- package/dist/captures/terminal.d.ts +2 -0
- package/dist/captures/terminal.js +65 -0
- package/dist/captures/types.d.ts +18 -0
- package/dist/captures/types.js +1 -0
- package/dist/captures/web.d.ts +3 -0
- package/dist/captures/web.js +248 -0
- package/dist/check/capture.d.ts +15 -0
- package/dist/check/capture.js +76 -0
- package/dist/check/junit.d.ts +9 -0
- package/dist/check/junit.js +51 -0
- package/dist/check/laravel.d.ts +2 -0
- package/dist/check/laravel.js +44 -0
- package/dist/check/parsers.d.ts +12 -0
- package/dist/check/parsers.js +278 -0
- package/dist/check/schema.d.ts +2 -0
- package/dist/check/schema.js +114 -0
- package/dist/cloud.d.ts +42 -0
- package/dist/cloud.js +334 -0
- package/dist/compare/shared.d.ts +42 -0
- package/dist/compare/shared.js +115 -0
- package/dist/compare.d.ts +3 -0
- package/dist/compare.js +33 -0
- package/dist/config.d.ts +146 -0
- package/dist/config.js +382 -0
- package/dist/desktop/a11y.d.ts +18 -0
- package/dist/desktop/a11y.js +74 -0
- package/dist/desktop/capture.d.ts +13 -0
- package/dist/desktop/capture.js +80 -0
- package/dist/desktop/macos.d.ts +8 -0
- package/dist/desktop/macos.js +98 -0
- package/dist/desktop/ocr.d.ts +17 -0
- package/dist/desktop/ocr.js +99 -0
- package/dist/diff/lcs.d.ts +5 -0
- package/dist/diff/lcs.js +42 -0
- package/dist/diff/numeric.d.ts +6 -0
- package/dist/diff/numeric.js +24 -0
- package/dist/diff/pixel.d.ts +23 -0
- package/dist/diff/pixel.js +97 -0
- package/dist/diff/structural.d.ts +11 -0
- package/dist/diff/structural.js +38 -0
- package/dist/diff/text.d.ts +7 -0
- package/dist/diff/text.js +64 -0
- package/dist/diff/tree.d.ts +46 -0
- package/dist/diff/tree.js +188 -0
- package/dist/doctor.d.ts +18 -0
- package/dist/doctor.js +57 -0
- package/dist/game/capture.d.ts +24 -0
- package/dist/game/capture.js +51 -0
- package/dist/game/protocol.d.ts +30 -0
- package/dist/game/protocol.js +146 -0
- package/dist/game/walkthrough.d.ts +45 -0
- package/dist/game/walkthrough.js +85 -0
- package/dist/guards.d.ts +2 -0
- package/dist/guards.js +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +504 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +40 -0
- package/dist/lifecycle.d.ts +14 -0
- package/dist/lifecycle.js +190 -0
- package/dist/normalization.d.ts +4 -0
- package/dist/normalization.js +27 -0
- package/dist/perf/ab.d.ts +6 -0
- package/dist/perf/ab.js +89 -0
- package/dist/perf/autocannon.d.ts +6 -0
- package/dist/perf/autocannon.js +101 -0
- package/dist/perf/capture.d.ts +7 -0
- package/dist/perf/capture.js +6 -0
- package/dist/perf/k6.d.ts +9 -0
- package/dist/perf/k6.js +44 -0
- package/dist/perf/parsers.d.ts +15 -0
- package/dist/perf/parsers.js +69 -0
- package/dist/perf/run.d.ts +8 -0
- package/dist/perf/run.js +45 -0
- package/dist/perf/toolOutput.d.ts +3 -0
- package/dist/perf/toolOutput.js +24 -0
- package/dist/reporters.d.ts +11 -0
- package/dist/reporters.js +314 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +352 -0
- package/dist/snapshot.d.ts +48 -0
- package/dist/snapshot.js +37 -0
- package/dist/terminal/ansi.d.ts +21 -0
- package/dist/terminal/ansi.js +144 -0
- package/dist/terminal/capture.d.ts +30 -0
- package/dist/terminal/capture.js +91 -0
- package/dist/tty.d.ts +72 -0
- package/dist/tty.js +175 -0
- package/dist/web/domSnapshot.d.ts +27 -0
- package/dist/web/domSnapshot.js +55 -0
- package/dist/web/playwrightCapture.d.ts +16 -0
- package/dist/web/playwrightCapture.js +64 -0
- package/package.json +79 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { BRAND_NAME } from "./brand.js";
|
|
5
|
+
import { captureTypes, isCaptureKind } from "./captures/registry.js";
|
|
6
|
+
import { parseJsonFile } from "./json.js";
|
|
7
|
+
export const CONFIG_FILE_NAME = "dungbeetle.config.json";
|
|
8
|
+
export const defaultMaskRules = [
|
|
9
|
+
{
|
|
10
|
+
name: "iso-timestamp",
|
|
11
|
+
pattern: "\\b\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z\\b",
|
|
12
|
+
replacement: "<iso-timestamp>"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "uuid",
|
|
16
|
+
pattern: "\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\\b",
|
|
17
|
+
replacement: "<uuid>"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "unix-temp-path",
|
|
21
|
+
pattern: "/(?:var/)?tmp/[^\\s\"']+",
|
|
22
|
+
replacement: "<temp-path>"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "windows-temp-path",
|
|
26
|
+
pattern: "\\b[A-Za-z]:\\\\(?:Users\\\\[^\\\\]+\\\\AppData\\\\Local\\\\Temp|Temp)\\\\[^\\s\"']+",
|
|
27
|
+
replacement: "<temp-path>"
|
|
28
|
+
}
|
|
29
|
+
];
|
|
30
|
+
export function createDefaultConfig(projectName = "dungbeetle-project") {
|
|
31
|
+
return {
|
|
32
|
+
version: 1,
|
|
33
|
+
project: {
|
|
34
|
+
name: projectName
|
|
35
|
+
},
|
|
36
|
+
baselinesDir: "dungbeetle.snapshots",
|
|
37
|
+
artifactsDir: ".dungbeetle/artifacts",
|
|
38
|
+
lifecycle: {
|
|
39
|
+
setup: [],
|
|
40
|
+
start: [],
|
|
41
|
+
wait: {
|
|
42
|
+
timeoutMs: 30_000
|
|
43
|
+
},
|
|
44
|
+
capture: [],
|
|
45
|
+
teardown: []
|
|
46
|
+
},
|
|
47
|
+
normalization: {
|
|
48
|
+
ansi: "semantic",
|
|
49
|
+
masks: defaultMaskRules
|
|
50
|
+
},
|
|
51
|
+
comparison: {
|
|
52
|
+
numericTolerance: {},
|
|
53
|
+
pixelTolerance: {}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// The capture targets are modelled as a discriminated union on `kind` so zod
|
|
58
|
+
// preserves each kind's field typing and strips nothing. Semantic rules
|
|
59
|
+
// (driver values, image-source requirements, mask regex validity, tolerance
|
|
60
|
+
// ranges) deliberately stay LENIENT here — `driver`, mask `pattern` and the
|
|
61
|
+
// tolerance numbers are plain string/number so their bespoke, test-pinned error
|
|
62
|
+
// messages keep coming from the retained registry/structural validators below,
|
|
63
|
+
// not from zod. The union output is asserted back to `CaptureTarget`; the only
|
|
64
|
+
// looseness vs. the hand-written type is `driver` (string vs. literal union),
|
|
65
|
+
// which the registry validator narrows at runtime.
|
|
66
|
+
const terminalCaptureSchema = z.object({
|
|
67
|
+
kind: z.literal("terminal"),
|
|
68
|
+
name: z.string(),
|
|
69
|
+
command: z.string(),
|
|
70
|
+
cwd: z.string().optional(),
|
|
71
|
+
timeoutMs: z.number().optional()
|
|
72
|
+
});
|
|
73
|
+
const pixelToleranceSchema = z.object({
|
|
74
|
+
maxChangedRatio: z.number().optional(),
|
|
75
|
+
perChannelThreshold: z.number().optional()
|
|
76
|
+
});
|
|
77
|
+
const webCaptureSchema = z.object({
|
|
78
|
+
kind: z.literal("web"),
|
|
79
|
+
name: z.string(),
|
|
80
|
+
driver: z.string().optional(),
|
|
81
|
+
url: z.string().optional(),
|
|
82
|
+
html: z.string().optional(),
|
|
83
|
+
screenshotFile: z.string().optional(),
|
|
84
|
+
screenshotMode: z.string().optional(),
|
|
85
|
+
pixelTolerance: pixelToleranceSchema.optional(),
|
|
86
|
+
accessibility: z.boolean().optional(),
|
|
87
|
+
screenshot: z.boolean().optional(),
|
|
88
|
+
browser: z
|
|
89
|
+
.object({
|
|
90
|
+
channel: z.string().optional(),
|
|
91
|
+
executablePath: z.string().optional()
|
|
92
|
+
})
|
|
93
|
+
.optional(),
|
|
94
|
+
viewport: z
|
|
95
|
+
.object({
|
|
96
|
+
width: z.number(),
|
|
97
|
+
height: z.number()
|
|
98
|
+
})
|
|
99
|
+
.optional(),
|
|
100
|
+
timeoutMs: z.number().optional()
|
|
101
|
+
});
|
|
102
|
+
const checkCaptureSchema = z.object({
|
|
103
|
+
kind: z.literal("check"),
|
|
104
|
+
name: z.string(),
|
|
105
|
+
tool: z.string(),
|
|
106
|
+
command: z.string().optional(),
|
|
107
|
+
output: z.string().optional(),
|
|
108
|
+
cwd: z.string().optional(),
|
|
109
|
+
timeoutMs: z.number().optional()
|
|
110
|
+
});
|
|
111
|
+
const performanceCaptureSchema = z.object({
|
|
112
|
+
kind: z.literal("performance"),
|
|
113
|
+
name: z.string(),
|
|
114
|
+
tool: z.string().optional(),
|
|
115
|
+
script: z.string().optional(),
|
|
116
|
+
command: z.string().optional(),
|
|
117
|
+
summary: z.string().optional(),
|
|
118
|
+
metrics: z.array(z.string()).optional(),
|
|
119
|
+
k6Path: z.string().optional(),
|
|
120
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
121
|
+
cwd: z.string().optional(),
|
|
122
|
+
timeoutMs: z.number().optional()
|
|
123
|
+
});
|
|
124
|
+
const desktopCaptureSchema = z.object({
|
|
125
|
+
kind: z.literal("desktop"),
|
|
126
|
+
name: z.string(),
|
|
127
|
+
driver: z.string().optional(),
|
|
128
|
+
app: z.string().optional(),
|
|
129
|
+
tree: z.string().optional(),
|
|
130
|
+
command: z.string().optional(),
|
|
131
|
+
maxDepth: z.number().optional(),
|
|
132
|
+
ocrFallback: z.boolean().optional(),
|
|
133
|
+
screenshot: z.string().optional(),
|
|
134
|
+
screenshotCommand: z.string().optional(),
|
|
135
|
+
ocrCommand: z.string().optional(),
|
|
136
|
+
cwd: z.string().optional(),
|
|
137
|
+
timeoutMs: z.number().optional()
|
|
138
|
+
});
|
|
139
|
+
const gameCaptureSchema = z.object({
|
|
140
|
+
kind: z.literal("game"),
|
|
141
|
+
name: z.string(),
|
|
142
|
+
engine: z.string(),
|
|
143
|
+
project: z.string(),
|
|
144
|
+
walkthrough: z.string(),
|
|
145
|
+
mode: z.string().optional(),
|
|
146
|
+
enginePath: z.string().optional(),
|
|
147
|
+
seed: z.number().optional(),
|
|
148
|
+
physicsFps: z.number().optional(),
|
|
149
|
+
pixelTolerance: pixelToleranceSchema.optional(),
|
|
150
|
+
screenshotMode: z.string().optional(),
|
|
151
|
+
markers: z
|
|
152
|
+
.record(z.string(), z.object({ pixelTolerance: pixelToleranceSchema.optional() }))
|
|
153
|
+
.optional(),
|
|
154
|
+
timeoutMs: z.number().optional()
|
|
155
|
+
});
|
|
156
|
+
const apiCaptureSchema = z.object({
|
|
157
|
+
kind: z.literal("api"),
|
|
158
|
+
name: z.string(),
|
|
159
|
+
url: z.string(),
|
|
160
|
+
method: z.string().optional(),
|
|
161
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
162
|
+
body: z.string().optional(),
|
|
163
|
+
query: z.string().optional(),
|
|
164
|
+
variables: z.record(z.string(), z.unknown()).optional(),
|
|
165
|
+
includeHeaders: z.array(z.string()).optional(),
|
|
166
|
+
timeoutMs: z.number().optional()
|
|
167
|
+
});
|
|
168
|
+
const captureTargetSchema = z
|
|
169
|
+
.discriminatedUnion("kind", [
|
|
170
|
+
apiCaptureSchema,
|
|
171
|
+
terminalCaptureSchema,
|
|
172
|
+
checkCaptureSchema,
|
|
173
|
+
webCaptureSchema,
|
|
174
|
+
performanceCaptureSchema,
|
|
175
|
+
desktopCaptureSchema,
|
|
176
|
+
gameCaptureSchema
|
|
177
|
+
])
|
|
178
|
+
.transform((target) => target);
|
|
179
|
+
const maskRuleSchema = z.object({
|
|
180
|
+
name: z.string(),
|
|
181
|
+
pattern: z.string(),
|
|
182
|
+
replacement: z.string()
|
|
183
|
+
});
|
|
184
|
+
// Nested objects use `.prefault({})` (not `.default({})`) so that when a parent
|
|
185
|
+
// key is omitted the value is still parsed THROUGH the schema and its own inner
|
|
186
|
+
// defaults fire — this nested-default chain IS the merge that replaces the old
|
|
187
|
+
// hand-rolled deep spread. Leaf optional fields (e.g. tolerance keys, wait
|
|
188
|
+
// command/url) carry NO default, so an omitted optional is ABSENT from the
|
|
189
|
+
// output rather than set to `undefined`.
|
|
190
|
+
function buildConfigSchema(defaultProjectName) {
|
|
191
|
+
return z
|
|
192
|
+
.object({
|
|
193
|
+
version: z.literal(1).default(1),
|
|
194
|
+
project: z
|
|
195
|
+
.object({
|
|
196
|
+
name: z.string().default(defaultProjectName)
|
|
197
|
+
})
|
|
198
|
+
.prefault({}),
|
|
199
|
+
baselinesDir: z.string().default("dungbeetle.snapshots"),
|
|
200
|
+
artifactsDir: z.string().default(".dungbeetle/artifacts"),
|
|
201
|
+
lifecycle: z
|
|
202
|
+
.object({
|
|
203
|
+
setup: z.array(z.string()).default([]),
|
|
204
|
+
start: z.array(z.string()).default([]),
|
|
205
|
+
wait: z
|
|
206
|
+
.object({
|
|
207
|
+
command: z.string().optional(),
|
|
208
|
+
url: z.string().optional(),
|
|
209
|
+
timeoutMs: z.number().default(30_000)
|
|
210
|
+
})
|
|
211
|
+
.prefault({}),
|
|
212
|
+
capture: z.array(captureTargetSchema).default([]),
|
|
213
|
+
teardown: z.array(z.string()).default([])
|
|
214
|
+
})
|
|
215
|
+
.prefault({}),
|
|
216
|
+
normalization: z
|
|
217
|
+
.object({
|
|
218
|
+
ansi: z.enum(["semantic", "strip"]).default("semantic"),
|
|
219
|
+
masks: z.array(maskRuleSchema).default(defaultMaskRules)
|
|
220
|
+
})
|
|
221
|
+
.prefault({}),
|
|
222
|
+
comparison: z
|
|
223
|
+
.object({
|
|
224
|
+
numericTolerance: z
|
|
225
|
+
.object({
|
|
226
|
+
absolute: z.number().optional(),
|
|
227
|
+
relative: z.number().optional()
|
|
228
|
+
})
|
|
229
|
+
.default({}),
|
|
230
|
+
pixelTolerance: z
|
|
231
|
+
.object({
|
|
232
|
+
maxChangedRatio: z.number().optional(),
|
|
233
|
+
perChannelThreshold: z.number().optional()
|
|
234
|
+
})
|
|
235
|
+
.default({})
|
|
236
|
+
})
|
|
237
|
+
.prefault({})
|
|
238
|
+
})
|
|
239
|
+
.strip();
|
|
240
|
+
}
|
|
241
|
+
export async function findConfigPath(cwd = process.cwd()) {
|
|
242
|
+
let current = path.resolve(cwd);
|
|
243
|
+
while (true) {
|
|
244
|
+
const candidate = path.join(current, CONFIG_FILE_NAME);
|
|
245
|
+
try {
|
|
246
|
+
await access(candidate);
|
|
247
|
+
return candidate;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
const parent = path.dirname(current);
|
|
251
|
+
if (parent === current) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
current = parent;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export async function loadConfig(configPath, cwd = process.cwd()) {
|
|
259
|
+
const resolvedPath = configPath ? path.resolve(cwd, configPath) : await findConfigPath(cwd);
|
|
260
|
+
if (!resolvedPath) {
|
|
261
|
+
return createDefaultConfig(path.basename(cwd));
|
|
262
|
+
}
|
|
263
|
+
const raw = await readFile(resolvedPath, "utf8");
|
|
264
|
+
const data = parseJsonFile(raw, resolvedPath, (message) => {
|
|
265
|
+
throw new ConfigValidationError([message], resolvedPath);
|
|
266
|
+
});
|
|
267
|
+
// zod owns the structural shape, the defaults and the merge: nested
|
|
268
|
+
// `.prefault()`/`.default()` fill every omitted key from defaults, so the
|
|
269
|
+
// file is layered onto defaults without a hand-rolled spread. The project
|
|
270
|
+
// name falls back to the config dir's basename when omitted.
|
|
271
|
+
const schema = buildConfigSchema(path.basename(path.dirname(resolvedPath)));
|
|
272
|
+
const result = schema.safeParse(data);
|
|
273
|
+
if (!result.success) {
|
|
274
|
+
const issues = result.error.issues.map((issue) => {
|
|
275
|
+
const location = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
276
|
+
return `${location}${issue.message}`;
|
|
277
|
+
});
|
|
278
|
+
throw new ConfigValidationError(issues, resolvedPath);
|
|
279
|
+
}
|
|
280
|
+
// The assignment is the compile-time drift guard: if the schema's parsed
|
|
281
|
+
// output stops being assignable to the hand-written DungbeetleConfig, tsc fails
|
|
282
|
+
// here.
|
|
283
|
+
const config = result.data;
|
|
284
|
+
// Retained semantic validation (per-kind registry validators + mask regex
|
|
285
|
+
// validity + tolerance ranges) runs AFTER zod so its bespoke, test-pinned
|
|
286
|
+
// error messages are preserved.
|
|
287
|
+
validateConfig(config, resolvedPath);
|
|
288
|
+
return config;
|
|
289
|
+
}
|
|
290
|
+
export class ConfigValidationError extends Error {
|
|
291
|
+
issues;
|
|
292
|
+
configPath;
|
|
293
|
+
constructor(issues, configPath) {
|
|
294
|
+
const location = configPath ? ` in ${configPath}` : "";
|
|
295
|
+
super(`Invalid ${BRAND_NAME} config${location}:\n- ${issues.join("\n- ")}`);
|
|
296
|
+
this.issues = issues;
|
|
297
|
+
this.configPath = configPath;
|
|
298
|
+
this.name = "ConfigValidationError";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export function validateConfig(config, configPath) {
|
|
302
|
+
const issues = [];
|
|
303
|
+
if (!config.project.name || typeof config.project.name !== "string") {
|
|
304
|
+
issues.push("project.name must be a non-empty string.");
|
|
305
|
+
}
|
|
306
|
+
if (typeof config.baselinesDir !== "string" || !config.baselinesDir) {
|
|
307
|
+
issues.push("baselinesDir must be a non-empty string.");
|
|
308
|
+
}
|
|
309
|
+
if (!Number.isFinite(config.lifecycle.wait.timeoutMs) || config.lifecycle.wait.timeoutMs <= 0) {
|
|
310
|
+
issues.push("lifecycle.wait.timeoutMs must be a positive number.");
|
|
311
|
+
}
|
|
312
|
+
if (!Array.isArray(config.lifecycle.capture)) {
|
|
313
|
+
issues.push("lifecycle.capture must be an array.");
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
config.lifecycle.capture.forEach((target, index) => {
|
|
317
|
+
validateCaptureTarget(target, index, issues);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
for (const phase of ["setup", "start", "teardown"]) {
|
|
321
|
+
if (!Array.isArray(config.lifecycle[phase])) {
|
|
322
|
+
issues.push(`lifecycle.${phase} must be an array of command strings.`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const key of ["absolute", "relative"]) {
|
|
326
|
+
const value = config.comparison.numericTolerance[key];
|
|
327
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
328
|
+
issues.push(`comparison.numericTolerance.${key} must be a non-negative number.`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const { maxChangedRatio, perChannelThreshold } = config.comparison.pixelTolerance;
|
|
332
|
+
if (maxChangedRatio !== undefined &&
|
|
333
|
+
(!Number.isFinite(maxChangedRatio) || maxChangedRatio < 0 || maxChangedRatio > 1)) {
|
|
334
|
+
issues.push("comparison.pixelTolerance.maxChangedRatio must be between 0 and 1.");
|
|
335
|
+
}
|
|
336
|
+
if (perChannelThreshold !== undefined &&
|
|
337
|
+
(!Number.isFinite(perChannelThreshold) || perChannelThreshold < 0 || perChannelThreshold > 255)) {
|
|
338
|
+
issues.push("comparison.pixelTolerance.perChannelThreshold must be between 0 and 255.");
|
|
339
|
+
}
|
|
340
|
+
if (!Array.isArray(config.normalization.masks)) {
|
|
341
|
+
issues.push("normalization.masks must be an array.");
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
config.normalization.masks.forEach((mask, index) => {
|
|
345
|
+
if (!mask || typeof mask.pattern !== "string" || typeof mask.replacement !== "string") {
|
|
346
|
+
issues.push(`normalization.masks[${index}] must have string "pattern" and "replacement".`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
new RegExp(mask.pattern);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
issues.push(`normalization.masks[${index}] has an invalid pattern: ${error instanceof Error ? error.message : String(error)}`);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (issues.length > 0) {
|
|
358
|
+
throw new ConfigValidationError(issues, configPath);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function validateCaptureTarget(target, index, issues) {
|
|
362
|
+
const label = `lifecycle.capture[${index}]`;
|
|
363
|
+
if (!target || typeof target !== "object") {
|
|
364
|
+
issues.push(`${label} must be an object.`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!target.name || typeof target.name !== "string") {
|
|
368
|
+
issues.push(`${label} must have a non-empty "name".`);
|
|
369
|
+
}
|
|
370
|
+
if (!isCaptureKind(target.kind)) {
|
|
371
|
+
issues.push(`${label} has unknown kind "${target.kind ?? ""}".`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
captureTypes[target.kind].validateConfig(target, { label, issues });
|
|
375
|
+
}
|
|
376
|
+
export async function writeDefaultConfig(outputPath = CONFIG_FILE_NAME, projectName = path.basename(process.cwd()), targets = []) {
|
|
377
|
+
const resolvedPath = path.resolve(outputPath);
|
|
378
|
+
const config = createDefaultConfig(projectName);
|
|
379
|
+
config.lifecycle.capture = targets;
|
|
380
|
+
await writeFile(resolvedPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
381
|
+
return resolvedPath;
|
|
382
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MaskRule } from "../config.js";
|
|
2
|
+
export type A11yNode = {
|
|
3
|
+
role: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
value?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
state?: string[];
|
|
8
|
+
children: A11yNode[];
|
|
9
|
+
};
|
|
10
|
+
export type DesktopSnapshot = {
|
|
11
|
+
kind: "desktop";
|
|
12
|
+
tool?: string;
|
|
13
|
+
root: A11yNode;
|
|
14
|
+
};
|
|
15
|
+
export type NormalizeA11yOptions = {
|
|
16
|
+
maskRules?: MaskRule[];
|
|
17
|
+
};
|
|
18
|
+
export declare function normalizeA11yTree(raw: unknown, options?: NormalizeA11yOptions): DesktopSnapshot;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { applyMaskRules } from "../normalization.js";
|
|
2
|
+
import { isRecord } from "../guards.js";
|
|
3
|
+
export function normalizeA11yTree(raw, options = {}) {
|
|
4
|
+
if (!isRecord(raw)) {
|
|
5
|
+
throw new Error("Invalid desktop snapshot: expected an object.");
|
|
6
|
+
}
|
|
7
|
+
// Accept either a bare tree or a wrapper of the form { tool?, root }.
|
|
8
|
+
const tool = typeof raw.tool === "string" ? raw.tool : undefined;
|
|
9
|
+
const rootSource = isRecord(raw.root) ? raw.root : raw;
|
|
10
|
+
const snapshot = {
|
|
11
|
+
kind: "desktop",
|
|
12
|
+
root: normalizeNode(rootSource, options.maskRules ?? [])
|
|
13
|
+
};
|
|
14
|
+
if (tool) {
|
|
15
|
+
snapshot.tool = tool;
|
|
16
|
+
}
|
|
17
|
+
return snapshot;
|
|
18
|
+
}
|
|
19
|
+
function normalizeNode(raw, masks) {
|
|
20
|
+
if (!isRecord(raw)) {
|
|
21
|
+
throw new Error("Invalid accessibility node: expected an object.");
|
|
22
|
+
}
|
|
23
|
+
const role = pickString(raw.role ?? raw.type) ?? "unknown";
|
|
24
|
+
const node = {
|
|
25
|
+
role,
|
|
26
|
+
children: normalizeChildren(raw.children, masks)
|
|
27
|
+
};
|
|
28
|
+
const name = maskOptional(pickString(raw.name ?? raw.title ?? raw.label), masks);
|
|
29
|
+
if (name !== undefined) {
|
|
30
|
+
node.name = name;
|
|
31
|
+
}
|
|
32
|
+
const value = maskOptional(pickString(raw.value), masks);
|
|
33
|
+
if (value !== undefined) {
|
|
34
|
+
node.value = value;
|
|
35
|
+
}
|
|
36
|
+
const description = maskOptional(pickString(raw.description ?? raw.help), masks);
|
|
37
|
+
if (description !== undefined) {
|
|
38
|
+
node.description = description;
|
|
39
|
+
}
|
|
40
|
+
const state = normalizeState(raw.state);
|
|
41
|
+
if (state.length > 0) {
|
|
42
|
+
node.state = state;
|
|
43
|
+
}
|
|
44
|
+
return node;
|
|
45
|
+
}
|
|
46
|
+
function normalizeChildren(raw, masks) {
|
|
47
|
+
if (!Array.isArray(raw)) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return raw.filter(isRecord).map((child) => normalizeNode(child, masks));
|
|
51
|
+
}
|
|
52
|
+
// Normalize the state to a sorted, de-duplicated list of flag names so the
|
|
53
|
+
// representation is stable regardless of input ordering or shape (array of
|
|
54
|
+
// strings, or an object of boolean flags).
|
|
55
|
+
function normalizeState(raw) {
|
|
56
|
+
if (Array.isArray(raw)) {
|
|
57
|
+
return dedupeSort(raw.filter((value) => typeof value === "string"));
|
|
58
|
+
}
|
|
59
|
+
if (isRecord(raw)) {
|
|
60
|
+
return dedupeSort(Object.entries(raw)
|
|
61
|
+
.filter(([, value]) => value === true)
|
|
62
|
+
.map(([key]) => key));
|
|
63
|
+
}
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
function dedupeSort(values) {
|
|
67
|
+
return [...new Set(values)].sort();
|
|
68
|
+
}
|
|
69
|
+
function maskOptional(value, masks) {
|
|
70
|
+
return value === undefined ? undefined : applyMaskRules(value, masks);
|
|
71
|
+
}
|
|
72
|
+
function pickString(value) {
|
|
73
|
+
return typeof value === "string" ? value : undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CaptureTarget, MaskRule } from "../config.js";
|
|
2
|
+
import { type DesktopSnapshot } from "./a11y.js";
|
|
3
|
+
import { type ShellRunner } from "./ocr.js";
|
|
4
|
+
export type DesktopTarget = Extract<CaptureTarget, {
|
|
5
|
+
kind: "desktop";
|
|
6
|
+
}>;
|
|
7
|
+
export type DesktopCaptureOptions = {
|
|
8
|
+
cwd: string;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
maskRules: MaskRule[];
|
|
11
|
+
run?: ShellRunner;
|
|
12
|
+
};
|
|
13
|
+
export declare function captureDesktop(target: DesktopTarget, options: DesktopCaptureOptions): Promise<DesktopSnapshot>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { parseJsonFile } from "../json.js";
|
|
6
|
+
import { normalizeA11yTree } from "./a11y.js";
|
|
7
|
+
import { captureMacosAx } from "./macos.js";
|
|
8
|
+
import { captureDesktopOcr } from "./ocr.js";
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
// Capture a desktop accessibility snapshot. The primary source is one of: the
|
|
11
|
+
// macOS AX driver, a saved tree JSON file (`tree`), or a command that prints an
|
|
12
|
+
// accessibility tree as JSON (`command`). For targets without reliable
|
|
13
|
+
// structured access, a screenshot + OCR path produces an equivalent snapshot —
|
|
14
|
+
// either directly (`driver: "ocr"`) or as a fallback (`ocrFallback: true`) when
|
|
15
|
+
// the structured capture comes back empty or fails.
|
|
16
|
+
export async function captureDesktop(target, options) {
|
|
17
|
+
if (target.driver === "ocr") {
|
|
18
|
+
return captureDesktopOcr(toOcrOptions(target, options));
|
|
19
|
+
}
|
|
20
|
+
if (!target.ocrFallback) {
|
|
21
|
+
return captureStructured(target, options);
|
|
22
|
+
}
|
|
23
|
+
// Fallback mode: prefer the structured tree, but drop to pixels + OCR when it
|
|
24
|
+
// yields nothing usable (an app the accessibility API can't see into) or the
|
|
25
|
+
// capture throws (e.g. permission denied). The OCR error surfaces if that path
|
|
26
|
+
// also fails — the caller opted into the fallback.
|
|
27
|
+
try {
|
|
28
|
+
const snapshot = await captureStructured(target, options);
|
|
29
|
+
if (snapshot.root.children.length === 0) {
|
|
30
|
+
return captureDesktopOcr(toOcrOptions(target, options));
|
|
31
|
+
}
|
|
32
|
+
return snapshot;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return captureDesktopOcr(toOcrOptions(target, options));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function toOcrOptions(target, options) {
|
|
39
|
+
return {
|
|
40
|
+
app: target.app,
|
|
41
|
+
screenshot: target.screenshot,
|
|
42
|
+
screenshotCommand: target.screenshotCommand,
|
|
43
|
+
ocrCommand: target.ocrCommand,
|
|
44
|
+
cwd: options.cwd,
|
|
45
|
+
timeoutMs: target.timeoutMs ?? options.timeoutMs,
|
|
46
|
+
maskRules: options.maskRules,
|
|
47
|
+
run: options.run
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function captureStructured(target, options) {
|
|
51
|
+
let raw;
|
|
52
|
+
if (target.driver === "macos-ax") {
|
|
53
|
+
raw = await captureMacosAx({
|
|
54
|
+
app: target.app ?? "",
|
|
55
|
+
maxDepth: target.maxDepth,
|
|
56
|
+
timeoutMs: target.timeoutMs ?? options.timeoutMs
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else if (target.tree) {
|
|
60
|
+
const treePath = path.resolve(options.cwd, target.tree);
|
|
61
|
+
raw = parseJsonFile(await readFile(treePath, "utf8"), treePath);
|
|
62
|
+
}
|
|
63
|
+
else if (target.command) {
|
|
64
|
+
const { stdout } = await execAsync(target.command, {
|
|
65
|
+
cwd: path.resolve(options.cwd, target.cwd ?? "."),
|
|
66
|
+
timeout: target.timeoutMs ?? options.timeoutMs,
|
|
67
|
+
maxBuffer: 32 * 1024 * 1024
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(stdout);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new Error(`Desktop target "${target.name}" command did not emit valid JSON on stdout.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
throw new Error(`Desktop target "${target.name}" must set a "driver", "tree", or "command".`);
|
|
78
|
+
}
|
|
79
|
+
return normalizeA11yTree(raw, { maskRules: options.maskRules });
|
|
80
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const MACOS_AX_JXA = "\nfunction run(argv) {\n var appName = argv[0];\n var maxDepth = parseInt(argv[1], 10);\n if (isNaN(maxDepth)) { maxDepth = 40; }\n\n var se = Application('System Events');\n var procs = se.applicationProcesses.whose({ name: appName })();\n if (!procs.length) {\n return JSON.stringify({ error: 'process-not-found', app: appName });\n }\n var proc = procs[0];\n\n function read(el, fn) {\n try { var v = fn(el); return v == null ? '' : String(v); } catch (e) { return ''; }\n }\n\n function walk(el, depth) {\n if (depth > maxDepth) { return null; }\n var node = { role: read(el, function (e) { return e.role(); }) || 'AXUnknown' };\n var name = read(el, function (e) { return e.title(); })\n || read(el, function (e) { return e.name(); })\n || read(el, function (e) { return e.description(); });\n if (name) { node.name = name; }\n var value = read(el, function (e) { return e.value(); });\n if (value) { node.value = value; }\n\n var children = [];\n var kids = [];\n try { kids = el.uiElements(); } catch (e) { kids = []; }\n for (var i = 0; i < kids.length; i++) {\n var child = walk(kids[i], depth + 1);\n if (child) { children.push(child); }\n }\n node.children = children;\n return node;\n }\n\n var root = { role: 'AXApplication', name: appName, children: [] };\n var windows = [];\n try { windows = proc.windows(); } catch (e) { windows = []; }\n for (var w = 0; w < windows.length; w++) {\n var win = walk(windows[w], 0);\n if (win) { root.children.push(win); }\n }\n\n return JSON.stringify({ tool: 'macos-ax', root: root });\n}\n";
|
|
2
|
+
export type MacosAxOptions = {
|
|
3
|
+
app: string;
|
|
4
|
+
maxDepth?: number;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
platform?: NodeJS.Platform;
|
|
7
|
+
};
|
|
8
|
+
export declare function captureMacosAx(options: MacosAxOptions): Promise<unknown>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { isRecord } from "../guards.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
// JXA (JavaScript for Automation) script run via `osascript -l JavaScript`. It
|
|
6
|
+
// walks a process's accessibility tree through System Events and prints a JSON
|
|
7
|
+
// tree in the shape normalizeA11yTree() accepts. Kept as a string so it ships
|
|
8
|
+
// with the compiled CLI (no sidecar resource file to copy into dist).
|
|
9
|
+
//
|
|
10
|
+
// argv: [appName, maxDepth]
|
|
11
|
+
export const MACOS_AX_JXA = `
|
|
12
|
+
function run(argv) {
|
|
13
|
+
var appName = argv[0];
|
|
14
|
+
var maxDepth = parseInt(argv[1], 10);
|
|
15
|
+
if (isNaN(maxDepth)) { maxDepth = 40; }
|
|
16
|
+
|
|
17
|
+
var se = Application('System Events');
|
|
18
|
+
var procs = se.applicationProcesses.whose({ name: appName })();
|
|
19
|
+
if (!procs.length) {
|
|
20
|
+
return JSON.stringify({ error: 'process-not-found', app: appName });
|
|
21
|
+
}
|
|
22
|
+
var proc = procs[0];
|
|
23
|
+
|
|
24
|
+
function read(el, fn) {
|
|
25
|
+
try { var v = fn(el); return v == null ? '' : String(v); } catch (e) { return ''; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function walk(el, depth) {
|
|
29
|
+
if (depth > maxDepth) { return null; }
|
|
30
|
+
var node = { role: read(el, function (e) { return e.role(); }) || 'AXUnknown' };
|
|
31
|
+
var name = read(el, function (e) { return e.title(); })
|
|
32
|
+
|| read(el, function (e) { return e.name(); })
|
|
33
|
+
|| read(el, function (e) { return e.description(); });
|
|
34
|
+
if (name) { node.name = name; }
|
|
35
|
+
var value = read(el, function (e) { return e.value(); });
|
|
36
|
+
if (value) { node.value = value; }
|
|
37
|
+
|
|
38
|
+
var children = [];
|
|
39
|
+
var kids = [];
|
|
40
|
+
try { kids = el.uiElements(); } catch (e) { kids = []; }
|
|
41
|
+
for (var i = 0; i < kids.length; i++) {
|
|
42
|
+
var child = walk(kids[i], depth + 1);
|
|
43
|
+
if (child) { children.push(child); }
|
|
44
|
+
}
|
|
45
|
+
node.children = children;
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var root = { role: 'AXApplication', name: appName, children: [] };
|
|
50
|
+
var windows = [];
|
|
51
|
+
try { windows = proc.windows(); } catch (e) { windows = []; }
|
|
52
|
+
for (var w = 0; w < windows.length; w++) {
|
|
53
|
+
var win = walk(windows[w], 0);
|
|
54
|
+
if (win) { root.children.push(win); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return JSON.stringify({ tool: 'macos-ax', root: root });
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
// Capture a macOS application's accessibility tree. Requires macOS, and the
|
|
61
|
+
// process running Dungbeetle must be granted Accessibility (and Automation)
|
|
62
|
+
// permission in System Settings → Privacy & Security. This is an experimental,
|
|
63
|
+
// local-only driver — it cannot run headless in CI.
|
|
64
|
+
export async function captureMacosAx(options) {
|
|
65
|
+
const platform = options.platform ?? process.platform;
|
|
66
|
+
if (platform !== "darwin") {
|
|
67
|
+
throw new Error(`The "macos-ax" desktop driver is only supported on macOS (current platform: ${platform}).`);
|
|
68
|
+
}
|
|
69
|
+
let stdout;
|
|
70
|
+
try {
|
|
71
|
+
({ stdout } = await execFileAsync("osascript", ["-l", "JavaScript", "-e", MACOS_AX_JXA, options.app, String(options.maxDepth ?? 40)], { timeout: options.timeoutMs ?? 30_000, maxBuffer: 64 * 1024 * 1024 }));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new Error(translateOsascriptError(error, options.app));
|
|
75
|
+
}
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(stdout);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error(`macOS accessibility capture for "${options.app}" did not return JSON.`);
|
|
82
|
+
}
|
|
83
|
+
if (isRecord(parsed) && parsed.error === "process-not-found") {
|
|
84
|
+
throw new Error(`App "${options.app}" is not running; launch it before capturing its accessibility tree.`);
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
function translateOsascriptError(error, app) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
if (/ENOENT/.test(message)) {
|
|
91
|
+
return `Could not run "osascript"; the macos-ax driver requires macOS.`;
|
|
92
|
+
}
|
|
93
|
+
// -25211 / "assistive access" is macOS's accessibility-permission error.
|
|
94
|
+
if (/-25211|assistive access|not allowed/i.test(message)) {
|
|
95
|
+
return `Accessibility permission denied. Grant the terminal/Node process Accessibility access in System Settings → Privacy & Security → Accessibility, then retry capturing "${app}".`;
|
|
96
|
+
}
|
|
97
|
+
return `macOS accessibility capture for "${app}" failed: ${message}`;
|
|
98
|
+
}
|