novahiz 0.5.2 → 0.6.0

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.
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env node
2
+ // stitch_capture.mjs — capture the running app screens plus their uiautomator
3
+ // bounds, so a Stitch fidelity comparison has fresh pixels AND measurable numbers.
4
+ //
5
+ // node stitch_capture.mjs --config stitch/verify.config.json [--only otp,home]
6
+ // [--from-current] [--dry-run] [--json] [--check] [--settle 6000]
7
+ //
8
+ // What is global vs what is project specific:
9
+ // * defaults/verify.config.defaults.json ships with Novahiz and holds every
10
+ // installation value (device policy, timings, batch size, output folder,
11
+ // serve port). Every project inherits it — nothing to copy.
12
+ // * the project's stitch/verify.config.json only declares its screens,
13
+ // routes, references and deep link template, and may override any default.
14
+ // * the device is auto-selected: the connected emulator wins, and one is
15
+ // started automatically when nothing is online (adb.autoStart).
16
+ //
17
+ // Exit code: 0 all selected screens captured, 1 a screen was not captured,
18
+ // 2 configuration/environment problem.
19
+
20
+ import { execFileSync, spawn } from 'node:child_process';
21
+ import { basename, dirname, resolve } from 'node:path';
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const MAX_BUFFER = 1024 * 1024 * 64;
26
+ const BATCH_LIMIT = 3; // adb drops the connection past ~3 chained screen operations
27
+ const SKILL_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
28
+
29
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
30
+
31
+ function fail(message) {
32
+ console.error(`stitch_capture: ${message}`);
33
+ process.exit(2);
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const out = {
38
+ config: 'stitch/verify.config.json', only: null, fromCurrent: false,
39
+ dryRun: false, json: false, settle: null, check: false,
40
+ };
41
+ for (let i = 0; i < argv.length; i += 1) {
42
+ const arg = argv[i];
43
+ if (arg === '--config') out.config = argv[++i];
44
+ else if (arg === '--only') out.only = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
45
+ else if (arg === '--settle') out.settle = Number(argv[++i]);
46
+ else if (arg === '--scroll') out.scroll = argv[++i];
47
+ else if (arg === '--from-current') out.fromCurrent = true;
48
+ else if (arg === '--dry-run') out.dryRun = true;
49
+ else if (arg === '--json') out.json = true;
50
+ else if (arg === '--check') out.check = true;
51
+ else if (arg === '--help' || arg === '-h') out.help = true;
52
+ else fail(`unknown argument: ${arg}`);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // Config files may carry // comments (they are documented that way).
58
+ function stripComments(text) {
59
+ let out = '';
60
+ let inString = false;
61
+ let inLine = false;
62
+ let inBlock = false;
63
+ for (let i = 0; i < text.length; i += 1) {
64
+ const c = text[i];
65
+ const next = text[i + 1];
66
+ if (inLine) { if (c === '\n') { inLine = false; out += c; } continue; }
67
+ if (inBlock) { if (c === '*' && next === '/') { inBlock = false; i += 1; } continue; }
68
+ if (inString) {
69
+ out += c;
70
+ if (c === '\\') { out += next ?? ''; i += 1; continue; }
71
+ if (c === '"') inString = false;
72
+ continue;
73
+ }
74
+ if (c === '"') { inString = true; out += c; continue; }
75
+ if (c === '/' && next === '/') { inLine = true; i += 1; continue; }
76
+ if (c === '/' && next === '*') { inBlock = true; i += 1; continue; }
77
+ out += c;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function readJsonc(path) {
83
+ return JSON.parse(stripComments(readFileSync(path, 'utf8')));
84
+ }
85
+
86
+ // Objects merge key by key, arrays and scalars are replaced by the override.
87
+ function deepMerge(base, over) {
88
+ if (over === undefined || over === null) return base;
89
+ if (Array.isArray(base) || Array.isArray(over)) return over;
90
+ if (typeof base === 'object' && typeof over === 'object') {
91
+ const out = { ...base };
92
+ for (const key of Object.keys(over)) out[key] = deepMerge(base[key], over[key]);
93
+ return out;
94
+ }
95
+ return over;
96
+ }
97
+
98
+ function commandExists(command) {
99
+ const probe = process.platform === 'win32' ? 'where' : 'which';
100
+ try {
101
+ return execFileSync(probe, [command], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().length > 0;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function loadConfig(path) {
108
+ const abs = resolve(path);
109
+ const defaultsPath = resolve(SKILL_DIR, 'defaults', 'verify.config.defaults.json');
110
+ if (!existsSync(defaultsPath)) fail(`skill defaults not found: ${defaultsPath}`);
111
+ if (!existsSync(abs)) {
112
+ fail(`config not found: ${abs}\n create it next to the references; it only lists screens/routes/refs ` +
113
+ '(defaults come from the skill: see SKILL.md, "Contract").');
114
+ }
115
+ const defaults = readJsonc(defaultsPath);
116
+ const project = readJsonc(abs);
117
+ // The config normally lives in stitch/, while every path in it is written from
118
+ // the project root (stitch/10-otp-hi.png, stitch/_cmp).
119
+ const dir = dirname(abs);
120
+ const root = basename(dir) === 'stitch' ? dirname(dir) : dir;
121
+ return { abs, root, cfg: deepMerge(defaults, project), defaultsPath, project };
122
+ }
123
+
124
+ function adbBase(serial) {
125
+ return serial ? ['-s', serial] : [];
126
+ }
127
+
128
+ function adbText(serial, args) {
129
+ return execFileSync('adb', [...adbBase(serial), ...args], {
130
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: MAX_BUFFER,
131
+ }).trim();
132
+ }
133
+
134
+ function adbBin(serial, args) {
135
+ return execFileSync('adb', [...adbBase(serial), ...args], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: MAX_BUFFER });
136
+ }
137
+
138
+ function connectedDevices() {
139
+ let lines;
140
+ try {
141
+ lines = adbText(null, ['devices']).split(/\r?\n/);
142
+ } catch {
143
+ return null; // adb missing or broken
144
+ }
145
+ return lines.slice(1)
146
+ .map((l) => l.trim())
147
+ .filter((l) => l && !l.startsWith('List of'))
148
+ .map((l) => {
149
+ const [serial, state, ...rest] = l.split(/\s+/);
150
+ return { serial, state, info: rest.join(' ') };
151
+ });
152
+ }
153
+
154
+ function pickDevice(cfg, notes) {
155
+ const all = connectedDevices();
156
+ if (all === null) return null;
157
+ const online = all.filter((d) => d.state === 'device');
158
+ const want = cfg.adb?.serial;
159
+ if (want && want !== 'auto') {
160
+ const found = online.find((d) => d.serial === want);
161
+ if (found) return found.serial;
162
+ notes.push(`configured adb.serial "${want}" is not connected -> auto-selecting`);
163
+ }
164
+ if (!online.length) return null;
165
+ const emulator = online.find((d) => /^emulator-/.test(d.serial)) ?? online[0];
166
+ if (online.length > 1) {
167
+ notes.push(`auto-selected ${emulator.serial} (${online.length} devices online: ${online.map((d) => d.serial).join(', ')})`);
168
+ }
169
+ return emulator.serial;
170
+ }
171
+
172
+ function listAvds() {
173
+ try {
174
+ return execFileSync('emulator', ['-list-avds'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
175
+ .split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !/^INFO/i.test(s));
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ // No device online: boot one, then wait for it to actually finish booting.
182
+ async function ensureDevice(cfg, notes) {
183
+ const picked = pickDevice(cfg, notes);
184
+ if (picked) return picked;
185
+ if (cfg.adb?.autoStart === false) {
186
+ notes.push('no device online and adb.autoStart is disabled');
187
+ return null;
188
+ }
189
+ if (!commandExists('adb')) { notes.push('adb is not on PATH'); return null; }
190
+ if (!commandExists('emulator')) {
191
+ notes.push('no device online and the `emulator` tool is not on PATH (install Android SDK + platform-tools)');
192
+ return null;
193
+ }
194
+ const avds = listAvds();
195
+ if (!avds.length) { notes.push('no device online and `emulator -list-avds` returned no AVD'); return null; }
196
+ const wanted = cfg.adb?.avd;
197
+ const avd = wanted && avds.includes(wanted) ? wanted : avds[0];
198
+ notes.push(`starting emulator "${avd}"${wanted && avd !== wanted ? ` (requested "${wanted}" not found)` : ''}`);
199
+ try {
200
+ spawn('emulator', ['-avd', avd], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
201
+ } catch (error) {
202
+ notes.push(`emulator launch failed: ${String(error).slice(0, 120)}`);
203
+ return null;
204
+ }
205
+ const timeout = cfg.adb?.bootTimeoutMs ?? 180000;
206
+ try {
207
+ execFileSync('adb', ['wait-for-device'], { timeout, stdio: ['ignore', 'ignore', 'ignore'] });
208
+ } catch {
209
+ notes.push(`emulator did not appear within ${timeout}ms`);
210
+ return null;
211
+ }
212
+ const deadline = Date.now() + timeout;
213
+ while (Date.now() < deadline) {
214
+ try {
215
+ if (adbText(null, ['shell', 'getprop', 'sys.boot_completed']) === '1') break;
216
+ } catch { /* transitional */ }
217
+ await sleep(1500);
218
+ }
219
+ return pickDevice(cfg, notes);
220
+ }
221
+
222
+ async function resolveDensity(cfg, serial) {
223
+ if (cfg.adb?.density) return Number(cfg.adb.density);
224
+ try {
225
+ const out = adbText(serial, ['shell', 'wm', 'density']);
226
+ const match = /(?:Physical|Override)\s+density:\s*(\d+)/i.exec(out);
227
+ if (match) return Math.round((Number(match[1]) / 160) * 1000) / 1000;
228
+ } catch { /* non fatal: measurements fall back to px only */ }
229
+ return null;
230
+ }
231
+
232
+ function deepLinkUrl(template, route) {
233
+ const raw = template.replace('{route}', route);
234
+ // '--/{route}' with route '/' must not become '--//' (same rule as the
235
+ // hand-written capture script: one slash after the Expo redirect marker).
236
+ return raw.replace(/--\/+/, '--/');
237
+ }
238
+
239
+ function parseBounds(xml) {
240
+ const nodes = [];
241
+ const tagRe = /<node\s+([^>]*?)\/?>/g;
242
+ let match = tagRe.exec(xml);
243
+ while (match !== null) {
244
+ const attrs = {};
245
+ const attrRe = /([\w:-]+)="([^"]*)"/g;
246
+ let a = attrRe.exec(match[1]);
247
+ while (a !== null) { attrs[a[1]] = a[2]; a = attrRe.exec(match[1]); }
248
+ const b = /^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/.exec(attrs.bounds || '');
249
+ if (b) {
250
+ const x1 = Number(b[1]); const y1 = Number(b[2]);
251
+ const x2 = Number(b[3]); const y2 = Number(b[4]);
252
+ nodes.push({
253
+ text: attrs.text || '',
254
+ desc: attrs['content-desc'] || '',
255
+ id: attrs['resource-id'] || '',
256
+ cls: attrs.class || '',
257
+ clickable: attrs.clickable === 'true',
258
+ bounds: { x: x1, y: y1, w: x2 - x1, h: y2 - y1, x2, y2 },
259
+ });
260
+ }
261
+ match = tagRe.exec(xml);
262
+ }
263
+ return nodes;
264
+ }
265
+
266
+ function metrics(nodes, density) {
267
+ const root = nodes.find((n) => n.bounds.x === 0 && n.bounds.y === 0) || null;
268
+ const w = root ? root.bounds.w : Math.max(0, ...nodes.map((n) => n.bounds.x2));
269
+ const h = root ? root.bounds.h : Math.max(0, ...nodes.map((n) => n.bounds.y2));
270
+ // Full-height containers (screens, scroll views) report y2 == h and would
271
+ // collapse every gap to 0: only content-bearing, non-full-height nodes count.
272
+ const isFullHeight = (n) => h > 0 && n.bounds.h >= h * 0.98;
273
+ const content = nodes.filter((n) => (n.text || n.desc || n.clickable) && !isFullHeight(n));
274
+ const counted = nodes.filter((n) => n.text || n.desc || n.id || n.clickable);
275
+ const lowest = content.reduce((acc, n) => (acc === null || n.bounds.y2 > acc.bounds.y2 ? n : acc), null);
276
+ const lowestClickable = content
277
+ .filter((n) => n.clickable)
278
+ .reduce((acc, n) => (acc === null || n.bounds.y2 > acc.bounds.y2 ? n : acc), null);
279
+ return {
280
+ screen: { w, h },
281
+ density,
282
+ bottomGapPx: lowest ? Math.round(h - lowest.bounds.y2) : null,
283
+ bottomGapDp: lowest && density ? Math.round((h - lowest.bounds.y2) / density) : null,
284
+ bottomClickableGapPx: lowestClickable ? Math.round(h - lowestClickable.bounds.y2) : null,
285
+ bottomClickableGapDp: lowestClickable && density ? Math.round((h - lowestClickable.bounds.y2) / density) : null,
286
+ lowestText: lowest ? (lowest.text || lowest.desc).slice(0, 60) : null,
287
+ nodeCount: counted.length,
288
+ };
289
+ }
290
+
291
+ let lastDumpError = null;
292
+
293
+ // uiautomator dump fails while an animation runs: wait and retry. No synthetic
294
+ // tap — a "neutral" point on an unknown screen can press a real control.
295
+ async function dumpXml(serial) {
296
+ for (let attempt = 0; attempt < 3; attempt += 1) {
297
+ try {
298
+ const dump = adbText(serial, ['shell', 'uiautomator', 'dump', '/sdcard/stitch_ui.xml']);
299
+ if (dump.includes('dumped to')) {
300
+ const xml = adbText(serial, ['exec-out', 'cat', '/sdcard/stitch_ui.xml']);
301
+ if (xml && xml.includes('<node') && !xml.includes('ERROR')) { lastDumpError = null; return xml; }
302
+ lastDumpError = `dump written but xml unusable (len ${xml ? xml.length : 0})`;
303
+ } else {
304
+ lastDumpError = dump.slice(0, 140) || 'empty dump output';
305
+ }
306
+ } catch (error) {
307
+ lastDumpError = String(error).replace(/\s+/g, ' ').slice(0, 180);
308
+ }
309
+ await sleep(600);
310
+ }
311
+ return null;
312
+ }
313
+
314
+ // Content nodes only, and geometry only: decorative or animated nodes (spinner,
315
+ // progress bar, shield pulse) churn forever, and a live screen (OTP countdown,
316
+ // clock) changes text every second — stability is judged on where the real
317
+ // content sits. "Richness" (below) is what keeps the splash screen out.
318
+ const signature = (nodes) => nodes
319
+ .filter((n) => n.text || n.desc || n.clickable)
320
+ .map((n) => `${n.cls}|${n.id}|${n.bounds.x},${n.bounds.y},${n.bounds.w},${n.bounds.h}`)
321
+ .join('\n');
322
+
323
+ // Wait until the UI is actually rendered: the splash screen (a logo plus the
324
+ // dev menu) is static too, so "stable" alone is not enough — the hierarchy must
325
+ // also carry real content. Two identical rich dumps in a row means ready.
326
+ async function waitForRenderedUi(serial, cfg, minMs, maxMs, pollMs) {
327
+ const started = Date.now();
328
+ await sleep(minMs);
329
+ let prev = null;
330
+ let streak = 0;
331
+ let last = null;
332
+ let blind = 0; // consecutive unusable dumps: an animation can hold uiautomator
333
+ while (Date.now() - started < maxMs) {
334
+ const xml = await dumpXml(serial);
335
+ if (xml) {
336
+ blind = 0;
337
+ const nodes = parseBounds(xml);
338
+ const texts = nodes.filter((n) => n.text || n.desc).length;
339
+ const clickable = nodes.filter((n) => n.clickable).length;
340
+ const rich = texts >= (cfg.ready?.minTexts ?? 3)
341
+ || clickable >= 1
342
+ || nodes.length >= (cfg.ready?.minNodes ?? 12);
343
+ const sig = signature(nodes);
344
+ streak = rich && sig === prev ? streak + 1 : 0;
345
+ prev = sig;
346
+ last = { texts, clickable, total: nodes.length, rich };
347
+ if (streak >= 1) return { ready: true, waitedMs: Date.now() - started, stats: last };
348
+ } else {
349
+ blind += 1;
350
+ // Give up early: the final dump after the screenshot still decides.
351
+ if (blind >= 6) break;
352
+ }
353
+ await sleep(pollMs);
354
+ }
355
+ return { ready: false, waitedMs: Date.now() - started, stats: last, dumpError: lastDumpError };
356
+ }
357
+
358
+ // Scroll state is part of the measurement: the same screen gives a bottom gap
359
+ // 155px different depending on where the ScrollView sits, so the capture pins it
360
+ // (resetScroll -> top, screen.scroll -> top/bottom) instead of inheriting
361
+ // whatever the previous interaction left behind.
362
+ async function normalizeScroll(serial, cfg, screen, args) {
363
+ const target = args.scroll ?? screen.scroll ?? (cfg.launch?.resetScroll === false ? null : 'top');
364
+ if (!target) return null;
365
+ const [x1, y1, x2, y2] = target === 'bottom' ? [540, 1900, 540, 500] : [540, 500, 540, 1900];
366
+ const times = target === 'bottom' ? (screen.scrollSwipes ?? 4) : 2;
367
+ for (let i = 0; i < times; i += 1) {
368
+ try { adbText(serial, ['shell', 'input', 'swipe', String(x1), String(y1), String(x2), String(y2), '400']); }
369
+ catch { return `scroll ${target} failed`; }
370
+ await sleep(cfg.launch?.scrollSettleMs ?? 900);
371
+ }
372
+ return `scroll reset to ${target}`;
373
+ }
374
+
375
+ async function captureScreen(ctx, screen) {
376
+ const { serial, cfg, outDir, args } = ctx;
377
+ const status = { id: screen.id, route: screen.route ?? null, status: 'ok', notes: [] };
378
+ const png = resolve(outDir, `${screen.id}.png`);
379
+ const boundsFile = resolve(outDir, `${screen.id}.bounds.json`);
380
+
381
+ try {
382
+ if (args.dryRun) { status.notes.push('dry-run: no adb call made'); return status; }
383
+
384
+ if (!args.fromCurrent) {
385
+ if (screen.taps?.length) {
386
+ // Ladder level 3: the screen is reached through the real UI.
387
+ for (const tap of screen.taps) {
388
+ const [x, y] = Array.isArray(tap) ? tap : [tap.x, tap.y];
389
+ adbText(serial, ['shell', 'input', 'tap', String(x), String(y)]);
390
+ await sleep(screen.tapSettleMs ?? cfg.launch?.tapSettleMs ?? 900);
391
+ }
392
+ }
393
+ const template = screen.deepLink ?? cfg.launch?.deepLink;
394
+ if (screen.route && template) {
395
+ if (screen.cold ?? cfg.launch?.coldStart) {
396
+ const pkg = screen.package ?? cfg.launch?.package;
397
+ if (pkg) { try { adbText(serial, ['shell', 'am', 'force-stop', pkg]); await sleep(800); } catch { /* best effort */ } }
398
+ }
399
+ const url = deepLinkUrl(template, screen.route);
400
+ status.notes.push(`launch ${url}`);
401
+ adbText(serial, ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]);
402
+ } else if (!screen.taps?.length) {
403
+ // Ladder level 2: initial route — the app is started and shows the screen first.
404
+ status.notes.push('no deep link configured: capturing the current screen');
405
+ }
406
+ } else {
407
+ status.notes.push('--from-current');
408
+ }
409
+
410
+ const ready = await waitForRenderedUi(
411
+ serial, cfg,
412
+ args.settle ?? screen.waitMs ?? cfg.launch?.waitForIdleMs ?? 6000,
413
+ cfg.launch?.maxWaitMs ?? 30000,
414
+ cfg.launch?.pollMs ?? 800,
415
+ );
416
+ status.notes.push(ready.ready
417
+ ? `rendered in ${ready.waitedMs}ms (${ready.stats.texts} texts, ${ready.stats.clickable} clickable)`
418
+ : `UI never stabilized within ${ready.waitedMs}ms (${ready.stats ? JSON.stringify(ready.stats) : 'no usable dump'})`
419
+ + (ready.dumpError ? `; last dump error: ${ready.dumpError}` : ''));
420
+
421
+ const scrollNote = await normalizeScroll(serial, cfg, screen, args);
422
+ if (scrollNote) status.notes.push(scrollNote);
423
+
424
+ const pngBytes = adbBin(serial, ['exec-out', 'screencap', '-p']);
425
+ if (!pngBytes.length) throw new Error('empty screencap');
426
+ writeFileSync(png, pngBytes);
427
+ status.png = png;
428
+
429
+ const xml = await dumpXml(serial);
430
+ if (!xml) {
431
+ status.status = ready.ready ? 'no-bounds' : 'unstable';
432
+ status.notes.push('uiautomator dump failed: png captured, measurements unavailable');
433
+ return status;
434
+ }
435
+
436
+ const nodes = parseBounds(xml);
437
+ const data = {
438
+ screenId: screen.id,
439
+ route: screen.route ?? null,
440
+ capturedAt: new Date().toISOString(),
441
+ ...metrics(nodes, ctx.density),
442
+ nodes: nodes.filter((n) => n.text || n.desc || n.id || n.clickable),
443
+ };
444
+ writeFileSync(boundsFile, JSON.stringify(data, null, 2));
445
+ status.bounds = boundsFile;
446
+ status.bottomGapPx = data.bottomGapPx;
447
+ status.bottomClickableGapPx = data.bottomClickableGapPx;
448
+ status.nodes = data.nodeCount;
449
+ if (!ready.ready) {
450
+ // The wait may have been blind (dumps failing during the whole window)
451
+ // while the screen itself was fine: the final dump decides, on content.
452
+ const texts = data.nodes.filter((n) => n.text || n.desc).length;
453
+ const clickable = data.nodes.filter((n) => n.clickable).length;
454
+ if (texts >= (cfg.ready?.minTexts ?? 3) || clickable >= 1) {
455
+ status.notes.push(`rendered confirmed by the final dump (${texts} texts, ${clickable} clickable)`);
456
+ } else {
457
+ status.status = 'unstable';
458
+ }
459
+ }
460
+ return status;
461
+ } catch (error) {
462
+ status.status = 'unreachable';
463
+ status.notes.push(String(error).slice(0, 200));
464
+ return status;
465
+ }
466
+ }
467
+
468
+ // --check: the "is this machine ready?" answer, before any capture is attempted.
469
+ function runCheck(cfg, root, args) {
470
+ const rows = [];
471
+ const add = (label, ok, detail) => rows.push({ label, ok, detail });
472
+
473
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
474
+ add('node >= 22', nodeMajor >= 22, `found ${process.version}`);
475
+
476
+ const hasAdb = commandExists('adb');
477
+ add('adb on PATH', hasAdb, hasAdb ? 'Android platform-tools available' : 'install Android platform-tools (winget/brew/apt: see provider "stitch-fidelity" in catalog/providers.json)');
478
+
479
+ const notes = [];
480
+ const serial = pickDevice(cfg, notes);
481
+ if (serial) {
482
+ add('device online', true, serial);
483
+ } else {
484
+ const hasEmu = commandExists('emulator');
485
+ const avds = hasEmu ? listAvds() : [];
486
+ const canBoot = hasAdb && hasEmu && avds.length > 0 && cfg.adb?.autoStart !== false;
487
+ add('device online', canBoot,
488
+ canBoot ? `none yet — will auto-start "${avds[0]}"` : (hasEmu ? 'no device online and no AVD to start' : 'no device online and no `emulator` tool to start one'));
489
+ }
490
+
491
+ const template = cfg.launch?.deepLink;
492
+ add('deep link template', !cfg.screens.some((s) => s.route) || Boolean(template),
493
+ template ?? 'no template: screens with a route will be captured on the current screen');
494
+
495
+ const missingRefs = cfg.screens.filter((s) => s.ref && !existsSync(resolve(root, s.ref))).map((s) => s.ref);
496
+ add('references present', missingRefs.length === 0,
497
+ missingRefs.length ? `missing: ${missingRefs.join(', ')}` : `${cfg.screens.filter((s) => s.ref).length} reference files`);
498
+
499
+ const outDir = resolve(root, cfg.out ?? 'stitch/_cmp');
500
+ let writable = true;
501
+ try { mkdirSync(outDir, { recursive: true }); } catch { writable = false; }
502
+ add(`output dir ${cfg.out}`, writable, writable ? outDir : 'not writable');
503
+
504
+ const serveRoot = resolve(root, cfg.serve?.root ?? 'stitch');
505
+ add(`serve root ${cfg.serve?.root ?? 'stitch'}`, existsSync(serveRoot), serveRoot);
506
+
507
+ for (const note of notes) add('device', false, note);
508
+
509
+ const failed = rows.filter((r) => !r.ok);
510
+ if (args.json) console.log(JSON.stringify({ ok: failed.length === 0, rows }, null, 2));
511
+ else {
512
+ console.log('stitch_capture --check');
513
+ for (const row of rows) console.log(` ${row.ok ? 'OK ' : 'MISS'} ${row.label.padEnd(30)} ${row.detail}`);
514
+ console.log(failed.length === 0 ? '\nready: every dependency is satisfied' : `\n${failed.length} missing: fix the MISS lines above, then re-run --check`);
515
+ }
516
+ return failed.length ? 1 : 0;
517
+ }
518
+
519
+ async function main() {
520
+ const args = parseArgs(process.argv.slice(2));
521
+ if (args.help) {
522
+ console.log('usage: node stitch_capture.mjs [--config stitch/verify.config.json] [--only id1,id2]\n' +
523
+ ' [--from-current] [--settle ms] [--scroll top|bottom]\n' +
524
+ ' [--dry-run] [--check] [--json]');
525
+ return 0;
526
+ }
527
+
528
+ const { root, cfg } = loadConfig(args.config);
529
+ if (!Array.isArray(cfg.screens) || cfg.screens.length === 0) fail('config.screens is empty (project file overrides the skill defaults)');
530
+ for (const screen of cfg.screens) if (!screen.id) fail('every config.screens entry needs an id');
531
+
532
+ if (args.check) return runCheck(cfg, root, args);
533
+
534
+ let screens = cfg.screens;
535
+ if (args.only) {
536
+ const unknown = args.only.filter((id) => !cfg.screens.some((s) => s.id === id));
537
+ if (unknown.length) fail(`unknown screen id(s): ${unknown.join(', ')}`);
538
+ screens = cfg.screens.filter((s) => args.only.includes(s.id));
539
+ }
540
+
541
+ const outDir = resolve(root, cfg.out ?? 'stitch/_cmp');
542
+ mkdirSync(outDir, { recursive: true });
543
+ const batch = Math.min(BATCH_LIMIT, Math.max(1, Number(cfg.launch?.batch ?? BATCH_LIMIT)));
544
+
545
+ const notes = [];
546
+ let serial = null;
547
+ let density = cfg.adb?.density ?? null;
548
+ if (args.dryRun) {
549
+ notes.push('dry-run: device resolution skipped');
550
+ } else {
551
+ serial = await ensureDevice(cfg, notes);
552
+ if (!serial) {
553
+ for (const note of notes) console.error(`stitch_capture: ${note}`);
554
+ console.error('stitch_capture: no usable device — run `node stitch_capture.mjs --check` for the full picture');
555
+ return 2;
556
+ }
557
+ density = await resolveDensity(cfg, serial);
558
+ if (!density) notes.push('could not read wm density: gaps stay in px only');
559
+ }
560
+ for (const note of notes) console.error(`stitch_capture: ${note}`);
561
+
562
+ const ctx = { serial, cfg, outDir, args, root, density };
563
+ const results = [];
564
+ for (let i = 0; i < screens.length; i += batch) {
565
+ const group = screens.slice(i, i + batch);
566
+ for (const screen of group) results.push(await captureScreen(ctx, screen));
567
+ if (i + batch < screens.length) await sleep(500);
568
+ }
569
+
570
+ const summary = {
571
+ config: args.config,
572
+ device: serial ?? 'dry-run',
573
+ density,
574
+ outDir,
575
+ batch,
576
+ capturedAt: new Date().toISOString(),
577
+ screens: results,
578
+ };
579
+ writeFileSync(resolve(outDir, 'capture-summary.json'), JSON.stringify(summary, null, 2));
580
+
581
+ const failed = results.filter((r) => r.status !== 'ok');
582
+ if (args.json) console.log(JSON.stringify(summary, null, 2));
583
+ else {
584
+ console.log('screen status nodes bottomGapPx path');
585
+ for (const r of results) {
586
+ console.log(
587
+ `${(r.id ?? '').padEnd(13)} ${r.status.padEnd(10)} ${String(r.nodes ?? '-').padEnd(6)} ` +
588
+ `${String(r.bottomGapPx ?? '-').padEnd(12)} ${(r.png ?? '(none)').replace(outDir, '.')}`,
589
+ );
590
+ for (const note of r.notes) console.log(` note: ${note}`);
591
+ }
592
+ console.log(`\n${results.length - failed.length}/${results.length} ok on ${serial ?? 'dry-run'} -> ${outDir}`);
593
+ if (failed.length) console.log(`not captured: ${failed.map((f) => `${f.id} (${f.status})`).join(', ')}`);
594
+ }
595
+ return failed.length ? 1 : 0;
596
+ }
597
+
598
+ process.exitCode = await main();