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
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
+ }