@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.
- package/README.md +12 -9
- package/dist/commands/assert-not-visible.js +4 -0
- package/dist/commands/assert-visible.js +4 -0
- package/dist/commands/back.js +4 -0
- package/dist/commands/cheat-sheet.js +11 -8
- package/dist/commands/delete-device.js +222 -0
- package/dist/commands/device-pool.js +33 -22
- package/dist/commands/download-app.js +87 -0
- package/dist/commands/erase-text.js +4 -0
- package/dist/commands/focused.js +39 -0
- package/dist/commands/foreground-app.js +4 -0
- package/dist/commands/hide-keyboard.js +4 -0
- package/dist/commands/inspect.js +8 -0
- package/dist/commands/install.js +103 -27
- package/dist/commands/launch-app.js +6 -0
- package/dist/commands/list-devices.js +39 -2
- package/dist/commands/logs.js +193 -0
- package/dist/commands/press-key.js +16 -0
- package/dist/commands/screenshot.js +1 -1
- package/dist/commands/scroll-until-visible.js +11 -0
- package/dist/commands/scroll.js +6 -0
- package/dist/commands/start-device.js +38 -4
- package/dist/commands/stop-app.js +4 -0
- package/dist/commands/swipe.js +22 -0
- package/dist/commands/tap.js +10 -3
- package/dist/commands/type.js +2 -2
- package/dist/commands/uninstall-app.js +4 -0
- package/dist/daemon/client.js +72 -9
- package/dist/daemon/log-collector.js +408 -0
- package/dist/daemon/server.js +110 -32
- package/dist/daemon/web-server.js +812 -0
- package/dist/device-picker.js +7 -2
- package/dist/drivers/bootstrap.js +124 -1
- package/dist/drivers/element-resolver.js +241 -30
- package/dist/drivers/flow-runner.js +63 -21
- package/dist/drivers/log-sources/android.js +156 -0
- package/dist/drivers/log-sources/daemon.js +112 -0
- package/dist/drivers/log-sources/ios.js +106 -0
- package/dist/drivers/log-sources/metro.js +252 -0
- package/dist/drivers/log-sources/types.js +13 -0
- package/dist/drivers/log-sources/web.js +96 -0
- package/dist/drivers/wait.js +57 -0
- package/dist/drivers/web.js +173 -0
- package/dist/index.js +62 -12
- package/dist/runner.js +32 -2
- package/drivers/ios/conductor-driver-ios.zip +0 -0
- package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
- package/drivers/tvos/conductor-driver-tvos.zip +0 -0
- package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
- package/package.json +5 -2
- package/skills/conductor/SKILL.md +72 -41
- package/skills/skills.yaml +1 -1
package/dist/device-picker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
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
|
|
71
|
-
|
|
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 (!
|
|
104
|
+
if (!matchPatternAgainstAnyTextField(sel.text, iosTextMatchFields(node)))
|
|
76
105
|
return false;
|
|
77
106
|
}
|
|
78
107
|
if (sel.id) {
|
|
79
|
-
if (!
|
|
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
|
-
*
|
|
95
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
278
|
+
if (!matchPatternAgainstAnyTextField(sel.text, androidTextMatchFields(n)))
|
|
247
279
|
return false;
|
|
248
280
|
}
|
|
249
281
|
if (sel.id) {
|
|
250
|
-
if (!
|
|
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
|
-
//
|
|
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
|
+
}
|