@houwert/conductor 0.17.0 → 0.19.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.
@@ -14,6 +14,7 @@ const ios_js_1 = require("../drivers/ios.js");
14
14
  const android_js_1 = require("../drivers/android.js");
15
15
  const web_js_1 = require("../drivers/web.js");
16
16
  const a11y_js_1 = require("../drivers/a11y.js");
17
+ const snapshot_store_js_1 = require("../snapshot-store.js");
17
18
  async function captureUI(outputPath, opts = {}, sessionName = 'default') {
18
19
  try {
19
20
  const driver = await (0, runner_js_1.getDriver)(sessionName);
@@ -87,6 +88,9 @@ async function captureUI(outputPath, opts = {}, sessionName = 'default') {
87
88
  a11ySnapshot,
88
89
  capabilities: { perViewPixels: false, depthData: false },
89
90
  };
91
+ // Persist `@eN` refs so `tap-on @e3` can act on this capture without a
92
+ // re-query. Keyed by session — see snapshot-store.ts.
93
+ await (0, snapshot_store_js_1.saveSnapshot)(sessionName, (0, snapshot_store_js_1.buildStoredSnapshot)(a11ySnapshot, { deviceId: sessionName, platform }));
90
94
  const json = JSON.stringify(bundle);
91
95
  if (outputPath) {
92
96
  const resolved = path_1.default.resolve(outputPath);
@@ -2,7 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.clearState = clearState;
5
- exports.HELP = ` clear-state [<appId>] Clear app data/state`;
5
+ exports.HELP = ` clear-state [<appId>] DESTRUCTIVE: wipe app data and signed-in state.
6
+ On iOS this uninstall+reinstalls the app, which also drops
7
+ the app's keychain items — the user will be signed out and
8
+ cannot be recovered without their credentials. Do not use to
9
+ reset focus or navigation state.`;
6
10
  const runner_js_1 = require("../runner.js");
7
11
  const session_js_1 = require("../session.js");
8
12
  const output_js_1 = require("../output.js");
@@ -13,6 +17,8 @@ async function clearState(appId, opts = {}, sessionName = 'default') {
13
17
  (0, output_js_1.printError)('clear-state: no appId provided and no active session. Run launch-app first.', opts);
14
18
  return 1;
15
19
  }
20
+ process.stderr.write('warning: clear-state wipes app data AND signed-in state; the user will be signed out ' +
21
+ 'and cannot be recovered without their credentials.\n');
16
22
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
17
23
  await driver.clearAppState(resolvedAppId);
18
24
  }, sessionName);
@@ -3,8 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.launchApp = launchApp;
5
5
  exports.HELP = ` launch-app <appId> Launch app (saves to session)
6
- --clear-state Clear app data/state before launching
7
- --clear-keychain Clear keychain before launching
6
+ --clear-state DESTRUCTIVE: wipe app data AND signed-in state before launching.
7
+ On iOS this uninstall+reinstalls the app, which also drops the
8
+ app's keychain items — the user will be signed out and you
9
+ cannot undo it without their credentials. Do not use to reset
10
+ focus or navigation state; relaunch without this flag instead.
11
+ --clear-keychain DESTRUCTIVE: wipe the device keychain before launching. Signs
12
+ the user out of every app on the simulator. Cannot be undone
13
+ without re-entering credentials.
8
14
  --no-stop-app Do not stop the app before launching (resume instead of restart)
9
15
  --argument key=value Set launch argument (repeatable)`;
10
16
  const runner_js_1 = require("../runner.js");
@@ -19,6 +25,10 @@ async function launchApp(appId, deviceId, opts = {}, sessionName = 'default', fl
19
25
  return 1;
20
26
  }
21
27
  await (0, session_js_1.updateSession)({ appId, ...(deviceId ? { deviceId } : {}) }, sessionName);
28
+ if (flags.clearState || flags.clearKeychain) {
29
+ process.stderr.write('warning: --clear-state / --clear-keychain wipes app data AND signed-in state; ' +
30
+ 'the user will be signed out and cannot be recovered without their credentials.\n');
31
+ }
22
32
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
23
33
  if (flags.clearKeychain)
24
34
  await driver.clearKeychain();
@@ -5,24 +5,108 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
7
  exports.screenshot = screenshot;
8
- exports.HELP = ` take-screenshot [--output <path>] [--full-page]
9
- Take screenshot (--full-page: web only, capture entire scrollable page)`;
8
+ exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page]
9
+ Take screenshot (--full-page: web only, capture entire scrollable page)
10
+ <element> Crop to the element matched by text (positional)
11
+ --id <id> Crop to the element matched by accessibility id
12
+ --text <text> Crop to the element matched by text only (not id)
13
+ --index <n> Pick the nth match (0-based)
14
+ --margin <px> Extra pixels around the crop (default 8) to capture shadows
15
+ --focused Match only focused elements
16
+ --enabled / --no-enabled Match by enabled state
17
+ --checked / --no-checked Match by checked state
18
+ --selected / --no-selected Match by selected state
19
+ --below <text> Match element below the given reference
20
+ --above <text> Match element above the given reference
21
+ --left-of <text> Match element left of the given reference
22
+ --right-of <text> Match element right of the given reference`;
10
23
  const path_1 = __importDefault(require("path"));
11
24
  const promises_1 = __importDefault(require("fs/promises"));
12
25
  const runner_js_1 = require("../runner.js");
13
26
  const output_js_1 = require("../output.js");
14
- async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false) {
27
+ const ios_js_1 = require("../drivers/ios.js");
28
+ const android_js_1 = require("../drivers/android.js");
29
+ const web_js_1 = require("../drivers/web.js");
30
+ const wait_js_1 = require("../drivers/wait.js");
31
+ const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
32
+ const png_crop_js_1 = require("../png-crop.js");
33
+ const DEFAULT_MARGIN_PX = 8;
34
+ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
15
35
  const timestamp = Date.now();
16
36
  const defaultName = `screenshot-${timestamp}.png`;
17
37
  const resolvedPath = outputPath
18
38
  ? path_1.default.resolve(outputPath)
19
39
  : path_1.default.resolve(process.cwd(), defaultName);
40
+ const hasSelector = !!(query || flags.id || flags.text);
41
+ const sel = hasSelector
42
+ ? {
43
+ ...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query }),
44
+ ...(flags.index !== undefined && { index: flags.index }),
45
+ ...(flags.focused !== undefined && { focused: flags.focused }),
46
+ ...(flags.enabled !== undefined && { enabled: flags.enabled }),
47
+ ...(flags.checked !== undefined && { checked: flags.checked }),
48
+ ...(flags.selected !== undefined && { selected: flags.selected }),
49
+ ...(flags.below && { below: { query: flags.below } }),
50
+ ...(flags.above && { above: { query: flags.above } }),
51
+ ...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
52
+ ...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
53
+ }
54
+ : null;
55
+ const label = flags.text
56
+ ? `text="${flags.text}"`
57
+ : flags.id
58
+ ? `id="${flags.id}"`
59
+ : query
60
+ ? `"${query}"`
61
+ : '';
62
+ const margin = flags.margin ?? DEFAULT_MARGIN_PX;
20
63
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
21
64
  const buf = await driver.screenshot({ fullPage });
22
- await promises_1.default.writeFile(resolvedPath, buf);
65
+ let out = buf;
66
+ if (sel) {
67
+ let el;
68
+ let hierarchyW;
69
+ let hierarchyH;
70
+ if (driver instanceof ios_js_1.IOSDriver) {
71
+ const h = await driver.viewHierarchy(false, [], { cache: false });
72
+ hierarchyW = h.axElement.frame.Width;
73
+ hierarchyH = h.axElement.frame.Height;
74
+ el = await (0, wait_js_1.waitForIOSElement)((o) => driver.viewHierarchy(false, [], { cache: o?.cached }).then((x) => x.axElement), sel, undefined, undefined, (0, direct_ios_selector_js_1.makeIOSDirectResolver)(driver, sel));
75
+ }
76
+ else if (driver instanceof web_js_1.WebDriver) {
77
+ const info = await driver.deviceInfo();
78
+ hierarchyW = info.widthPixels;
79
+ hierarchyH = info.heightPixels;
80
+ el = await (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), sel);
81
+ }
82
+ else if (driver instanceof android_js_1.AndroidDriver) {
83
+ const xml = await driver.viewHierarchy();
84
+ // Android XML root bounds: derive from the first parseable <node bounds="[0,0][W,H]">
85
+ const m = xml.match(/<node[^>]*bounds="\[0,0\]\[(\d+),(\d+)\]"/);
86
+ hierarchyW = m ? +m[1] : 0;
87
+ hierarchyH = m ? +m[2] : 0;
88
+ el = await (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel);
89
+ }
90
+ else {
91
+ throw new Error('selector cropping is not supported for this driver');
92
+ }
93
+ const { width: pngW, height: pngH } = (0, png_crop_js_1.readPngDimensions)(buf);
94
+ const scaleX = hierarchyW > 0 ? pngW / hierarchyW : 1;
95
+ const scaleY = hierarchyH > 0 ? pngH / hierarchyH : 1;
96
+ const rectX = Math.round(el.bounds.x * scaleX - margin);
97
+ const rectY = Math.round(el.bounds.y * scaleY - margin);
98
+ const rectW = Math.round(el.bounds.width * scaleX + margin * 2);
99
+ const rectH = Math.round(el.bounds.height * scaleY + margin * 2);
100
+ if (rectX + rectW <= 0 || rectY + rectH <= 0 || rectX >= pngW || rectY >= pngH) {
101
+ throw new Error(`element ${label} bounds [${rectX},${rectY} ${rectW}x${rectH}] are outside the screenshot (${pngW}x${pngH})`);
102
+ }
103
+ out = (0, png_crop_js_1.cropPng)(buf, { x: rectX, y: rectY, width: rectW, height: rectH });
104
+ }
105
+ await promises_1.default.writeFile(resolvedPath, out);
23
106
  }, sessionName);
24
107
  if (result.success) {
25
- (0, output_js_1.printSuccess)(`screenshot saved to ${resolvedPath}`, opts);
108
+ const suffix = sel ? ` (${label})` : '';
109
+ (0, output_js_1.printSuccess)(`screenshot saved to ${resolvedPath}${suffix}`, opts);
26
110
  return 0;
27
111
  }
28
112
  else {
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.setViewport = setViewport;
5
+ exports.HELP = ` set-viewport [<width> <height>] Resize the web browser viewport (web only)
6
+ --preset <mobile|tablet|desktop> Use a device preset instead of explicit width/height
7
+ --width <n> / --height <n> Viewport size in CSS pixels
8
+ --scale <n> Device scale factor (default: 2 for mobile/tablet, else 1)
9
+ --mobile / --no-mobile Emulate a mobile device (touch + mobile UA hints)
10
+ --user-agent <str> Override the user agent string
11
+ --color-scheme <dark|light> Emulate prefers-color-scheme`;
12
+ const runner_js_1 = require("../runner.js");
13
+ const web_js_1 = require("../drivers/web.js");
14
+ const output_js_1 = require("../output.js");
15
+ const PRESETS = {
16
+ mobile: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true },
17
+ tablet: { width: 820, height: 1180, deviceScaleFactor: 2, isMobile: true },
18
+ desktop: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false },
19
+ };
20
+ async function setViewport(flags, opts = {}, sessionName = 'default') {
21
+ let width = flags.width;
22
+ let height = flags.height;
23
+ let isMobile = flags.mobile;
24
+ let scale = flags.scale;
25
+ if (flags.preset !== undefined) {
26
+ const preset = PRESETS[flags.preset.toLowerCase()];
27
+ if (!preset) {
28
+ (0, output_js_1.printError)(`--preset must be one of: ${Object.keys(PRESETS).join(', ')}`, opts);
29
+ return 1;
30
+ }
31
+ width ?? (width = preset.width);
32
+ height ?? (height = preset.height);
33
+ scale ?? (scale = preset.deviceScaleFactor);
34
+ isMobile ?? (isMobile = preset.isMobile);
35
+ }
36
+ if (width === undefined || height === undefined) {
37
+ (0, output_js_1.printError)('set-viewport requires --preset or both width and height', opts);
38
+ return 1;
39
+ }
40
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
41
+ (0, output_js_1.printError)('set-viewport width and height must be positive numbers', opts);
42
+ return 1;
43
+ }
44
+ if (flags.colorScheme !== undefined &&
45
+ flags.colorScheme !== 'dark' &&
46
+ flags.colorScheme !== 'light') {
47
+ (0, output_js_1.printError)('--color-scheme must be "dark" or "light"', opts);
48
+ return 1;
49
+ }
50
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
51
+ if (!(driver instanceof web_js_1.WebDriver)) {
52
+ throw new Error('set-viewport is only supported on web devices');
53
+ }
54
+ await driver.setViewport({
55
+ width: width,
56
+ height: height,
57
+ ...(scale !== undefined ? { deviceScaleFactor: scale } : {}),
58
+ ...(isMobile !== undefined ? { isMobile } : {}),
59
+ ...(flags.userAgent !== undefined ? { userAgent: flags.userAgent } : {}),
60
+ ...(flags.colorScheme !== undefined
61
+ ? { colorScheme: flags.colorScheme }
62
+ : {}),
63
+ });
64
+ }, sessionName);
65
+ if (result.success) {
66
+ (0, output_js_1.printSuccess)(`set-viewport ${width}x${height} — done`, opts);
67
+ return 0;
68
+ }
69
+ else {
70
+ (0, output_js_1.printError)(`set-viewport ${width}x${height} — failed\n${result.stderr}`, opts);
71
+ return 1;
72
+ }
73
+ }
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.tap = tap;
5
- exports.HELP = ` tap-on <element> Tap element by text or id
5
+ exports.HELP = ` tap-on <element> Tap element by text, id, or @eN snapshot ref
6
6
  --id <id> Match by accessibility id instead of text
7
7
  --text <text> Match by text only (not id)
8
8
  --index <n> Pick the nth match (0-based)
@@ -24,6 +24,7 @@ const android_js_1 = require("../drivers/android.js");
24
24
  const web_js_1 = require("../drivers/web.js");
25
25
  const wait_js_1 = require("../drivers/wait.js");
26
26
  const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
27
+ const snapshot_store_js_1 = require("../snapshot-store.js");
27
28
  const utils_js_1 = require("../utils.js");
28
29
  async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
29
30
  if (!query && !flags.id && !flags.text) {
@@ -42,6 +43,9 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
42
43
  ...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
43
44
  ...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
44
45
  };
46
+ // A bare `@eN` query taps the cached coordinates from the last `capture-ui`
47
+ // snapshot, skipping fuzzy text/id resolution. Explicit --text/--id win.
48
+ const useRef = (0, snapshot_store_js_1.isRefQuery)(query) && !flags.text && !flags.id;
45
49
  const label = flags.text ? `text="${flags.text}"` : flags.id ? `id="${flags.id}"` : `"${query}"`;
46
50
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
47
51
  if (driver instanceof ios_js_1.IOSDriver && driver.platform === 'tvos') {
@@ -49,7 +53,16 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
49
53
  'Use press-key to navigate (e.g. conductor press-key "Remote Dpad Center").');
50
54
  }
51
55
  let el;
52
- if (driver instanceof ios_js_1.IOSDriver) {
56
+ if (useRef) {
57
+ const { entry, staleReason } = (0, snapshot_store_js_1.resolveRef)(await (0, snapshot_store_js_1.loadSnapshot)(sessionName), query, {
58
+ deviceId: sessionName,
59
+ });
60
+ if (staleReason) {
61
+ process.stderr.write(`warning: ${query} — ${staleReason}; re-run capture-ui if the tap misses\n`);
62
+ }
63
+ el = { centerX: entry.centerX, centerY: entry.centerY };
64
+ }
65
+ else if (driver instanceof ios_js_1.IOSDriver) {
53
66
  el = await (0, wait_js_1.waitForIOSElement)((o) => driver.viewHierarchy(false, [], { cache: o?.cached }).then((h) => h.axElement), sel, undefined, undefined, (0, direct_ios_selector_js_1.makeIOSDirectResolver)(driver, sel));
54
67
  }
55
68
  else if (driver instanceof web_js_1.WebDriver) {
@@ -110,7 +110,7 @@ async function ensureDriverRunning() {
110
110
  }
111
111
  else if (driverPlatform === 'tvos') {
112
112
  // Health-check restart — don't dismiss, to avoid disrupting user's app
113
- await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
113
+ await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* restoreFocusAfterLaunch */ false);
114
114
  }
115
115
  else if (driverPlatform === 'web') {
116
116
  await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
@@ -357,8 +357,9 @@ async function main() {
357
357
  await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
358
358
  }
359
359
  else if (platform === 'tvos') {
360
- // First install — dismiss the runner app to return to homescreen
361
- await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
360
+ // First install — the runner takes foreground; ask it to hand
361
+ // focus back to whatever app the user had open.
362
+ await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* restoreFocusAfterLaunch */ true);
362
363
  }
363
364
  else if (platform === 'web') {
364
365
  await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
@@ -79,6 +79,7 @@ function buildIOSA11y(root) {
79
79
  accessibilityOrder = order++;
80
80
  snapshot.push({
81
81
  nodeId: path,
82
+ ref: `@e${accessibilityOrder + 1}`,
82
83
  order: accessibilityOrder,
83
84
  frame: {
84
85
  x: node.frame.X,
@@ -281,6 +282,7 @@ function buildAndroidA11y(xml) {
281
282
  accessibilityOrder = order++;
282
283
  snapshot.push({
283
284
  nodeId: path,
285
+ ref: `@e${accessibilityOrder + 1}`,
284
286
  order: accessibilityOrder,
285
287
  frame: {
286
288
  x: n.bounds.x1,
@@ -382,6 +384,7 @@ function buildWebA11y(hierarchy) {
382
384
  accessibilityOrder = order++;
383
385
  snapshot.push({
384
386
  nodeId: path,
387
+ ref: `@e${accessibilityOrder + 1}`,
385
388
  order: accessibilityOrder,
386
389
  frame: { x: n.bounds.x, y: n.bounds.y, w: n.bounds.width, h: n.bounds.height },
387
390
  label: n.name,
@@ -547,11 +547,16 @@ async function setupTvOSDriverCache() {
547
547
  * Start the tvOS XCTest driver via `xcodebuild test-without-building`.
548
548
  * Mirrors startIOSDriver but targets the tvOS xctestrun.
549
549
  *
550
- * On first launch the runner app appears in the foreground, so we press the
551
- * home button to dismiss it. On subsequent restarts (e.g. health-check recovery)
552
- * we skip the dismiss to avoid disrupting the user's navigation state.
550
+ * On first launch the runner app appears in the foreground, displacing whatever
551
+ * app the user had open. When `restoreFocusAfterLaunch` is set we ask the driver
552
+ * to re-activate that app once the server is up; otherwise the runner stays in
553
+ * front and commands like `inspect` run against the wrong target. The driver
554
+ * falls back to pressing home when no candidate app is found.
555
+ *
556
+ * Subsequent restarts (e.g. health-check recovery) skip the restore to avoid
557
+ * disrupting whatever the user is doing.
553
558
  */
554
- async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, dismissAfterLaunch = false) {
559
+ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, restoreFocusAfterLaunch = false) {
555
560
  if (await isPortOpen(port)) {
556
561
  (0, verbose_js_1.log)(`tvOS driver already running on port ${port}`);
557
562
  return;
@@ -579,13 +584,18 @@ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, dismissAfterLaun
579
584
  await (0, utils_js_1.sleep)(TVOS_STARTUP_POLL_MS);
580
585
  if (await isPortOpen(port)) {
581
586
  (0, verbose_js_1.log)(`tvOS driver ready on port ${port}`);
582
- if (dismissAfterLaunch) {
587
+ if (restoreFocusAfterLaunch) {
583
588
  try {
584
- await pressButtonViaDriver(port, 'home');
585
- (0, verbose_js_1.log)('Dismissed tvOS driver app');
589
+ const restored = await restoreFocusViaDriver(port);
590
+ if (restored) {
591
+ (0, verbose_js_1.log)(`Restored tvOS focus to ${restored}`);
592
+ }
593
+ else {
594
+ (0, verbose_js_1.log)('Dismissed tvOS driver app (no previous app to restore)');
595
+ }
586
596
  }
587
597
  catch {
588
- (0, verbose_js_1.log)('Could not dismiss tvOS driver app (non-fatal)');
598
+ (0, verbose_js_1.log)('Could not restore tvOS focus (non-fatal)');
589
599
  }
590
600
  }
591
601
  return;
@@ -827,25 +837,39 @@ async function uninstallDriver(deviceId, platform) {
827
837
  }
828
838
  }
829
839
  // ── Helpers ───────────────────────────────────────────────────────────────────
830
- /** Send a pressButton command directly to the driver HTTP server. */
831
- function pressButtonViaDriver(port, button) {
840
+ /**
841
+ * Ask the driver to restore foreground focus to whatever app was active before
842
+ * the runner launched. Returns the bundle ID that was activated, or null when
843
+ * the driver fell back to pressing home because no previous app was found.
844
+ */
845
+ function restoreFocusViaDriver(port) {
832
846
  return new Promise((resolve, reject) => {
833
- const body = JSON.stringify({ button });
834
847
  const options = {
835
848
  hostname: '127.0.0.1',
836
849
  port,
837
- path: '/pressButton',
850
+ path: '/restoreFocus',
838
851
  method: 'POST',
839
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
852
+ headers: { 'Content-Type': 'application/json', 'Content-Length': 0 },
840
853
  };
841
854
  const req = http_1.default.request(options, (res) => {
842
- res.resume();
843
- res.on('end', () => res.statusCode && res.statusCode < 300
844
- ? resolve()
845
- : reject(new Error(`HTTP ${res.statusCode}`)));
855
+ const chunks = [];
856
+ res.on('data', (c) => chunks.push(c));
857
+ res.on('end', () => {
858
+ if (!res.statusCode || res.statusCode >= 300) {
859
+ reject(new Error(`HTTP ${res.statusCode}`));
860
+ return;
861
+ }
862
+ try {
863
+ const body = Buffer.concat(chunks).toString('utf-8');
864
+ const parsed = JSON.parse(body);
865
+ resolve(parsed.restoredBundleId ? parsed.restoredBundleId : null);
866
+ }
867
+ catch (err) {
868
+ reject(err);
869
+ }
870
+ });
846
871
  });
847
872
  req.on('error', reject);
848
- req.write(body);
849
873
  req.end();
850
874
  });
851
875
  }
@@ -63,6 +63,7 @@ function makeIOSDirectResolver(driver, sel, appIds = []) {
63
63
  return {
64
64
  centerX: X + Width / 2,
65
65
  centerY: Y + Height / 2,
66
+ bounds: { x: X, y: Y, width: Width, height: Height },
66
67
  text: n.label || n.title || n.value || n.placeholderValue || undefined,
67
68
  id: n.identifier || undefined,
68
69
  };
@@ -204,6 +204,7 @@ function findIOSElement(root, sel) {
204
204
  return {
205
205
  centerX: X + Width / 2,
206
206
  centerY: Y + Height / 2,
207
+ bounds: { x: X, y: Y, width: Width, height: Height },
207
208
  text: iosTextOf(node) || undefined,
208
209
  id: node.identifier || undefined,
209
210
  };
@@ -353,6 +354,7 @@ function findAndroidElement(xml, sel) {
353
354
  return {
354
355
  centerX: (x1 + x2) / 2,
355
356
  centerY: (y1 + y2) / 2,
357
+ bounds: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 },
356
358
  text: androidTextOf(node) || undefined,
357
359
  id: node.resourceId || undefined,
358
360
  };
@@ -558,6 +560,7 @@ function findWebElement(hierarchy, sel) {
558
560
  return {
559
561
  centerX: b.x + b.width / 2,
560
562
  centerY: b.y + b.height / 2,
563
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
561
564
  text: node.name || undefined,
562
565
  id: node.ref || undefined,
563
566
  };
@@ -38,17 +38,17 @@ async function startRecording(sessionName, out, appId) {
38
38
  return target;
39
39
  }
40
40
  async function finishRecording(sessionName) {
41
- const session = (await (0, session_js_1.getSession)(sessionName));
41
+ const session = await (0, session_js_1.getSession)(sessionName);
42
42
  if (!session.recordingPath)
43
43
  return null;
44
44
  const out = session.recordingPath;
45
45
  fs_1.default.appendFileSync(out, `# Recording finished ${new Date().toISOString()}\n`);
46
- delete session.recordingPath;
47
- await (0, session_js_1.updateSession)(session, sessionName);
46
+ // Setting to undefined clears the key on save — JSON.stringify drops it.
47
+ await (0, session_js_1.updateSession)({ recordingPath: undefined }, sessionName);
48
48
  return out;
49
49
  }
50
50
  async function getActiveRecording(sessionName) {
51
- const session = (await (0, session_js_1.getSession)(sessionName));
51
+ const session = await (0, session_js_1.getSession)(sessionName);
52
52
  return session.recordingPath ?? null;
53
53
  }
54
54
  function appendStep(filePath, yamlStep) {
@@ -25,13 +25,19 @@ exports.targetsForDevice = targetsForDevice;
25
25
  const child_process_1 = require("child_process");
26
26
  const metro_js_1 = require("./metro.js");
27
27
  const sdk_js_1 = require("../../android/sdk.js");
28
- /** Metro dev-server port ranges we consider. */
29
- const METRO_PORT_RANGES = [
30
- [8080, 8099], // Metro default range
31
- [19000, 19002], // Expo
32
- ];
28
+ /**
29
+ * Heuristic to reject ports that obviously aren't a Metro dev server (system
30
+ * ports, well-known services). Candidates that pass are always verified via
31
+ * {@link fetchTargets} before being returned, so this filter only exists to
32
+ * keep the number of verification probes bounded.
33
+ */
33
34
  function isMetroPort(port) {
34
- return METRO_PORT_RANGES.some(([lo, hi]) => port >= lo && port <= hi);
35
+ if (port < 1024 || port > 65535)
36
+ return false;
37
+ // Skip a handful of common services that share the localhost loopback to
38
+ // avoid pointless /json probes against them.
39
+ const wellKnownNonMetro = new Set([3306, 5432, 6379, 27017, 9200, 11211]);
40
+ return !wellKnownNonMetro.has(port);
35
41
  }
36
42
  function spawnCapture(cmd, args) {
37
43
  return new Promise((resolve, reject) => {
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.MetroCdpClient = void 0;
7
+ exports.selectDebuggerUrl = selectDebuggerUrl;
7
8
  exports.resolveDebuggerUrl = resolveDebuggerUrl;
8
9
  exports.cdpCall = cdpCall;
9
10
  /**
@@ -20,13 +21,15 @@ const ws_1 = __importDefault(require("ws"));
20
21
  const metro_js_1 = require("./log-sources/metro.js");
21
22
  const metro_discovery_js_1 = require("./log-sources/metro-discovery.js");
22
23
  /**
23
- * Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex.
24
- * Throws with a clear message if Metro is unreachable or no target matches.
24
+ * Pick a debugger `webSocketDebuggerUrl` from an already-fetched target list.
25
+ * Pure the async `fetchTargets` / `getDeviceDisplayName` calls happen in
26
+ * `resolveDebuggerUrl`. `displayName` is the device's resolved display name,
27
+ * used for device-scoped selection when present. Throws with a clear message
28
+ * when no target matches.
25
29
  */
26
- async function resolveDebuggerUrl(opts) {
30
+ function selectDebuggerUrl(targets, opts, displayName) {
27
31
  const port = opts.port ?? 8081;
28
32
  const host = opts.host ?? 'localhost';
29
- const targets = await (0, metro_js_1.fetchTargets)(port, host);
30
33
  const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
31
34
  if (withWs.length === 0) {
32
35
  throw new Error(`Metro on ${host}:${port} returned no debugger targets. Is an app running on a device/simulator?`);
@@ -37,18 +40,29 @@ async function resolveDebuggerUrl(opts) {
37
40
  }
38
41
  return withWs[opts.targetIndex].webSocketDebuggerUrl;
39
42
  }
40
- if (opts.deviceId && opts.platform) {
41
- const displayName = await (0, metro_discovery_js_1.getDeviceDisplayName)(opts.platform, opts.deviceId);
42
- if (displayName) {
43
- const target = (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName);
44
- if (target)
45
- return target.webSocketDebuggerUrl;
46
- }
43
+ if (displayName) {
44
+ const target = (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName);
45
+ if (target)
46
+ return target.webSocketDebuggerUrl;
47
47
  }
48
48
  // Prefer the Hermes/React target by title, otherwise first.
49
49
  const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0];
50
50
  return target.webSocketDebuggerUrl;
51
51
  }
52
+ /**
53
+ * Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex.
54
+ * Throws with a clear message if Metro is unreachable or no target matches.
55
+ */
56
+ async function resolveDebuggerUrl(opts) {
57
+ const port = opts.port ?? 8081;
58
+ const host = opts.host ?? 'localhost';
59
+ const targets = await (0, metro_js_1.fetchTargets)(port, host);
60
+ let displayName;
61
+ if (opts.deviceId && opts.platform) {
62
+ displayName = (await (0, metro_discovery_js_1.getDeviceDisplayName)(opts.platform, opts.deviceId)) ?? undefined;
63
+ }
64
+ return selectDebuggerUrl(targets, opts, displayName);
65
+ }
52
66
  /**
53
67
  * Open a short-lived CDP socket, send a single method, return the result.
54
68
  * Closes the socket whether the call succeeds or throws.
@@ -157,6 +157,9 @@ class WebDriver {
157
157
  async eraseAllText(count = 50) {
158
158
  await this.post('eraseText', { count });
159
159
  }
160
+ async setViewport(opts) {
161
+ await this.post('setViewport', opts);
162
+ }
160
163
  // ── Stub methods for mobile-only features ──────────────────────────────────
161
164
  // These throw clear errors rather than silently no-op so the user knows
162
165
  // the command isn't applicable to web.
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ const hide_keyboard_js_1 = require("./commands/hide-keyboard.js");
51
51
  const scroll_until_visible_js_1 = require("./commands/scroll-until-visible.js");
52
52
  const set_location_js_1 = require("./commands/set-location.js");
53
53
  const set_orientation_js_1 = require("./commands/set-orientation.js");
54
+ const set_viewport_js_1 = require("./commands/set-viewport.js");
54
55
  const start_device_js_1 = require("./commands/start-device.js");
55
56
  const stop_device_js_1 = require("./commands/stop-device.js");
56
57
  const delete_device_js_1 = require("./commands/delete-device.js");
@@ -91,6 +92,7 @@ const COMMAND_HELP = {
91
92
  'open-link': open_link_js_1.HELP,
92
93
  'set-location': set_location_js_1.HELP,
93
94
  'set-orientation': set_orientation_js_1.HELP,
95
+ 'set-viewport': set_viewport_js_1.HELP,
94
96
  'take-screenshot': screenshot_js_1.HELP,
95
97
  'capture-ui': capture_ui_js_1.HELP,
96
98
  inspect: inspect_js_1.HELP,
@@ -214,6 +216,11 @@ async function main() {
214
216
  'interval',
215
217
  'app',
216
218
  'since',
219
+ 'preset',
220
+ 'width',
221
+ 'height',
222
+ 'user-agent',
223
+ 'color-scheme',
217
224
  ],
218
225
  alias: { h: 'help', v: 'verbose', V: 'version' },
219
226
  });
@@ -502,10 +509,44 @@ async function main() {
502
509
  exitCode = await (0, set_orientation_js_1.setOrientation)(orientation, opts, sessionName);
503
510
  break;
504
511
  }
512
+ case 'set-viewport': {
513
+ exitCode = await (0, set_viewport_js_1.setViewport)({
514
+ preset: argv['preset'],
515
+ width: argv['width'] !== undefined
516
+ ? Number(argv['width'])
517
+ : rest[0] !== undefined
518
+ ? Number(rest[0])
519
+ : undefined,
520
+ height: argv['height'] !== undefined
521
+ ? Number(argv['height'])
522
+ : rest[1] !== undefined
523
+ ? Number(rest[1])
524
+ : undefined,
525
+ scale: argv['scale'] !== undefined ? Number(argv['scale']) : undefined,
526
+ mobile: argv['mobile'],
527
+ userAgent: argv['user-agent'],
528
+ colorScheme: argv['color-scheme'],
529
+ }, opts, sessionName);
530
+ break;
531
+ }
505
532
  case 'take-screenshot': {
506
533
  const outPath = argv['output'];
507
534
  const fullPage = Boolean(argv['full-page']);
508
- exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName, fullPage);
535
+ const element = rest.join(' ');
536
+ exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName, fullPage, element, {
537
+ id: argv['id'],
538
+ text: argv['text'],
539
+ index: argv['index'] !== undefined ? Number(argv['index']) : undefined,
540
+ margin: argv['margin'] !== undefined ? Number(argv['margin']) : undefined,
541
+ focused: argv['focused'] !== undefined ? argv['focused'] : undefined,
542
+ enabled: argv['enabled'] !== undefined ? argv['enabled'] : undefined,
543
+ checked: argv['checked'] !== undefined ? argv['checked'] : undefined,
544
+ selected: argv['selected'] !== undefined ? argv['selected'] : undefined,
545
+ below: argv['below'],
546
+ above: argv['above'],
547
+ leftOf: argv['left-of'],
548
+ rightOf: argv['right-of'],
549
+ });
509
550
  break;
510
551
  }
511
552
  case 'capture-ui': {
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.readPngDimensions = readPngDimensions;
7
+ exports.cropPng = cropPng;
8
+ /**
9
+ * Minimal PNG cropper — decodes 8-bit RGB / RGBA / grayscale / grayscale+alpha
10
+ * PNGs, crops to a rect, re-encodes with filter type 0 (None).
11
+ *
12
+ * Used by `take-screenshot --id/--text/<query>` to return only the pixels
13
+ * inside a resolved element's bounds.
14
+ */
15
+ const zlib_1 = __importDefault(require("zlib"));
16
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
17
+ const CRC_TABLE = (() => {
18
+ const table = new Uint32Array(256);
19
+ for (let n = 0; n < 256; n++) {
20
+ let c = n;
21
+ for (let k = 0; k < 8; k++)
22
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
23
+ table[n] = c >>> 0;
24
+ }
25
+ return table;
26
+ })();
27
+ function crc32(buf) {
28
+ let c = 0xffffffff;
29
+ for (let i = 0; i < buf.length; i++)
30
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
31
+ return (c ^ 0xffffffff) >>> 0;
32
+ }
33
+ /** Read width/height from a PNG IHDR chunk. Throws if `buf` is not a PNG. */
34
+ function readPngDimensions(buf) {
35
+ if (buf.length < 24 || !SIGNATURE.equals(buf.subarray(0, 8))) {
36
+ throw new Error('not a PNG');
37
+ }
38
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
39
+ }
40
+ function bytesPerPixel(colorType) {
41
+ switch (colorType) {
42
+ case 0:
43
+ return 1; // grayscale
44
+ case 2:
45
+ return 3; // RGB
46
+ case 3:
47
+ return 1; // palette (unsupported below)
48
+ case 4:
49
+ return 2; // grayscale + alpha
50
+ case 6:
51
+ return 4; // RGBA
52
+ default:
53
+ throw new Error(`unsupported PNG color type ${colorType}`);
54
+ }
55
+ }
56
+ function paeth(a, b, c) {
57
+ const p = a + b - c;
58
+ const pa = Math.abs(p - a);
59
+ const pb = Math.abs(p - b);
60
+ const pc = Math.abs(p - c);
61
+ if (pa <= pb && pa <= pc)
62
+ return a;
63
+ if (pb <= pc)
64
+ return b;
65
+ return c;
66
+ }
67
+ function unfilter(filtered, w, h, bpp) {
68
+ const stride = w * bpp;
69
+ const out = Buffer.alloc(stride * h);
70
+ let inOff = 0;
71
+ for (let y = 0; y < h; y++) {
72
+ const filter = filtered[inOff++];
73
+ const rowOff = y * stride;
74
+ for (let x = 0; x < stride; x++) {
75
+ const raw = filtered[inOff++];
76
+ const left = x >= bpp ? out[rowOff + x - bpp] : 0;
77
+ const up = y > 0 ? out[rowOff - stride + x] : 0;
78
+ const upLeft = x >= bpp && y > 0 ? out[rowOff - stride + x - bpp] : 0;
79
+ let v;
80
+ switch (filter) {
81
+ case 0:
82
+ v = raw;
83
+ break;
84
+ case 1:
85
+ v = raw + left;
86
+ break;
87
+ case 2:
88
+ v = raw + up;
89
+ break;
90
+ case 3:
91
+ v = raw + ((left + up) >> 1);
92
+ break;
93
+ case 4:
94
+ v = raw + paeth(left, up, upLeft);
95
+ break;
96
+ default:
97
+ throw new Error(`unsupported PNG filter ${filter}`);
98
+ }
99
+ out[rowOff + x] = v & 0xff;
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ function writeChunk(out, type, data) {
105
+ const len = Buffer.alloc(4);
106
+ len.writeUInt32BE(data.length, 0);
107
+ const typeBuf = Buffer.from(type, 'ascii');
108
+ const crcInput = Buffer.concat([typeBuf, data]);
109
+ const crc = Buffer.alloc(4);
110
+ crc.writeUInt32BE(crc32(crcInput), 0);
111
+ out.push(len, typeBuf, data, crc);
112
+ }
113
+ /**
114
+ * Crop a PNG buffer to the given rect. Coordinates are in PNG pixel space;
115
+ * `x`/`y`/`width`/`height` are clamped to the PNG canvas before cropping.
116
+ * Throws if the rect lies fully outside the canvas.
117
+ */
118
+ function cropPng(buf, rect) {
119
+ if (buf.length < 8 || !SIGNATURE.equals(buf.subarray(0, 8))) {
120
+ throw new Error('not a PNG');
121
+ }
122
+ let off = 8;
123
+ let ihdr = null;
124
+ const idatParts = [];
125
+ while (off < buf.length) {
126
+ const len = buf.readUInt32BE(off);
127
+ off += 4;
128
+ const type = buf.subarray(off, off + 4).toString('ascii');
129
+ off += 4;
130
+ const data = buf.subarray(off, off + len);
131
+ off += len;
132
+ off += 4; // CRC
133
+ if (type === 'IHDR') {
134
+ ihdr = {
135
+ width: data.readUInt32BE(0),
136
+ height: data.readUInt32BE(4),
137
+ bitDepth: data.readUInt8(8),
138
+ colorType: data.readUInt8(9),
139
+ };
140
+ }
141
+ else if (type === 'IDAT') {
142
+ idatParts.push(data);
143
+ }
144
+ else if (type === 'IEND') {
145
+ break;
146
+ }
147
+ }
148
+ if (!ihdr)
149
+ throw new Error('PNG missing IHDR');
150
+ if (ihdr.bitDepth !== 8) {
151
+ throw new Error(`unsupported PNG bit depth ${ihdr.bitDepth} (only 8 supported)`);
152
+ }
153
+ if (ihdr.colorType === 3) {
154
+ throw new Error('palette PNGs are not supported for cropping');
155
+ }
156
+ const bpp = bytesPerPixel(ihdr.colorType);
157
+ // Clamp crop rect to canvas bounds
158
+ const cx = Math.max(0, Math.min(Math.floor(rect.x), ihdr.width));
159
+ const cy = Math.max(0, Math.min(Math.floor(rect.y), ihdr.height));
160
+ const cx2 = Math.max(0, Math.min(Math.floor(rect.x + rect.width), ihdr.width));
161
+ const cy2 = Math.max(0, Math.min(Math.floor(rect.y + rect.height), ihdr.height));
162
+ const cw = cx2 - cx;
163
+ const ch = cy2 - cy;
164
+ if (cw <= 0 || ch <= 0) {
165
+ throw new Error('crop rect is outside the screenshot canvas');
166
+ }
167
+ const filtered = zlib_1.default.inflateSync(Buffer.concat(idatParts));
168
+ const raw = unfilter(filtered, ihdr.width, ihdr.height, bpp);
169
+ const srcStride = ihdr.width * bpp;
170
+ const dstStride = cw * bpp;
171
+ // New filtered scanlines with filter byte 0 (None) prefix.
172
+ const filteredOut = Buffer.alloc(ch * (dstStride + 1));
173
+ for (let y = 0; y < ch; y++) {
174
+ filteredOut[y * (dstStride + 1)] = 0;
175
+ raw.copy(filteredOut, y * (dstStride + 1) + 1, (cy + y) * srcStride + cx * bpp, (cy + y) * srcStride + cx * bpp + dstStride);
176
+ }
177
+ const idat = zlib_1.default.deflateSync(filteredOut);
178
+ const chunks = [SIGNATURE];
179
+ const ihdrData = Buffer.alloc(13);
180
+ ihdrData.writeUInt32BE(cw, 0);
181
+ ihdrData.writeUInt32BE(ch, 4);
182
+ ihdrData.writeUInt8(ihdr.bitDepth, 8);
183
+ ihdrData.writeUInt8(ihdr.colorType, 9);
184
+ ihdrData.writeUInt8(0, 10); // compression
185
+ ihdrData.writeUInt8(0, 11); // filter method
186
+ ihdrData.writeUInt8(0, 12); // interlace
187
+ writeChunk(chunks, 'IHDR', ihdrData);
188
+ writeChunk(chunks, 'IDAT', idat);
189
+ writeChunk(chunks, 'IEND', Buffer.alloc(0));
190
+ return Buffer.concat(chunks);
191
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SNAPSHOT_STALE_MS = void 0;
7
+ exports.snapshotFilePath = snapshotFilePath;
8
+ exports.isRefQuery = isRefQuery;
9
+ exports.buildStoredSnapshot = buildStoredSnapshot;
10
+ exports.saveSnapshot = saveSnapshot;
11
+ exports.loadSnapshot = loadSnapshot;
12
+ exports.resolveRef = resolveRef;
13
+ /**
14
+ * Snapshot-scoped ephemeral element refs.
15
+ *
16
+ * `capture-ui` assigns each accessible element a short ref (`@e1`, `@e2`, …) and
17
+ * persists its resolved screen coordinates here, keyed by session. `tap-on @e3`
18
+ * then taps the cached point directly — no fuzzy text/id matching.
19
+ *
20
+ * Refs are deliberately ephemeral: a stale snapshot warns (it does not hard-fail),
21
+ * and the agent is expected to re-run `capture-ui` and act on fresh refs.
22
+ */
23
+ const promises_1 = __importDefault(require("fs/promises"));
24
+ const os_1 = __importDefault(require("os"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const SNAPSHOTS_DIR = path_1.default.join(os_1.default.homedir(), '.conductor', 'snapshots');
27
+ /** A snapshot older than this is considered stale. */
28
+ exports.SNAPSHOT_STALE_MS = 60000;
29
+ function snapshotFilePath(sessionName = 'default') {
30
+ return path_1.default.join(SNAPSHOTS_DIR, `${sessionName}.json`);
31
+ }
32
+ /** True when `s` looks like an ephemeral element ref (`@e3`). */
33
+ function isRefQuery(s) {
34
+ return /^@e\d+$/i.test(s.trim());
35
+ }
36
+ /** Build a `StoredSnapshot` from a freshly built a11y snapshot. */
37
+ function buildStoredSnapshot(entries, device) {
38
+ const refs = {};
39
+ for (const e of entries) {
40
+ refs[e.ref] = {
41
+ ref: e.ref,
42
+ centerX: e.frame.x + e.frame.w / 2,
43
+ centerY: e.frame.y + e.frame.h / 2,
44
+ frame: e.frame,
45
+ label: e.label,
46
+ nodeId: e.nodeId,
47
+ };
48
+ }
49
+ return {
50
+ version: 1,
51
+ capturedAt: new Date().toISOString(),
52
+ deviceId: device.deviceId,
53
+ platform: device.platform,
54
+ refs,
55
+ };
56
+ }
57
+ async function saveSnapshot(sessionName, snapshot) {
58
+ await promises_1.default.mkdir(SNAPSHOTS_DIR, { recursive: true });
59
+ await promises_1.default.writeFile(snapshotFilePath(sessionName), JSON.stringify(snapshot, null, 2));
60
+ }
61
+ async function loadSnapshot(sessionName) {
62
+ try {
63
+ const data = await promises_1.default.readFile(snapshotFilePath(sessionName), 'utf-8');
64
+ return JSON.parse(data);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Resolve an `@eN` ref against the session's last `capture-ui` snapshot.
72
+ * Throws when there is no snapshot or the ref is unknown. `staleReason` is
73
+ * advisory — callers warn but still act, since refs are explicitly ephemeral.
74
+ */
75
+ function resolveRef(snapshot, ref, ctx) {
76
+ if (!snapshot) {
77
+ throw new Error(`no snapshot for this session — run \`conductor capture-ui\` before using ${ref}`);
78
+ }
79
+ const norm = ref.trim().toLowerCase();
80
+ const key = Object.keys(snapshot.refs).find((k) => k.toLowerCase() === norm);
81
+ if (!key) {
82
+ const avail = Object.keys(snapshot.refs);
83
+ const shown = avail.slice(0, 8).join(', ');
84
+ throw new Error(`${ref} is not in the last snapshot ` +
85
+ `(${avail.length} ref${avail.length === 1 ? '' : 's'}: ${shown}${avail.length > 8 ? ', …' : ''})`);
86
+ }
87
+ let staleReason = null;
88
+ const ageMs = Date.now() - new Date(snapshot.capturedAt).getTime();
89
+ if (ageMs > exports.SNAPSHOT_STALE_MS) {
90
+ staleReason = `snapshot is ${Math.round(ageMs / 1000)}s old`;
91
+ }
92
+ else if (ctx?.deviceId && snapshot.deviceId && ctx.deviceId !== snapshot.deviceId) {
93
+ staleReason = `snapshot was captured on a different device (${snapshot.deviceId})`;
94
+ }
95
+ return { entry: snapshot.refs[key], staleReason };
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {