@houwert/conductor 0.4.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 (53) hide show
  1. package/README.md +13 -10
  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 +12 -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-app.js +51 -0
  15. package/dist/commands/install.js +103 -27
  16. package/dist/commands/launch-app.js +6 -0
  17. package/dist/commands/list-devices.js +39 -2
  18. package/dist/commands/logs.js +193 -0
  19. package/dist/commands/press-key.js +16 -0
  20. package/dist/commands/screenshot.js +1 -1
  21. package/dist/commands/scroll-until-visible.js +11 -0
  22. package/dist/commands/scroll.js +6 -0
  23. package/dist/commands/start-device.js +38 -4
  24. package/dist/commands/stop-app.js +4 -0
  25. package/dist/commands/swipe.js +22 -0
  26. package/dist/commands/tap.js +10 -3
  27. package/dist/commands/type.js +2 -2
  28. package/dist/commands/uninstall-app.js +4 -0
  29. package/dist/daemon/client.js +72 -9
  30. package/dist/daemon/log-collector.js +408 -0
  31. package/dist/daemon/server.js +110 -32
  32. package/dist/daemon/web-server.js +812 -0
  33. package/dist/device-picker.js +7 -2
  34. package/dist/drivers/bootstrap.js +124 -1
  35. package/dist/drivers/element-resolver.js +241 -30
  36. package/dist/drivers/flow-runner.js +63 -21
  37. package/dist/drivers/log-sources/android.js +156 -0
  38. package/dist/drivers/log-sources/daemon.js +112 -0
  39. package/dist/drivers/log-sources/ios.js +106 -0
  40. package/dist/drivers/log-sources/metro.js +252 -0
  41. package/dist/drivers/log-sources/types.js +13 -0
  42. package/dist/drivers/log-sources/web.js +96 -0
  43. package/dist/drivers/wait.js +57 -0
  44. package/dist/drivers/web.js +173 -0
  45. package/dist/index.js +69 -12
  46. package/dist/runner.js +32 -2
  47. package/drivers/ios/conductor-driver-ios.zip +0 -0
  48. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  49. package/drivers/tvos/conductor-driver-tvos.zip +0 -0
  50. package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
  51. package/package.json +5 -2
  52. package/skills/conductor/SKILL.md +83 -41
  53. package/skills/skills.yaml +1 -1
package/README.md CHANGED
@@ -24,10 +24,10 @@ Conductor gives Claude Code the ability to interact with iOS simulators and Andr
24
24
 
25
25
  ```bash
26
26
  conductor launch-app com.example.myapp
27
- conductor tap "Sign In"
28
- conductor type "user@example.com"
27
+ conductor tap-on "Sign In"
28
+ conductor input-text "user@example.com"
29
29
  conductor assert-visible "Dashboard"
30
- conductor screenshot --output /tmp/screen.png
30
+ conductor take-screenshot --output /tmp/screen.png
31
31
  ```
32
32
 
33
33
  One agent writes the feature. Another taps through the app. They talk. It works. 🤝
@@ -57,18 +57,21 @@ Claude learns every available command, how to coordinate across devices, and how
57
57
 
58
58
  | Command | What it does |
59
59
  |---|---|
60
- | `npm install -g @houwert/conductor` | Registers global plugin automatically (via postinstall) |
61
- | `conductor install` | Re-register or update the global plugin |
62
- | `conductor install --skills` | Copy skills into `.claude/skills/conductor/` in the current project |
63
- | `conductor install --check` | Print current install status without modifying anything |
60
+ | `npm install -g @houwert/conductor` | Registers or updates the global Claude Code plugin (via package postinstall) |
61
+ | `conductor install-plugin` | Re-register or update the global Claude Code plugin (same as postinstall) |
62
+ | `conductor install-plugin --check` | Print whether the global plugin is registered (no changes) |
63
+ | `conductor install-skills` | Copy skills into `.claude/skills/conductor/` in the current project |
64
+ | `conductor install-skills --check` | Print whether local skills are installed (no changes) |
65
+ | `conductor install-web` | Install a Playwright browser for web automation (default: chromium) |
66
+ | `conductor install-web --check` | Print which Playwright browsers are installed (no changes) |
64
67
 
65
68
  ### 📱 What Claude can do
66
69
 
67
70
  | Capability | Commands |
68
71
  |---|---|
69
- | App lifecycle | `launch-app`, `stop-app`, `foreground-app`, `copy-app` |
70
- | Interaction | `tap`, `type`, `scroll`, `scroll-until-visible`, `swipe`, `press-key`, `erase-text`, `hide-keyboard` |
71
- | Inspection | `inspect`, `focused`, `screenshot`, `list-apps` |
72
+ | App lifecycle | `launch-app`, `stop-app`, `clear-state`, `uninstall-app`, `install-app`, `foreground-app`, `copy-app` |
73
+ | Interaction | `tap-on`, `input-text`, `scroll`, `scroll-until-visible`, `swipe`, `press-key`, `erase-text`, `hide-keyboard` |
74
+ | Inspection | `inspect`, `focused`, `take-screenshot`, `list-apps` |
72
75
  | Assertions | `assert-visible`, `assert-not-visible` |
73
76
  | Navigation | `open-link`, `back` |
74
77
  | Flows | `run-flow`, `run-flow-inline`, `run-parallel` |
@@ -10,6 +10,7 @@ const runner_js_1 = require("../runner.js");
10
10
  const output_js_1 = require("../output.js");
11
11
  const ios_js_1 = require("../drivers/ios.js");
12
12
  const android_js_1 = require("../drivers/android.js");
13
+ const web_js_1 = require("../drivers/web.js");
13
14
  const wait_js_1 = require("../drivers/wait.js");
14
15
  async function assertNotVisible(element, opts = {}, sessionName = 'default', flags = {}) {
15
16
  if (!element && !flags.id && !flags.text) {
@@ -38,6 +39,9 @@ async function assertNotVisible(element, opts = {}, sessionName = 'default', fla
38
39
  if (driver instanceof ios_js_1.IOSDriver) {
39
40
  await (0, wait_js_1.waitUntilIOSElementGone)(() => driver.viewHierarchy().then((h) => h.axElement), sel, flags.timeout);
40
41
  }
42
+ else if (driver instanceof web_js_1.WebDriver) {
43
+ await (0, wait_js_1.waitUntilWebElementGone)(() => driver.viewHierarchy(), sel, flags.timeout);
44
+ }
41
45
  else if (driver instanceof android_js_1.AndroidDriver) {
42
46
  await (0, wait_js_1.waitUntilAndroidElementGone)(() => driver.viewHierarchy(), sel, flags.timeout);
43
47
  }
@@ -20,6 +20,7 @@ const runner_js_1 = require("../runner.js");
20
20
  const output_js_1 = require("../output.js");
21
21
  const ios_js_1 = require("../drivers/ios.js");
22
22
  const android_js_1 = require("../drivers/android.js");
23
+ const web_js_1 = require("../drivers/web.js");
23
24
  const wait_js_1 = require("../drivers/wait.js");
24
25
  async function assertVisible(element, opts = {}, sessionName = 'default', flags = {}) {
25
26
  if (!element && !flags.id && !flags.text) {
@@ -50,6 +51,9 @@ async function assertVisible(element, opts = {}, sessionName = 'default', flags
50
51
  if (driver instanceof ios_js_1.IOSDriver) {
51
52
  return (0, wait_js_1.waitForIOSElement)(() => driver.viewHierarchy().then((h) => h.axElement), sel, timeoutMs);
52
53
  }
54
+ else if (driver instanceof web_js_1.WebDriver) {
55
+ return (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), sel, timeoutMs);
56
+ }
53
57
  else if (driver instanceof android_js_1.AndroidDriver) {
54
58
  return (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel, timeoutMs);
55
59
  }
@@ -7,6 +7,7 @@ const runner_js_1 = require("../runner.js");
7
7
  const output_js_1 = require("../output.js");
8
8
  const ios_js_1 = require("../drivers/ios.js");
9
9
  const android_js_1 = require("../drivers/android.js");
10
+ const web_js_1 = require("../drivers/web.js");
10
11
  async function back(opts = {}, sessionName = 'default') {
11
12
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
12
13
  if (driver instanceof ios_js_1.IOSDriver) {
@@ -15,6 +16,9 @@ async function back(opts = {}, sessionName = 'default') {
15
16
  }
16
17
  // Plain iOS has no universal "back" concept — this is a no-op (same as maestro IOSDriver.backPress)
17
18
  }
19
+ else if (driver instanceof web_js_1.WebDriver) {
20
+ await driver.goBack();
21
+ }
18
22
  else if (driver instanceof android_js_1.AndroidDriver) {
19
23
  await driver.back(); // adb shell input keyevent 4
20
24
  }
@@ -34,6 +34,8 @@ DEVICE MANAGEMENT
34
34
  session --list List all device sessions
35
35
 
36
36
  APP CONTROL
37
+ download-app <appId> [--output <path>] Download installed app binary from device
38
+ install-app <path> Install .app / .ipa / .apk onto device
37
39
  launch-app <appId> [--device <id>] Launch app and save to session
38
40
  --clear-state Wipe app data/state before launching
39
41
  --clear-keychain Wipe keychain before launching
@@ -43,12 +45,12 @@ APP CONTROL
43
45
  uninstall-app <appId> Uninstall app from device
44
46
 
45
47
  INTERACTIONS
46
- tap <element> Tap element by text
48
+ tap-on <element> Tap element by text
47
49
  --id <id> Match by accessibility ID instead of text
48
50
  --index <n> Pick the nth match (0-based)
49
51
  --long-press Hold instead of tap
50
52
  --double-tap Double-tap the element
51
- type <text> Type text into focused field
53
+ input-text <text> Type text into focused field
52
54
  back Press back button (Android only)
53
55
  press-key <key> Press a key (Enter, Backspace, Home, ...)
54
56
  scroll [--direction down|up|left|right] Scroll (default: down)
@@ -64,7 +66,7 @@ ASSERTIONS
64
66
  --optional Succeed even if element is not found
65
67
 
66
68
  SCREENSHOTS & INSPECTION
67
- screenshot [--output <path>] Take screenshot (default: ./screenshot-<ts>.png)
69
+ take-screenshot [--output <path>] Take screenshot (default: ./screenshot-<ts>.png)
68
70
  inspect Print UI hierarchy
69
71
 
70
72
  FLOW EXECUTION
@@ -83,7 +85,9 @@ MULTI-AGENT / PARALLEL
83
85
  run-parallel --flows-dir <path> Run flows in parallel across all devices
84
86
 
85
87
  MISC
86
- install --skills Install skill files into .claude/skills/
88
+ install-plugin Register/update the global Claude Code plugin
89
+ install-skills Install skill files into .claude/skills/
90
+ install-web [browser] Install Playwright browser for web automation
87
91
  cheat-sheet Print this reference
88
92
 
89
93
  GLOBAL FLAGS
@@ -94,11 +98,11 @@ GLOBAL FLAGS
94
98
 
95
99
  EXAMPLES
96
100
  conductor launch-app com.example.app
97
- conductor tap "Sign In"
98
- conductor tap --id "btn_login"
99
- conductor type "hello@example.com"
101
+ conductor tap-on "Sign In"
102
+ conductor tap-on --id "btn_login"
103
+ conductor input-text "hello@example.com"
100
104
  conductor swipe --start 0.5,0.8 --end 0.5,0.2
101
105
  conductor assert-visible "Dashboard" --timeout 30000
102
- conductor screenshot --output /tmp/screen.png
106
+ conductor take-screenshot --output /tmp/screen.png
103
107
  conductor run-flow ./flows/login.yaml
104
108
  `.trim();
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.deleteDevice = deleteDevice;
5
+ exports.HELP = ` delete-device <name-or-id>
6
+ --platform <ios|tvos|android> Scope to a single platform
7
+ --all Delete all shutdown simulators / non-running AVDs`;
8
+ const runner_js_1 = require("../runner.js");
9
+ const output_js_1 = require("../output.js");
10
+ async function listSimulators() {
11
+ const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'list', 'devices', '--json']);
12
+ if (!result.success)
13
+ throw new Error(`xcrun simctl list failed: ${result.stderr}`);
14
+ const parsed = JSON.parse(result.stdout);
15
+ const flat = [];
16
+ for (const [runtime, sims] of Object.entries(parsed.devices)) {
17
+ for (const sim of sims) {
18
+ if (sim.isAvailable)
19
+ flat.push({ runtime, device: sim });
20
+ }
21
+ }
22
+ return flat;
23
+ }
24
+ function simPlatform(runtime) {
25
+ return runtime.includes('tvOS') ? 'tvos' : 'ios';
26
+ }
27
+ async function shutdownSimulator(udid) {
28
+ const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'shutdown', udid]);
29
+ if (!result.success && !result.stderr.includes('current state: Shutdown')) {
30
+ throw new Error(`Failed to shutdown simulator: ${result.stderr.trim()}`);
31
+ }
32
+ }
33
+ async function deleteSimulator(udid) {
34
+ const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'delete', udid]);
35
+ if (!result.success) {
36
+ throw new Error(`Failed to delete simulator: ${result.stderr.trim()}`);
37
+ }
38
+ }
39
+ // ── Android ──────────────────────────────────────────────────────────────────
40
+ async function listAVDs() {
41
+ const result = await (0, runner_js_1.spawnCommand)('emulator', ['-list-avds']);
42
+ if (!result.success)
43
+ return [];
44
+ return result.stdout
45
+ .split('\n')
46
+ .map((l) => l.trim())
47
+ .filter(Boolean);
48
+ }
49
+ /** Map running emulator serial → AVD name */
50
+ async function runningAVDs() {
51
+ const map = new Map();
52
+ const result = await (0, runner_js_1.spawnCommand)('adb', ['devices']);
53
+ if (!result.success)
54
+ return map;
55
+ const serials = result.stdout
56
+ .split('\n')
57
+ .slice(1)
58
+ .map((l) => l.trim().split(/\s+/)[0])
59
+ .filter((s) => s && s.startsWith('emulator-'));
60
+ for (const serial of serials) {
61
+ const name = await (0, runner_js_1.spawnCommand)('adb', ['-s', serial, 'emu', 'avd', 'name']);
62
+ if (name.success) {
63
+ const avdName = name.stdout.trim().split('\n')[0];
64
+ if (avdName)
65
+ map.set(avdName, serial);
66
+ }
67
+ }
68
+ return map;
69
+ }
70
+ async function killEmulator(serial) {
71
+ const result = await (0, runner_js_1.spawnCommand)('adb', ['-s', serial, 'emu', 'kill']);
72
+ if (!result.success) {
73
+ throw new Error(`Failed to kill emulator ${serial}: ${result.stderr.trim()}`);
74
+ }
75
+ }
76
+ async function deleteAVD(name) {
77
+ const result = await (0, runner_js_1.spawnCommand)('avdmanager', ['delete', 'avd', '-n', name]);
78
+ if (!result.success) {
79
+ throw new Error(`Failed to delete AVD "${name}": ${result.stderr.trim()}`);
80
+ }
81
+ }
82
+ // ── Entry point ──────────────────────────────────────────────────────────────
83
+ async function deleteDevice(nameOrId, opts, flags) {
84
+ if (!nameOrId && !flags.all) {
85
+ (0, output_js_1.printError)('delete-device requires a device name/ID, or --all', opts);
86
+ return 1;
87
+ }
88
+ const platform = flags.platform?.toLowerCase();
89
+ const includeIOS = !platform || platform === 'ios';
90
+ const includeTvOS = !platform || platform === 'tvos';
91
+ const includeAndroid = !platform || platform === 'android';
92
+ const deleted = [];
93
+ // ── --all mode ───────────────────────────────────────────────────────────
94
+ if (flags.all) {
95
+ // iOS / tvOS: delete all non-booted simulators
96
+ if (includeIOS || includeTvOS) {
97
+ let sims;
98
+ try {
99
+ sims = await listSimulators();
100
+ }
101
+ catch (e) {
102
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
103
+ return 1;
104
+ }
105
+ for (const { runtime, device } of sims) {
106
+ const p = simPlatform(runtime);
107
+ if (p === 'ios' && !includeIOS)
108
+ continue;
109
+ if (p === 'tvos' && !includeTvOS)
110
+ continue;
111
+ if (device.state === 'Booted')
112
+ continue;
113
+ try {
114
+ await deleteSimulator(device.udid);
115
+ deleted.push({ id: device.udid, name: device.name, platform: p });
116
+ }
117
+ catch (e) {
118
+ (0, output_js_1.printError)(`Failed to delete ${device.name} (${device.udid}): ${e instanceof Error ? e.message : String(e)}`, opts);
119
+ }
120
+ }
121
+ }
122
+ // Android: delete all non-running AVDs
123
+ if (includeAndroid) {
124
+ const avds = await listAVDs();
125
+ const running = await runningAVDs();
126
+ for (const avd of avds) {
127
+ if (running.has(avd))
128
+ continue;
129
+ try {
130
+ await deleteAVD(avd);
131
+ deleted.push({ id: avd, name: avd, platform: 'android' });
132
+ }
133
+ catch (e) {
134
+ (0, output_js_1.printError)(`Failed to delete AVD "${avd}": ${e instanceof Error ? e.message : String(e)}`, opts);
135
+ }
136
+ }
137
+ }
138
+ if (deleted.length === 0) {
139
+ (0, output_js_1.printError)('No devices to delete.', opts);
140
+ return 1;
141
+ }
142
+ if (opts.json) {
143
+ (0, output_js_1.printData)({ status: 'ok', deleted }, opts);
144
+ }
145
+ else {
146
+ for (const d of deleted) {
147
+ (0, output_js_1.printSuccess)(`Deleted ${d.platform} device: ${d.name} (${d.id})`, opts);
148
+ }
149
+ }
150
+ return 0;
151
+ }
152
+ // ── Single device mode ─────────────────────────────────────────────────
153
+ // Try iOS / tvOS first
154
+ if (includeIOS || includeTvOS) {
155
+ let sims;
156
+ try {
157
+ sims = await listSimulators();
158
+ }
159
+ catch (e) {
160
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
161
+ return 1;
162
+ }
163
+ const match = sims.find(({ runtime, device }) => {
164
+ const p = simPlatform(runtime);
165
+ if (p === 'ios' && !includeIOS)
166
+ return false;
167
+ if (p === 'tvos' && !includeTvOS)
168
+ return false;
169
+ return device.udid === nameOrId || device.name === nameOrId;
170
+ });
171
+ if (match) {
172
+ const { runtime, device } = match;
173
+ try {
174
+ if (device.state === 'Booted') {
175
+ console.log(`Shutting down ${device.name}...`);
176
+ await shutdownSimulator(device.udid);
177
+ }
178
+ await deleteSimulator(device.udid);
179
+ }
180
+ catch (e) {
181
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
182
+ return 1;
183
+ }
184
+ const p = simPlatform(runtime);
185
+ if (opts.json) {
186
+ (0, output_js_1.printData)({ status: 'ok', deleted: [{ id: device.udid, name: device.name, platform: p }] }, opts);
187
+ }
188
+ else {
189
+ (0, output_js_1.printSuccess)(`Deleted ${p} device: ${device.name} (${device.udid})`, opts);
190
+ }
191
+ return 0;
192
+ }
193
+ }
194
+ // Try Android
195
+ if (includeAndroid) {
196
+ const avds = await listAVDs();
197
+ if (avds.includes(nameOrId)) {
198
+ const running = await runningAVDs();
199
+ try {
200
+ const serial = running.get(nameOrId);
201
+ if (serial) {
202
+ console.log(`Killing emulator ${serial}...`);
203
+ await killEmulator(serial);
204
+ }
205
+ await deleteAVD(nameOrId);
206
+ }
207
+ catch (e) {
208
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
209
+ return 1;
210
+ }
211
+ if (opts.json) {
212
+ (0, output_js_1.printData)({ status: 'ok', deleted: [{ id: nameOrId, name: nameOrId, platform: 'android' }] }, opts);
213
+ }
214
+ else {
215
+ (0, output_js_1.printSuccess)(`Deleted android AVD: ${nameOrId}`, opts);
216
+ }
217
+ return 0;
218
+ }
219
+ }
220
+ (0, output_js_1.printError)(`Device "${nameOrId}" not found.`, opts);
221
+ return 1;
222
+ }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.HELP = void 0;
6
+ exports._testDeviceOverride = exports.HELP = void 0;
7
7
  exports.devicePool = devicePool;
8
8
  exports.HELP = ` device-pool --list List all devices and pool status
9
9
  device-pool --acquire Claim a free device (prints device ID)
@@ -23,22 +23,24 @@ const path_1 = __importDefault(require("path"));
23
23
  const fs_1 = __importDefault(require("fs"));
24
24
  const child_process_1 = require("child_process");
25
25
  const output_js_1 = require("../output.js");
26
- const POOL_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'device-pool.json');
27
- const LOCK_FILE = POOL_FILE + '.lock';
26
+ function poolFilePath() {
27
+ return (process.env.__CONDUCTOR_POOL_FILE ?? path_1.default.join(os_1.default.homedir(), '.conductor', 'device-pool.json'));
28
+ }
28
29
  const LOCK_TIMEOUT_MS = 5000;
29
30
  // ── File locking ──────────────────────────────────────────────────────────────
30
31
  async function withLock(fn) {
32
+ const lockFile = poolFilePath() + '.lock';
31
33
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
32
34
  while (Date.now() < deadline) {
33
35
  try {
34
- const fd = fs_1.default.openSync(LOCK_FILE, 'wx'); // exclusive create
36
+ const fd = fs_1.default.openSync(lockFile, 'wx'); // exclusive create
35
37
  fs_1.default.closeSync(fd);
36
38
  try {
37
39
  return await Promise.resolve(fn());
38
40
  }
39
41
  finally {
40
42
  try {
41
- fs_1.default.unlinkSync(LOCK_FILE);
43
+ fs_1.default.unlinkSync(lockFile);
42
44
  }
43
45
  catch {
44
46
  /* ok */
@@ -53,7 +55,7 @@ async function withLock(fn) {
53
55
  }
54
56
  function readPool() {
55
57
  try {
56
- const raw = fs_1.default.readFileSync(POOL_FILE, 'utf-8');
58
+ const raw = fs_1.default.readFileSync(poolFilePath(), 'utf-8');
57
59
  return JSON.parse(raw);
58
60
  }
59
61
  catch {
@@ -61,11 +63,13 @@ function readPool() {
61
63
  }
62
64
  }
63
65
  function writePool(state) {
64
- fs_1.default.mkdirSync(path_1.default.dirname(POOL_FILE), { recursive: true });
65
- fs_1.default.writeFileSync(POOL_FILE, JSON.stringify(state, null, 2));
66
+ const p = poolFilePath();
67
+ fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
68
+ fs_1.default.writeFileSync(p, JSON.stringify(state, null, 2));
66
69
  }
67
- // ── Device discovery ──────────────────────────────────────────────────────────
68
70
  async function discoverAllDevices() {
71
+ if (exports._testDeviceOverride)
72
+ return exports._testDeviceOverride;
69
73
  const devices = [];
70
74
  // Android: adb devices
71
75
  try {
@@ -96,11 +100,29 @@ async function discoverAllDevices() {
96
100
  }
97
101
  return devices;
98
102
  }
103
+ function pruneStaleAcquisitions(state) {
104
+ for (const entry of state.devices) {
105
+ if (entry.acquiredBy) {
106
+ try {
107
+ process.kill(parseInt(entry.acquiredBy, 10), 0);
108
+ }
109
+ catch {
110
+ delete entry.acquiredBy;
111
+ delete entry.acquiredAt;
112
+ }
113
+ }
114
+ }
115
+ }
99
116
  // ── Commands ──────────────────────────────────────────────────────────────────
100
117
  async function devicePool(action, releaseId, opts = {}) {
101
118
  if (action === 'list') {
102
119
  const allDevices = await discoverAllDevices();
103
- const pool = readPool();
120
+ const pool = await withLock(() => {
121
+ const state = readPool();
122
+ pruneStaleAcquisitions(state);
123
+ writePool(state);
124
+ return state;
125
+ });
104
126
  const rows = allDevices.map((id) => {
105
127
  const entry = pool.devices.find((e) => e.deviceId === id);
106
128
  const status = entry?.acquiredBy ? `acquired by PID ${entry.acquiredBy}` : 'free';
@@ -131,18 +153,7 @@ async function devicePool(action, releaseId, opts = {}) {
131
153
  }
132
154
  const result = await withLock(() => {
133
155
  const state = readPool();
134
- // Prune stale acquisitions (process no longer running)
135
- for (const entry of state.devices) {
136
- if (entry.acquiredBy) {
137
- try {
138
- process.kill(parseInt(entry.acquiredBy, 10), 0);
139
- }
140
- catch {
141
- delete entry.acquiredBy;
142
- delete entry.acquiredAt;
143
- }
144
- }
145
- }
156
+ pruneStaleAcquisitions(state);
146
157
  // Ensure all discovered devices are in the pool
147
158
  for (const id of allDevices) {
148
159
  if (!state.devices.find((e) => e.deviceId === id)) {
@@ -0,0 +1,87 @@
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.HELP = void 0;
7
+ exports.downloadApp = downloadApp;
8
+ exports.HELP = ` download-app <appId> --output <path> Download installed app binary from device`;
9
+ const path_1 = __importDefault(require("path"));
10
+ const runner_js_1 = require("../runner.js");
11
+ const session_js_1 = require("../session.js");
12
+ const output_js_1 = require("../output.js");
13
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
14
+ async function resolveDeviceId(sessionName) {
15
+ if (sessionName !== 'default')
16
+ return sessionName;
17
+ const session = await (0, session_js_1.getSession)(sessionName);
18
+ return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
19
+ }
20
+ async function downloadApp(appId, output, opts = {}, sessionName = 'default') {
21
+ if (!appId) {
22
+ (0, output_js_1.printError)('download-app requires <appId>', opts);
23
+ return 1;
24
+ }
25
+ const deviceId = await resolveDeviceId(sessionName);
26
+ if (!deviceId) {
27
+ (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
28
+ return 1;
29
+ }
30
+ const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
31
+ if (platform === 'ios' || platform === 'tvos') {
32
+ // Get the .app bundle path from the simulator
33
+ const getPath = await (0, runner_js_1.spawnCommand)('xcrun', [
34
+ 'simctl',
35
+ 'get_app_container',
36
+ deviceId,
37
+ appId,
38
+ 'app',
39
+ ]);
40
+ if (!getPath.success) {
41
+ (0, output_js_1.printError)(`Failed to locate app on device: ${getPath.stderr}`, opts);
42
+ return 1;
43
+ }
44
+ const appPath = getPath.stdout.trim();
45
+ const appName = path_1.default.basename(appPath);
46
+ const dest = output ?? path_1.default.join(process.cwd(), appName);
47
+ const copy = await (0, runner_js_1.spawnCommand)('cp', ['-R', appPath, dest]);
48
+ if (!copy.success) {
49
+ (0, output_js_1.printError)(`Failed to copy app bundle: ${copy.stderr}`, opts);
50
+ return 1;
51
+ }
52
+ if (opts.json) {
53
+ (0, output_js_1.printData)({ status: 'ok', appId, path: dest }, opts);
54
+ }
55
+ else {
56
+ (0, output_js_1.printSuccess)(`download-app "${appId}" → ${dest}`, opts);
57
+ }
58
+ return 0;
59
+ }
60
+ else {
61
+ // Android: find the APK path, then pull it
62
+ const pmPath = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'pm', 'path', appId]);
63
+ if (!pmPath.success) {
64
+ (0, output_js_1.printError)(`Failed to locate app on device: ${pmPath.stderr}`, opts);
65
+ return 1;
66
+ }
67
+ // pm path output: "package:/data/app/.../base.apk"
68
+ const apkPath = pmPath.stdout.trim().replace(/^package:/, '');
69
+ if (!apkPath) {
70
+ (0, output_js_1.printError)(`Could not resolve APK path for "${appId}"`, opts);
71
+ return 1;
72
+ }
73
+ const dest = output ?? path_1.default.join(process.cwd(), `${appId}.apk`);
74
+ const pull = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'pull', apkPath, dest]);
75
+ if (!pull.success) {
76
+ (0, output_js_1.printError)(`Failed to pull APK: ${pull.stderr}`, opts);
77
+ return 1;
78
+ }
79
+ if (opts.json) {
80
+ (0, output_js_1.printData)({ status: 'ok', appId, path: dest }, opts);
81
+ }
82
+ else {
83
+ (0, output_js_1.printSuccess)(`download-app "${appId}" → ${dest}`, opts);
84
+ }
85
+ return 0;
86
+ }
87
+ }
@@ -7,11 +7,15 @@ const runner_js_1 = require("../runner.js");
7
7
  const output_js_1 = require("../output.js");
8
8
  const ios_js_1 = require("../drivers/ios.js");
9
9
  const android_js_1 = require("../drivers/android.js");
10
+ const web_js_1 = require("../drivers/web.js");
10
11
  async function eraseText(characters, opts = {}, sessionName = 'default') {
11
12
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
12
13
  if (driver instanceof android_js_1.AndroidDriver) {
13
14
  await driver.eraseAllText(characters);
14
15
  }
16
+ else if (driver instanceof web_js_1.WebDriver) {
17
+ await driver.eraseAllText(characters);
18
+ }
15
19
  else if (driver instanceof ios_js_1.IOSDriver) {
16
20
  for (let i = 0; i < characters; i++)
17
21
  await driver.pressKey('delete');
@@ -8,6 +8,7 @@ const runner_js_1 = require("../runner.js");
8
8
  const output_js_1 = require("../output.js");
9
9
  const ios_js_1 = require("../drivers/ios.js");
10
10
  const android_js_1 = require("../drivers/android.js");
11
+ const web_js_1 = require("../drivers/web.js");
11
12
  const element_resolver_js_1 = require("../drivers/element-resolver.js");
12
13
  // XCUIElementType rawValue → human-readable name.
13
14
  // Source: XCUIElementType enum in XCTest framework.
@@ -161,12 +162,50 @@ function formatAndroidNode(node) {
161
162
  },
162
163
  };
163
164
  }
165
+ function findFocusedWeb(elements) {
166
+ for (const el of elements) {
167
+ if (el.children) {
168
+ const found = findFocusedWeb(el.children);
169
+ if (found)
170
+ return found;
171
+ }
172
+ if (el.focused)
173
+ return el;
174
+ }
175
+ return null;
176
+ }
177
+ function formatWebElement(node) {
178
+ const b = node.bounds;
179
+ return {
180
+ text: node.name || '',
181
+ ref: node.ref || '',
182
+ role: node.role,
183
+ enabled: node.enabled,
184
+ focused: node.focused,
185
+ checked: node.checked ?? null,
186
+ selected: node.selected ?? null,
187
+ bounds: b
188
+ ? {
189
+ x: Math.round(b.x),
190
+ y: Math.round(b.y),
191
+ width: Math.round(b.width),
192
+ height: Math.round(b.height),
193
+ }
194
+ : null,
195
+ center: b ? { x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) } : null,
196
+ };
197
+ }
164
198
  async function queryFocused(driver) {
165
199
  if (driver instanceof ios_js_1.IOSDriver) {
166
200
  const hierarchy = await driver.viewHierarchy(false);
167
201
  const node = findFocusedIOS(hierarchy.axElement);
168
202
  return node ? formatIOSElement(node) : null;
169
203
  }
204
+ else if (driver instanceof web_js_1.WebDriver) {
205
+ const hierarchy = await driver.viewHierarchy();
206
+ const node = findFocusedWeb(hierarchy.elements);
207
+ return node ? formatWebElement(node) : null;
208
+ }
170
209
  else if (driver instanceof android_js_1.AndroidDriver) {
171
210
  const xml = await driver.viewHierarchy();
172
211
  const nodes = (0, element_resolver_js_1.parseAndroidHierarchy)(xml);