@houwert/conductor 0.5.0 → 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.
Files changed (52) hide show
  1. package/README.md +12 -9
  2. package/dist/commands/assert-not-visible.js +4 -0
  3. package/dist/commands/assert-visible.js +4 -0
  4. package/dist/commands/back.js +4 -0
  5. package/dist/commands/cheat-sheet.js +11 -8
  6. package/dist/commands/delete-device.js +222 -0
  7. package/dist/commands/device-pool.js +33 -22
  8. package/dist/commands/download-app.js +87 -0
  9. package/dist/commands/erase-text.js +4 -0
  10. package/dist/commands/focused.js +39 -0
  11. package/dist/commands/foreground-app.js +4 -0
  12. package/dist/commands/hide-keyboard.js +4 -0
  13. package/dist/commands/inspect.js +8 -0
  14. package/dist/commands/install.js +103 -27
  15. package/dist/commands/launch-app.js +6 -0
  16. package/dist/commands/list-devices.js +39 -2
  17. package/dist/commands/logs.js +193 -0
  18. package/dist/commands/press-key.js +16 -0
  19. package/dist/commands/screenshot.js +1 -1
  20. package/dist/commands/scroll-until-visible.js +11 -0
  21. package/dist/commands/scroll.js +6 -0
  22. package/dist/commands/start-device.js +38 -4
  23. package/dist/commands/stop-app.js +4 -0
  24. package/dist/commands/swipe.js +22 -0
  25. package/dist/commands/tap.js +10 -3
  26. package/dist/commands/type.js +2 -2
  27. package/dist/commands/uninstall-app.js +4 -0
  28. package/dist/daemon/client.js +72 -9
  29. package/dist/daemon/log-collector.js +408 -0
  30. package/dist/daemon/server.js +110 -32
  31. package/dist/daemon/web-server.js +812 -0
  32. package/dist/device-picker.js +7 -2
  33. package/dist/drivers/bootstrap.js +124 -1
  34. package/dist/drivers/element-resolver.js +241 -30
  35. package/dist/drivers/flow-runner.js +63 -21
  36. package/dist/drivers/log-sources/android.js +156 -0
  37. package/dist/drivers/log-sources/daemon.js +112 -0
  38. package/dist/drivers/log-sources/ios.js +106 -0
  39. package/dist/drivers/log-sources/metro.js +252 -0
  40. package/dist/drivers/log-sources/types.js +13 -0
  41. package/dist/drivers/log-sources/web.js +96 -0
  42. package/dist/drivers/wait.js +57 -0
  43. package/dist/drivers/web.js +173 -0
  44. package/dist/index.js +62 -12
  45. package/dist/runner.js +32 -2
  46. package/drivers/ios/conductor-driver-ios.zip +0 -0
  47. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  48. package/drivers/tvos/conductor-driver-tvos.zip +0 -0
  49. package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
  50. package/package.json +5 -2
  51. package/skills/conductor/SKILL.md +72 -41
  52. package/skills/skills.yaml +1 -1
@@ -11,9 +11,14 @@ const list_devices_js_1 = require("./commands/list-devices.js");
11
11
  * - 1 device → returns it automatically
12
12
  * - N devices + TTY → shows a numbered picker
13
13
  * - N devices + no TTY → returns undefined (caller should error)
14
+ *
15
+ * When `platform` is provided, only devices matching that platform are considered.
14
16
  */
15
- async function pickDevice() {
16
- const devices = await (0, list_devices_js_1.discoverBootedDevices)();
17
+ async function pickDevice(platform) {
18
+ let devices = await (0, list_devices_js_1.discoverBootedDevices)();
19
+ if (platform) {
20
+ devices = devices.filter((d) => d.platform === platform.toLowerCase());
21
+ }
17
22
  if (devices.length === 0)
18
23
  return undefined;
19
24
  if (devices.length === 1)
@@ -15,6 +15,12 @@ exports.startTvOSDriver = startTvOSDriver;
15
15
  exports.stopTvOSDriver = stopTvOSDriver;
16
16
  exports.startAndroidDriver = startAndroidDriver;
17
17
  exports.stopAndroidDriver = stopAndroidDriver;
18
+ exports.webBrowserName = webBrowserName;
19
+ exports.generateWebSessionId = generateWebSessionId;
20
+ exports.isUnqualifiedWebId = isUnqualifiedWebId;
21
+ exports.isPlaywrightBrowserInstalled = isPlaywrightBrowserInstalled;
22
+ exports.ensurePlaywrightBrowser = ensurePlaywrightBrowser;
23
+ exports.stopWebDriver = stopWebDriver;
18
24
  exports.uninstallDriver = uninstallDriver;
19
25
  /**
20
26
  * Driver lifecycle manager.
@@ -27,6 +33,7 @@ exports.uninstallDriver = uninstallDriver;
27
33
  * drivers/ios/ — no separate Conductor/JVM installation required.
28
34
  */
29
35
  const child_process_1 = require("child_process");
36
+ const crypto_1 = __importDefault(require("crypto"));
30
37
  const http_1 = __importDefault(require("http"));
31
38
  const net_1 = __importDefault(require("net"));
32
39
  const os_1 = __importDefault(require("os"));
@@ -39,6 +46,11 @@ const _platformCache = new Map();
39
46
  async function detectPlatform(deviceId) {
40
47
  if (_platformCache.has(deviceId))
41
48
  return _platformCache.get(deviceId);
49
+ // Web browser: "web", "web:chromium", "web:firefox", "web:webkit"
50
+ if (deviceId === 'web' || deviceId.startsWith('web:')) {
51
+ _platformCache.set(deviceId, 'web');
52
+ return 'web';
53
+ }
42
54
  // Check if it looks like an iOS/tvOS simulator UUID (8-4-4-4-12 hex chars)
43
55
  const iosUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
44
56
  if (iosUuidRe.test(deviceId)) {
@@ -68,6 +80,7 @@ async function detectPlatform(deviceId) {
68
80
  const IOS_BASE_PORT = 1075;
69
81
  const TVOS_BASE_PORT = 2075;
70
82
  const ANDROID_BASE_PORT = 3763;
83
+ const WEB_BASE_PORT = 4075;
71
84
  const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ports.json');
72
85
  const PORT_LOCK = PORT_FILE + '.lock';
73
86
  const PORT_LOCK_TIMEOUT_MS = 5000;
@@ -81,6 +94,7 @@ function readPortState() {
81
94
  nextIosPort: IOS_BASE_PORT,
82
95
  nextTvosPort: TVOS_BASE_PORT,
83
96
  nextAndroidPort: ANDROID_BASE_PORT,
97
+ nextWebPort: WEB_BASE_PORT,
84
98
  };
85
99
  }
86
100
  }
@@ -124,9 +138,11 @@ async function getDriverPort(platform, deviceId) {
124
138
  if (state.assignments[deviceId] !== undefined) {
125
139
  return state.assignments[deviceId];
126
140
  }
127
- // Ensure tvos counter is initialised for port files created before tvOS support
141
+ // Ensure counters are initialised for port files created before new platform support
128
142
  if (state.nextTvosPort === undefined)
129
143
  state.nextTvosPort = TVOS_BASE_PORT;
144
+ if (state.nextWebPort === undefined)
145
+ state.nextWebPort = WEB_BASE_PORT;
130
146
  let port;
131
147
  if (platform === 'ios') {
132
148
  port = state.nextIosPort++;
@@ -134,6 +150,9 @@ async function getDriverPort(platform, deviceId) {
134
150
  else if (platform === 'tvos') {
135
151
  port = state.nextTvosPort++;
136
152
  }
153
+ else if (platform === 'web') {
154
+ port = state.nextWebPort++;
155
+ }
137
156
  else {
138
157
  port = state.nextAndroidPort++;
139
158
  }
@@ -480,6 +499,110 @@ async function stopAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
480
499
  ]).catch(() => { });
481
500
  await spawnAndWait('adb', ['-s', deviceId, 'forward', '--remove', `tcp:${port}`]).catch(() => { });
482
501
  }
502
+ // ── Web bootstrap ────────────────────────────────────────────────────────────
503
+ /**
504
+ * Extract the browser name from a web device ID.
505
+ * "web" → "chromium", "web:firefox" → "firefox", "web:webkit" → "webkit"
506
+ *
507
+ * Also handles instance-qualified IDs:
508
+ * "web:chromium:abc1" → "chromium", "web:firefox:abc1" → "firefox"
509
+ */
510
+ function webBrowserName(deviceId) {
511
+ if (deviceId.startsWith('web:')) {
512
+ const browser = deviceId.slice(4).split(':')[0];
513
+ if (browser === 'firefox' || browser === 'webkit')
514
+ return browser;
515
+ }
516
+ return 'chromium';
517
+ }
518
+ /**
519
+ * Generate a unique web session ID for parallel browser instances.
520
+ * Format: "web:<browser>:<8-hex-char-id>"
521
+ */
522
+ function generateWebSessionId(browserName = 'chromium') {
523
+ const id = crypto_1.default.randomBytes(4).toString('hex');
524
+ return `web:${browserName}:${id}`;
525
+ }
526
+ /**
527
+ * True when the device ID refers to a web browser but does NOT include
528
+ * an instance qualifier — i.e. "web" or "web:<browser>" but not "web:<browser>:<id>".
529
+ */
530
+ function isUnqualifiedWebId(deviceId) {
531
+ if (deviceId === 'web')
532
+ return true;
533
+ if (!deviceId.startsWith('web:'))
534
+ return false;
535
+ return deviceId.split(':').length === 2;
536
+ }
537
+ /**
538
+ * Check whether a Playwright browser is installed for the current playwright-core version.
539
+ * Returns true if the executable exists, false otherwise.
540
+ */
541
+ function isPlaywrightBrowserInstalled(browserName) {
542
+ try {
543
+ // Dynamic import to avoid pulling playwright-core into every code path
544
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
545
+ const pw = require('playwright-core');
546
+ const execPath = pw[browserName].executablePath();
547
+ return fs_1.default.existsSync(execPath);
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ }
553
+ /**
554
+ * Install a Playwright browser if not already present.
555
+ * Shells out to `npx playwright-core install <browser>` which downloads the
556
+ * version-matched browser binary to the default cache (~/.cache/ms-playwright).
557
+ */
558
+ async function ensurePlaywrightBrowser(browserName, logger = verbose_js_1.log) {
559
+ if (isPlaywrightBrowserInstalled(browserName)) {
560
+ logger(`Playwright ${browserName} is already installed`);
561
+ return;
562
+ }
563
+ logger(`Installing Playwright ${browserName} browser...`);
564
+ // Use the playwright-core CLI to install the browser.
565
+ // Resolve the playwright-core binary from node_modules.
566
+ const pwCoreBin = path_1.default.join(path_1.default.dirname(require.resolve('playwright-core/package.json')), 'cli.js');
567
+ await new Promise((resolve, reject) => {
568
+ const proc = (0, child_process_1.spawn)(process.execPath, [pwCoreBin, 'install', browserName], {
569
+ stdio: ['ignore', 'pipe', 'pipe'],
570
+ });
571
+ let stderr = '';
572
+ proc.stderr?.on('data', (chunk) => {
573
+ stderr += chunk.toString();
574
+ });
575
+ proc.stdout?.on('data', (chunk) => {
576
+ const line = chunk.toString().trim();
577
+ if (line)
578
+ logger(line);
579
+ });
580
+ proc.on('close', (code) => {
581
+ if (code === 0) {
582
+ logger(`Playwright ${browserName} installed successfully`);
583
+ resolve();
584
+ }
585
+ else {
586
+ reject(new Error(`Failed to install Playwright ${browserName} (exit ${code}).\n${stderr.trim()}\n` +
587
+ `You can install manually: npx playwright-core install ${browserName}`));
588
+ }
589
+ });
590
+ proc.on('error', (err) => {
591
+ reject(new Error(`Failed to run Playwright installer: ${err.message}\n` +
592
+ `You can install manually: npx playwright-core install ${browserName}`));
593
+ });
594
+ });
595
+ }
596
+ /**
597
+ * Stop the web driver by sending a shutdown request to its HTTP server.
598
+ */
599
+ async function stopWebDriver(port) {
600
+ return new Promise((resolve) => {
601
+ const req = http_1.default.request({ hostname: '127.0.0.1', port, path: '/shutdown', method: 'POST' }, () => resolve());
602
+ req.on('error', () => resolve());
603
+ req.end();
604
+ });
605
+ }
483
606
  /**
484
607
  * Uninstall the Conductor driver app(s) from the device.
485
608
  */
@@ -5,10 +5,11 @@ exports.parseAndroidHierarchy = parseAndroidHierarchy;
5
5
  exports.findAndroidElement = findAndroidElement;
6
6
  exports.inspectIOSToText = inspectIOSToText;
7
7
  exports.inspectAndroidToText = inspectAndroidToText;
8
+ exports.findWebElement = findWebElement;
9
+ exports.inspectWebToText = inspectWebToText;
8
10
  const verbose_js_1 = require("../verbose.js");
9
11
  // XCUIElementType rawValues that represent interactive controls.
10
- // Mirrors Maestro's clickableFirst() behaviour for iOS, where clickable is not
11
- // exposed in the AXElement — we sort by element type instead.
12
+ // Approximates “prefer clickable” when sorting, since AXElement does not expose clickable.
12
13
  const IOS_INTERACTIVE_TYPES = new Set([
13
14
  9, // Button
14
15
  23, // Slider
@@ -46,37 +47,65 @@ function collectIOSElements(node, results) {
46
47
  function iosTextOf(node) {
47
48
  return node.label || node.title || node.value || node.placeholderValue || '';
48
49
  }
49
- function matchesText(candidate, query) {
50
- if (!query)
51
- return false;
52
- if (candidate === query)
53
- return true;
54
- if (candidate.toLowerCase() === query.toLowerCase())
55
- return true;
56
- // fuzzy: .*query.* regex
50
+ function iosTextMatchFields(node) {
51
+ return [node.label, node.title, node.value, node.placeholderValue];
52
+ }
53
+ function escapeRegex(s) {
54
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
+ }
56
+ function toRegexSafe(pattern) {
57
57
  try {
58
- const re = new RegExp(`.*${escapeRegex(query)}.*`, 'i');
59
- return re.test(candidate);
58
+ return new RegExp(pattern, 'ims');
60
59
  }
61
60
  catch {
61
+ return new RegExp(escapeRegex(pattern), 'ims');
62
+ }
63
+ }
64
+ function regexMatchesEntireString(regex, value) {
65
+ const anchored = new RegExp(`^(?:${regex.source})$`, regex.flags);
66
+ return anchored.test(value);
67
+ }
68
+ /** Entire attribute value must match the pattern; newlines are normalized to spaces for a second pass. */
69
+ function matchPatternAgainstTextField(pattern, value) {
70
+ if (value == null || value === '')
62
71
  return false;
72
+ const re = toRegexSafe(pattern);
73
+ const stripped = value.replace(/\n/g, ' ');
74
+ return (regexMatchesEntireString(re, value) ||
75
+ pattern === value ||
76
+ regexMatchesEntireString(re, stripped) ||
77
+ pattern === stripped);
78
+ }
79
+ function matchPatternAgainstAnyTextField(pattern, fields) {
80
+ for (const f of fields) {
81
+ if (f != null && f !== '' && matchPatternAgainstTextField(pattern, f))
82
+ return true;
63
83
  }
84
+ return false;
64
85
  }
65
- function escapeRegex(s) {
66
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
+ function substringAfterLastSlash(s) {
87
+ const i = s.lastIndexOf('/');
88
+ return i === -1 ? s : s.slice(i + 1);
89
+ }
90
+ function matchPatternAgainstElementId(pattern, value) {
91
+ if (value == null || value === '')
92
+ return false;
93
+ return (matchPatternAgainstTextField(pattern, value) ||
94
+ matchPatternAgainstTextField(pattern, substringAfterLastSlash(value)));
67
95
  }
68
96
  function matchesIOSElement(node, sel) {
69
97
  if (sel.query) {
70
- const text = iosTextOf(node);
71
- if (!matchesText(text, sel.query) && !matchesText(node.identifier, sel.query))
98
+ const textOk = matchPatternAgainstAnyTextField(sel.query, iosTextMatchFields(node));
99
+ const idOk = matchPatternAgainstElementId(sel.query, node.identifier);
100
+ if (!textOk && !idOk)
72
101
  return false;
73
102
  }
74
103
  if (sel.text) {
75
- if (!matchesText(iosTextOf(node), sel.text))
104
+ if (!matchPatternAgainstAnyTextField(sel.text, iosTextMatchFields(node)))
76
105
  return false;
77
106
  }
78
107
  if (sel.id) {
79
- if (!matchesText(node.identifier, sel.id))
108
+ if (!matchPatternAgainstElementId(sel.id, node.identifier))
80
109
  return false;
81
110
  }
82
111
  // State attributes — only match fields that exist on AXElement
@@ -91,10 +120,8 @@ function matchesIOSElement(node, sel) {
91
120
  return true;
92
121
  }
93
122
  /**
94
- * Mirrors Maestro's deepestMatchingElement(): for each branch of the tree,
95
- * return matching nodes only from the deepest level that has a match.
96
- * This prevents parent wrapper nodes that inherit their child's accessibility
97
- * label (common in React Native) from appearing as separate candidates.
123
+ * Deepest match per branch: prefer matches in descendants so parent wrappers that only
124
+ * duplicate a child label (e.g. React Native) do not appear as duplicate tap targets.
98
125
  */
99
126
  function deepestMatchingIOSElements(node, pred) {
100
127
  // Recurse into children first — deepest match wins over ancestor
@@ -150,14 +177,14 @@ function findIOSElement(root, sel) {
150
177
  `type=${n.elementType}${interactive ? ' (interactive)' : ''}`);
151
178
  });
152
179
  if (sel.index !== undefined) {
153
- // Mirror Maestro's INDEX_COMPARATOR: sort top-to-bottom, then left-to-right
180
+ // Sort top-to-bottom, then left-to-right when index is set
154
181
  matches = [...matches].sort((a, b) => {
155
182
  const dy = a.frame.Y - b.frame.Y;
156
183
  return dy !== 0 ? dy : a.frame.X - b.frame.X;
157
184
  });
158
185
  }
159
186
  else {
160
- // Prefer interactive element types (approximation of Maestro's clickableFirst for iOS)
187
+ // Prefer interactive element types when no explicit index
161
188
  matches = [...matches].sort((a, b) => Number(IOS_INTERACTIVE_TYPES.has(b.elementType)) -
162
189
  Number(IOS_INTERACTIVE_TYPES.has(a.elementType)));
163
190
  }
@@ -237,17 +264,22 @@ function parseAndroidHierarchy(xml) {
237
264
  function androidTextOf(n) {
238
265
  return n.text || n.contentDesc || '';
239
266
  }
267
+ function androidTextMatchFields(n) {
268
+ return [n.text, n.contentDesc, n.hintText];
269
+ }
240
270
  function matchesAndroidNode(n, sel) {
241
271
  if (sel.query) {
242
- if (!matchesText(androidTextOf(n), sel.query) && !matchesText(n.resourceId, sel.query))
272
+ const textOk = matchPatternAgainstAnyTextField(sel.query, androidTextMatchFields(n));
273
+ const idOk = matchPatternAgainstElementId(sel.query, n.resourceId);
274
+ if (!textOk && !idOk)
243
275
  return false;
244
276
  }
245
277
  if (sel.text) {
246
- if (!matchesText(androidTextOf(n), sel.text))
278
+ if (!matchPatternAgainstAnyTextField(sel.text, androidTextMatchFields(n)))
247
279
  return false;
248
280
  }
249
281
  if (sel.id) {
250
- if (!matchesText(n.resourceId, sel.id))
282
+ if (!matchPatternAgainstElementId(sel.id, n.resourceId))
251
283
  return false;
252
284
  }
253
285
  if (sel.enabled !== undefined && n.enabled !== sel.enabled)
@@ -301,9 +333,7 @@ function findAndroidElement(xml, sel) {
301
333
  `bounds=[${x1},${y1}][${x2},${y2}]` +
302
334
  `${n.clickable ? ' (clickable)' : ''}`);
303
335
  });
304
- // Mirror Maestro's clickableFirst(): when no index is specified, prefer
305
- // clickable nodes so a text label shared by a Button and a plain TextView
306
- // resolves to the button, matching Maestro's GraalVM behaviour.
336
+ // When no index is specified, prefer clickable nodes so shared labels resolve to controls.
307
337
  if (sel.index === undefined) {
308
338
  matches = [...matches].sort((a, b) => Number(b.clickable) - Number(a.clickable));
309
339
  }
@@ -379,3 +409,184 @@ function inspectAndroidToText(xml) {
379
409
  })
380
410
  .join('\n');
381
411
  }
412
+ // ── Web: ARIA snapshot tree traversal ───────────────────────────────────────
413
+ // ARIA roles that represent interactive controls — mirrors IOS_INTERACTIVE_TYPES
414
+ const WEB_INTERACTIVE_ROLES = new Set([
415
+ 'button',
416
+ 'link',
417
+ 'textbox',
418
+ 'searchbox',
419
+ 'checkbox',
420
+ 'radio',
421
+ 'switch',
422
+ 'slider',
423
+ 'spinbutton',
424
+ 'combobox',
425
+ 'listbox',
426
+ 'option',
427
+ 'menuitem',
428
+ 'menuitemcheckbox',
429
+ 'menuitemradio',
430
+ 'tab',
431
+ ]);
432
+ /** Collect all visible (has bounds) leaf/interactive elements from a WebElement tree. */
433
+ function collectWebElements(nodes, results) {
434
+ for (const node of nodes) {
435
+ const visible = node.bounds && node.bounds.width > 0 && node.bounds.height > 0;
436
+ if (visible) {
437
+ const hasContent = !!(node.name || node.ref);
438
+ const isLeaf = !node.children || node.children.length === 0;
439
+ if (hasContent || isLeaf) {
440
+ results.push(node);
441
+ }
442
+ }
443
+ if (node.children) {
444
+ collectWebElements(node.children, results);
445
+ }
446
+ }
447
+ }
448
+ function matchesWebElement(node, sel) {
449
+ if (sel.query) {
450
+ const textOk = matchPatternAgainstAnyTextField(sel.query, [node.name]);
451
+ const idOk = matchPatternAgainstElementId(sel.query, node.ref);
452
+ if (!textOk && !idOk)
453
+ return false;
454
+ }
455
+ if (sel.text) {
456
+ if (!matchPatternAgainstAnyTextField(sel.text, [node.name]))
457
+ return false;
458
+ }
459
+ if (sel.id) {
460
+ if (!matchPatternAgainstElementId(sel.id, node.ref))
461
+ return false;
462
+ }
463
+ if (sel.enabled !== undefined && node.enabled !== sel.enabled)
464
+ return false;
465
+ if (sel.checked !== undefined && node.checked !== sel.checked)
466
+ return false;
467
+ if (sel.focused !== undefined && node.focused !== sel.focused)
468
+ return false;
469
+ if (sel.selected !== undefined && node.selected !== sel.selected)
470
+ return false;
471
+ return true;
472
+ }
473
+ /**
474
+ * Deepest matching elements — same logic as iOS to avoid parent wrapper duplicates.
475
+ */
476
+ function deepestMatchingWebElements(nodes, pred) {
477
+ const results = [];
478
+ for (const node of nodes) {
479
+ const childMatches = node.children ? deepestMatchingWebElements(node.children, pred) : [];
480
+ if (childMatches.length > 0) {
481
+ results.push(...childMatches);
482
+ }
483
+ else if (node.bounds && node.bounds.width > 0 && node.bounds.height > 0 && pred(node)) {
484
+ results.push(node);
485
+ }
486
+ }
487
+ return results;
488
+ }
489
+ function findWebElement(hierarchy, sel) {
490
+ // Resolve reference frame for relative-position selectors
491
+ let refBounds = null;
492
+ const relSel = sel.below ?? sel.above ?? sel.leftOf ?? sel.rightOf;
493
+ if (relSel) {
494
+ const allNodes = [];
495
+ collectWebElements(hierarchy.elements, allNodes);
496
+ const ref = allNodes.find((n) => matchesWebElement(n, relSel));
497
+ if (!ref?.bounds)
498
+ return null;
499
+ refBounds = ref.bounds;
500
+ }
501
+ // Find deepest matching nodes
502
+ let matches = deepestMatchingWebElements(hierarchy.elements, (n) => matchesWebElement(n, sel));
503
+ // Apply relative position filter
504
+ if (refBounds) {
505
+ const refBottom = refBounds.y + refBounds.height;
506
+ const refRight = refBounds.x + refBounds.width;
507
+ if (sel.below) {
508
+ matches = matches.filter((n) => n.bounds && n.bounds.y >= refBottom);
509
+ }
510
+ else if (sel.above) {
511
+ matches = matches.filter((n) => n.bounds && n.bounds.y + n.bounds.height <= refBounds.y);
512
+ }
513
+ else if (sel.leftOf) {
514
+ matches = matches.filter((n) => n.bounds && n.bounds.x + n.bounds.width <= refBounds.x);
515
+ }
516
+ else if (sel.rightOf) {
517
+ matches = matches.filter((n) => n.bounds && n.bounds.x >= refRight);
518
+ }
519
+ }
520
+ // Filter to elements that have bounding boxes (visible and measurable)
521
+ matches = matches.filter((n) => n.bounds && n.bounds.width > 0 && n.bounds.height > 0);
522
+ if (matches.length === 0) {
523
+ (0, verbose_js_1.log)(`[Web] no candidates matched selector`, sel);
524
+ return null;
525
+ }
526
+ (0, verbose_js_1.log)(`[Web] ${matches.length} candidate(s):`);
527
+ matches.forEach((n, i) => {
528
+ const b = n.bounds;
529
+ const interactive = WEB_INTERACTIVE_ROLES.has(n.role);
530
+ (0, verbose_js_1.log)(` [${i}] text="${n.name}" ref="${n.ref}" role="${n.role}" ` +
531
+ `bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}]` +
532
+ `${interactive ? ' (interactive)' : ''}`);
533
+ });
534
+ if (sel.index !== undefined) {
535
+ // Sort top-to-bottom, then left-to-right
536
+ matches = [...matches].sort((a, b) => {
537
+ const dy = a.bounds.y - b.bounds.y;
538
+ return dy !== 0 ? dy : a.bounds.x - b.bounds.x;
539
+ });
540
+ }
541
+ else {
542
+ // Prefer interactive roles
543
+ matches = [...matches].sort((a, b) => Number(WEB_INTERACTIVE_ROLES.has(b.role)) - Number(WEB_INTERACTIVE_ROLES.has(a.role)));
544
+ }
545
+ const idx = sel.index ?? 0;
546
+ const node = matches[idx < 0 ? matches.length + idx : idx];
547
+ if (!node) {
548
+ (0, verbose_js_1.log)(`[Web] index ${idx} out of range (${matches.length} candidates)`);
549
+ return null;
550
+ }
551
+ const b = node.bounds;
552
+ (0, verbose_js_1.log)(`[Web] chose [${idx}] text="${node.name}" ref="${node.ref}" role="${node.role}" ` +
553
+ `bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}] ` +
554
+ `→ tap (${Math.round(b.x + b.width / 2)}, ${Math.round(b.y + b.height / 2)})`);
555
+ return {
556
+ centerX: b.x + b.width / 2,
557
+ centerY: b.y + b.height / 2,
558
+ text: node.name || undefined,
559
+ id: node.ref || undefined,
560
+ };
561
+ }
562
+ /**
563
+ * Format web ARIA hierarchy into LLM-optimized text.
564
+ */
565
+ function inspectWebToText(hierarchy) {
566
+ const lines = [];
567
+ visitWeb(hierarchy.elements, lines, 0);
568
+ return lines.join('\n');
569
+ }
570
+ function visitWeb(nodes, lines, depth) {
571
+ for (const node of nodes) {
572
+ const parts = [];
573
+ parts.push(node.role);
574
+ if (node.name)
575
+ parts.push(`"${node.name}"`);
576
+ if (node.ref)
577
+ parts.push(`ref=${node.ref}`);
578
+ if (node.bounds) {
579
+ const b = node.bounds;
580
+ parts.push(`bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}]`);
581
+ }
582
+ if (!node.enabled)
583
+ parts.push('disabled');
584
+ // Only output nodes that have content
585
+ if (node.name || node.ref || (node.bounds && (!node.children || node.children.length === 0))) {
586
+ lines.push(`${' '.repeat(depth)}${parts.join(' ')}`);
587
+ }
588
+ if (node.children) {
589
+ visitWeb(node.children, lines, depth + 1);
590
+ }
591
+ }
592
+ }