@houwert/conductor 0.2.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 (60) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/README.md +39 -0
  3. package/dist/commands/assert-not-visible.js +47 -0
  4. package/dist/commands/assert-visible.js +58 -0
  5. package/dist/commands/back.js +25 -0
  6. package/dist/commands/cheat-sheet.js +100 -0
  7. package/dist/commands/daemon.js +61 -0
  8. package/dist/commands/device-pool.js +202 -0
  9. package/dist/commands/erase-text.js +26 -0
  10. package/dist/commands/foreground-app.js +50 -0
  11. package/dist/commands/hide-keyboard.js +27 -0
  12. package/dist/commands/inspect.js +37 -0
  13. package/dist/commands/install.js +64 -0
  14. package/dist/commands/launch-app.js +42 -0
  15. package/dist/commands/list-apps.js +60 -0
  16. package/dist/commands/list-devices.js +61 -0
  17. package/dist/commands/open-link.js +22 -0
  18. package/dist/commands/press-key.js +91 -0
  19. package/dist/commands/run-flow-inline.js +25 -0
  20. package/dist/commands/run-flow.js +29 -0
  21. package/dist/commands/run-parallel.js +143 -0
  22. package/dist/commands/screenshot.js +29 -0
  23. package/dist/commands/scroll-until-visible.js +69 -0
  24. package/dist/commands/scroll.js +36 -0
  25. package/dist/commands/session.js +49 -0
  26. package/dist/commands/set-location.js +18 -0
  27. package/dist/commands/set-orientation.js +23 -0
  28. package/dist/commands/start-device.js +178 -0
  29. package/dist/commands/stop-app.js +32 -0
  30. package/dist/commands/swipe.js +72 -0
  31. package/dist/commands/tap.js +69 -0
  32. package/dist/commands/type.js +22 -0
  33. package/dist/daemon/client.js +112 -0
  34. package/dist/daemon/protocol.js +25 -0
  35. package/dist/daemon/server.js +208 -0
  36. package/dist/drivers/android.js +343 -0
  37. package/dist/drivers/bootstrap.js +371 -0
  38. package/dist/drivers/element-resolver.js +371 -0
  39. package/dist/drivers/flow-runner.js +1309 -0
  40. package/dist/drivers/ios.js +328 -0
  41. package/dist/drivers/js-engine.js +150 -0
  42. package/dist/drivers/wait.js +211 -0
  43. package/dist/index.js +426 -0
  44. package/dist/output.js +36 -0
  45. package/dist/pkg-root.js +28 -0
  46. package/dist/postinstall.js +12 -0
  47. package/dist/runner.js +190 -0
  48. package/dist/session.js +66 -0
  49. package/dist/update-check.js +109 -0
  50. package/dist/utils.js +19 -0
  51. package/dist/verbose.js +17 -0
  52. package/drivers/android/conductor-app.apk +0 -0
  53. package/drivers/android/conductor-server.apk +0 -0
  54. package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
  55. package/drivers/ios/conductor-driver-ios.zip +0 -0
  56. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  57. package/package.json +52 -0
  58. package/proto/conductor_android.proto +116 -0
  59. package/skills/conductor/SKILL.md +677 -0
  60. package/skills/conductor/references/flow-syntax.md +179 -0
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "conductor",
3
+ "description": "CLI for mobile UI testing, designed for AI agents",
4
+ "repository": "https://github.com/houwert/conductor",
5
+ "author": { "name": "Houwert" }
6
+ }
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # Conductor
2
+
3
+ A token-efficient CLI for mobile UI testing, designed for AI agents.
4
+
5
+ Inspired by [`@playwright/cli`](https://github.com/microsoft/playwright-cli), this is a TypeScript reimplementation and partial fork of [Maestro](https://github.com/mobile-dev-inc/maestro) that talks directly to the bundled native drivers — no external CLI installation required.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm install
11
+ pnpm build
12
+ pnpm link --global
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ conductor list-devices
19
+ conductor launch-app com.example.myapp
20
+ conductor tap "Sign In"
21
+ conductor type "user@example.com"
22
+ conductor screenshot --output /tmp/screen.png
23
+ ```
24
+
25
+ ## Documentation
26
+
27
+ See [`skills/conductor/SKILL.md`](./skills/conductor/SKILL.md) for full command reference.
28
+
29
+ ## Requirements
30
+
31
+ - Android: `adb` on `PATH` with a running emulator/device
32
+ - iOS: Xcode with a running simulator
33
+
34
+ ## Architecture
35
+
36
+ - `src/session.ts` — persists `appId` + `deviceId` in `~/.conductor/session.json`
37
+ - `src/runner.ts` — resolves devices, manages driver lifecycle, executes flows natively
38
+ - `src/commands/` — one file per command
39
+ - `src/index.ts` — argument parsing with `minimist`, command dispatch
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertNotVisible = assertNotVisible;
4
+ const runner_js_1 = require("../runner.js");
5
+ const output_js_1 = require("../output.js");
6
+ const ios_js_1 = require("../drivers/ios.js");
7
+ const android_js_1 = require("../drivers/android.js");
8
+ const wait_js_1 = require("../drivers/wait.js");
9
+ async function assertNotVisible(element, opts = {}, sessionName = 'default', flags = {}) {
10
+ if (!element && !flags.id && !flags.text) {
11
+ (0, output_js_1.printError)('assert-not-visible requires <element> or --id <id>', opts);
12
+ return 1;
13
+ }
14
+ const sel = {
15
+ ...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query: element }),
16
+ ...(flags.index !== undefined && { index: flags.index }),
17
+ ...(flags.focused !== undefined && { focused: flags.focused }),
18
+ ...(flags.enabled !== undefined && { enabled: flags.enabled }),
19
+ ...(flags.checked !== undefined && { checked: flags.checked }),
20
+ ...(flags.selected !== undefined && { selected: flags.selected }),
21
+ ...(flags.below && { below: { query: flags.below } }),
22
+ ...(flags.above && { above: { query: flags.above } }),
23
+ ...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
24
+ ...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
25
+ };
26
+ const label = flags.text
27
+ ? `text="${flags.text}"`
28
+ : flags.id
29
+ ? `id="${flags.id}"`
30
+ : `"${element}"`;
31
+ try {
32
+ const driver = await (0, runner_js_1.getDriver)(sessionName);
33
+ if (driver instanceof ios_js_1.IOSDriver) {
34
+ await (0, wait_js_1.waitUntilIOSElementGone)(() => driver.viewHierarchy().then((h) => h.axElement), sel, flags.timeout);
35
+ }
36
+ else if (driver instanceof android_js_1.AndroidDriver) {
37
+ await (0, wait_js_1.waitUntilAndroidElementGone)(() => driver.viewHierarchy(), sel, flags.timeout);
38
+ }
39
+ (0, output_js_1.printSuccess)(`assert-not-visible ${label} — element not found`, opts);
40
+ return 0;
41
+ }
42
+ catch (err) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ (0, output_js_1.printError)(`assert-not-visible ${label} — ${msg}`, opts);
45
+ return 1;
46
+ }
47
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertVisible = assertVisible;
4
+ const runner_js_1 = require("../runner.js");
5
+ const output_js_1 = require("../output.js");
6
+ const ios_js_1 = require("../drivers/ios.js");
7
+ const android_js_1 = require("../drivers/android.js");
8
+ const wait_js_1 = require("../drivers/wait.js");
9
+ async function assertVisible(element, opts = {}, sessionName = 'default', flags = {}) {
10
+ if (!element && !flags.id && !flags.text) {
11
+ (0, output_js_1.printError)('assert-visible requires <element> or --id <id>', opts);
12
+ return 1;
13
+ }
14
+ const sel = {
15
+ ...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query: element }),
16
+ ...(flags.index !== undefined && { index: flags.index }),
17
+ ...(flags.focused !== undefined && { focused: flags.focused }),
18
+ ...(flags.enabled !== undefined && { enabled: flags.enabled }),
19
+ ...(flags.checked !== undefined && { checked: flags.checked }),
20
+ ...(flags.selected !== undefined && { selected: flags.selected }),
21
+ ...(flags.below && { below: { query: flags.below } }),
22
+ ...(flags.above && { above: { query: flags.above } }),
23
+ ...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
24
+ ...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
25
+ };
26
+ const label = flags.text
27
+ ? `text="${flags.text}"`
28
+ : flags.id
29
+ ? `id="${flags.id}"`
30
+ : `"${element}"`;
31
+ const timeoutMs = flags.timeout ?? (flags.optional ? wait_js_1.OPTIONAL_TIMEOUT_MS : undefined);
32
+ try {
33
+ const driver = await (0, runner_js_1.getDriver)(sessionName);
34
+ const find = async () => {
35
+ if (driver instanceof ios_js_1.IOSDriver) {
36
+ return (0, wait_js_1.waitForIOSElement)(() => driver.viewHierarchy().then((h) => h.axElement), sel, timeoutMs);
37
+ }
38
+ else if (driver instanceof android_js_1.AndroidDriver) {
39
+ return (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel, timeoutMs);
40
+ }
41
+ };
42
+ if (flags.optional) {
43
+ await find().catch(() => {
44
+ /* not found — acceptable */
45
+ });
46
+ }
47
+ else {
48
+ await find();
49
+ }
50
+ (0, output_js_1.printSuccess)(`assert-visible ${label} — element found`, opts);
51
+ return 0;
52
+ }
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ (0, output_js_1.printError)(`assert-visible ${label} — ${msg}`, opts);
56
+ return 1;
57
+ }
58
+ }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.back = back;
4
+ const runner_js_1 = require("../runner.js");
5
+ const output_js_1 = require("../output.js");
6
+ const ios_js_1 = require("../drivers/ios.js");
7
+ const android_js_1 = require("../drivers/android.js");
8
+ async function back(opts = {}, sessionName = 'default') {
9
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
10
+ if (driver instanceof ios_js_1.IOSDriver) {
11
+ // iOS has no universal "back" concept — this is a no-op (same as maestro IOSDriver.backPress)
12
+ }
13
+ else if (driver instanceof android_js_1.AndroidDriver) {
14
+ await driver.back(); // adb shell input keyevent 4
15
+ }
16
+ }, sessionName);
17
+ if (result.success) {
18
+ (0, output_js_1.printSuccess)('back — done', opts);
19
+ return 0;
20
+ }
21
+ else {
22
+ (0, output_js_1.printError)(`back — failed\n${result.stderr}`, opts);
23
+ return 1;
24
+ }
25
+ }
@@ -0,0 +1,100 @@
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.cheatSheet = cheatSheet;
7
+ const promises_1 = __importDefault(require("fs/promises"));
8
+ const path_1 = __importDefault(require("path"));
9
+ async function cheatSheet() {
10
+ // Try to read SKILL.md from the package root
11
+ const skillPath = path_1.default.join(__dirname, '../../skills/conductor/SKILL.md');
12
+ try {
13
+ const content = await promises_1.default.readFile(skillPath, 'utf-8');
14
+ console.log(content);
15
+ }
16
+ catch {
17
+ // Fallback: print inline reference
18
+ console.log(INLINE_CHEAT_SHEET);
19
+ }
20
+ return 0;
21
+ }
22
+ const INLINE_CHEAT_SHEET = `
23
+ conductor — Command Reference
24
+ ================================
25
+
26
+ DEVICE MANAGEMENT
27
+ list-devices List connected devices/simulators
28
+ foreground-app Print bundle ID / package of the foreground app
29
+ list-apps List installed app IDs / package names
30
+ session Show current session (appId, deviceId)
31
+ session --clear Clear session state
32
+ session --list List all device sessions
33
+
34
+ APP CONTROL
35
+ launch-app <appId> [--device <id>] Launch app and save to session
36
+ --clear-state Wipe app data/state before launching
37
+ --clear-keychain Wipe keychain before launching
38
+ --argument key=value Set launch argument (repeatable)
39
+ stop-app [<appId>] Stop app (uses session appId if omitted)
40
+
41
+ INTERACTIONS
42
+ tap <element> Tap element by text
43
+ --id <id> Match by accessibility ID instead of text
44
+ --index <n> Pick the nth match (0-based)
45
+ --long-press Hold instead of tap
46
+ --double-tap Double-tap the element
47
+ type <text> Type text into focused field
48
+ back Press back button (Android only)
49
+ press-key <key> Press a key (Enter, Backspace, Home, ...)
50
+ scroll [--direction down|up|left|right] Scroll (default: down)
51
+ swipe --direction <UP|DOWN|LEFT|RIGHT> Directional swipe
52
+ --start <x,y> Start coordinate (0–1 normalised or absolute px)
53
+ --end <x,y> End coordinate (same)
54
+ --duration <ms> Swipe duration (default: 500)
55
+
56
+ ASSERTIONS
57
+ assert-visible <element> Assert element is visible
58
+ --id <id> Match by accessibility ID instead of text
59
+ --timeout <ms> Max wait time (default: 17000)
60
+ --optional Succeed even if element is not found
61
+
62
+ SCREENSHOTS & INSPECTION
63
+ screenshot [--output <path>] Take screenshot (default: ./screenshot-<ts>.png)
64
+ inspect Print UI hierarchy
65
+
66
+ FLOW EXECUTION
67
+ run-flow <file> [--device <id>] Run a Maestro YAML flow file
68
+ run-flow-inline <yaml> Run inline YAML commands
69
+
70
+ DAEMON (optional — keeps driver alive between commands)
71
+ daemon-start [--device <id>] Start background daemon
72
+ daemon-stop [--device <id>] [--all] Stop daemon (--all stops every daemon)
73
+ daemon-status [--device <id>] Show daemon status
74
+
75
+ MULTI-AGENT / PARALLEL
76
+ device-pool --list List devices and pool status
77
+ device-pool --acquire Claim a free device (prints device ID)
78
+ device-pool --release <id> Release a device back to the pool
79
+ run-parallel --flows-dir <path> Run flows in parallel across all devices
80
+
81
+ MISC
82
+ install --skills Install skill files into .claude/skills/
83
+ cheat-sheet Print this reference
84
+
85
+ GLOBAL FLAGS
86
+ --device <id> Target device (auto-detected if omitted)
87
+ --json Machine-readable JSON output
88
+ --verbose, -v Log daemon calls, fallbacks, raw output
89
+ --help, -h Show help
90
+
91
+ EXAMPLES
92
+ conductor launch-app com.example.app
93
+ conductor tap "Sign In"
94
+ conductor tap --id "btn_login"
95
+ conductor type "hello@example.com"
96
+ conductor swipe --start 0.5,0.8 --end 0.5,0.2
97
+ conductor assert-visible "Dashboard" --timeout 30000
98
+ conductor screenshot --output /tmp/screen.png
99
+ conductor run-flow ./flows/login.yaml
100
+ `.trim();
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.daemonStart = daemonStart;
4
+ exports.daemonStop = daemonStop;
5
+ exports.daemonStatusCmd = daemonStatusCmd;
6
+ const client_js_1 = require("../daemon/client.js");
7
+ const output_js_1 = require("../output.js");
8
+ async function daemonStart(opts = {}, sessionName = 'default') {
9
+ const ready = await (0, client_js_1.startDaemon)(sessionName);
10
+ if (ready) {
11
+ (0, output_js_1.printSuccess)(`daemon [${sessionName}] started — driver process is running`, opts);
12
+ return 0;
13
+ }
14
+ else {
15
+ (0, output_js_1.printError)(`daemon [${sessionName}] failed to start within timeout`, opts);
16
+ return 1;
17
+ }
18
+ }
19
+ async function daemonStop(opts = {}, sessionName = 'default', all = false) {
20
+ if (all) {
21
+ const sessions = (0, client_js_1.listDaemonSessions)();
22
+ if (sessions.length === 0) {
23
+ (0, output_js_1.printSuccess)('no daemons running', opts);
24
+ return 0;
25
+ }
26
+ let exitCode = 0;
27
+ for (const name of sessions) {
28
+ const stopped = await (0, client_js_1.stopDaemon)(name);
29
+ if (stopped) {
30
+ (0, output_js_1.printSuccess)(`daemon [${name}] stopped`, opts);
31
+ }
32
+ else {
33
+ (0, output_js_1.printError)(`daemon [${name}] was not running`, opts);
34
+ exitCode = 1;
35
+ }
36
+ }
37
+ return exitCode;
38
+ }
39
+ const stopped = await (0, client_js_1.stopDaemon)(sessionName);
40
+ if (stopped) {
41
+ (0, output_js_1.printSuccess)(`daemon [${sessionName}] stopped`, opts);
42
+ return 0;
43
+ }
44
+ else {
45
+ (0, output_js_1.printError)(`daemon [${sessionName}] was not running`, opts);
46
+ return 1;
47
+ }
48
+ }
49
+ async function daemonStatusCmd(opts = {}, sessionName = 'default') {
50
+ const status = await (0, client_js_1.daemonStatus)(sessionName);
51
+ if (opts.json) {
52
+ (0, output_js_1.printData)({ ...status, sessionName }, opts);
53
+ }
54
+ else if (status.running) {
55
+ console.log(`daemon [${sessionName}]: running (pid ${status.pid ?? 'unknown'})`);
56
+ }
57
+ else {
58
+ console.log(`daemon [${sessionName}]: not running`);
59
+ }
60
+ return 0;
61
+ }
@@ -0,0 +1,202 @@
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.devicePool = devicePool;
7
+ /**
8
+ * device-pool: Manage a pool of available devices for concurrent multi-agent use.
9
+ *
10
+ * Pool state is stored in ~/.conductor/device-pool.json with file-based locking.
11
+ *
12
+ * Usage:
13
+ * conductor device-pool --list # list all devices and pool status
14
+ * conductor device-pool --acquire # claim a free device, print its ID
15
+ * conductor device-pool --release <id> # release a device back to the pool
16
+ */
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const child_process_1 = require("child_process");
21
+ const output_js_1 = require("../output.js");
22
+ const POOL_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'device-pool.json');
23
+ const LOCK_FILE = POOL_FILE + '.lock';
24
+ const LOCK_TIMEOUT_MS = 5000;
25
+ // ── File locking ──────────────────────────────────────────────────────────────
26
+ async function withLock(fn) {
27
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
28
+ while (Date.now() < deadline) {
29
+ try {
30
+ const fd = fs_1.default.openSync(LOCK_FILE, 'wx'); // exclusive create
31
+ fs_1.default.closeSync(fd);
32
+ try {
33
+ return await Promise.resolve(fn());
34
+ }
35
+ finally {
36
+ try {
37
+ fs_1.default.unlinkSync(LOCK_FILE);
38
+ }
39
+ catch {
40
+ /* ok */
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ await sleep(50);
46
+ }
47
+ }
48
+ throw new Error('Could not acquire device pool lock');
49
+ }
50
+ function readPool() {
51
+ try {
52
+ const raw = fs_1.default.readFileSync(POOL_FILE, 'utf-8');
53
+ return JSON.parse(raw);
54
+ }
55
+ catch {
56
+ return { devices: [] };
57
+ }
58
+ }
59
+ function writePool(state) {
60
+ fs_1.default.mkdirSync(path_1.default.dirname(POOL_FILE), { recursive: true });
61
+ fs_1.default.writeFileSync(POOL_FILE, JSON.stringify(state, null, 2));
62
+ }
63
+ // ── Device discovery ──────────────────────────────────────────────────────────
64
+ async function discoverAllDevices() {
65
+ const devices = [];
66
+ // Android: adb devices
67
+ try {
68
+ const out = await spawnCapture('adb', ['devices', '-l']);
69
+ for (const line of out.split('\n').slice(1)) {
70
+ const id = line.trim().split(/\s+/)[0];
71
+ if (id && !line.includes('offline') && id !== '') {
72
+ devices.push(id);
73
+ }
74
+ }
75
+ }
76
+ catch {
77
+ /* adb not available */
78
+ }
79
+ // iOS: xcrun simctl list booted
80
+ try {
81
+ const out = await spawnCapture('xcrun', ['simctl', 'list', 'devices', 'booted', '--json']);
82
+ const parsed = JSON.parse(out);
83
+ for (const sims of Object.values(parsed.devices)) {
84
+ for (const sim of sims) {
85
+ if (sim.state === 'Booted')
86
+ devices.push(sim.udid);
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ /* xcrun not available */
92
+ }
93
+ return devices;
94
+ }
95
+ // ── Commands ──────────────────────────────────────────────────────────────────
96
+ async function devicePool(action, releaseId, opts = {}) {
97
+ if (action === 'list') {
98
+ const allDevices = await discoverAllDevices();
99
+ const pool = readPool();
100
+ const rows = allDevices.map((id) => {
101
+ const entry = pool.devices.find((e) => e.deviceId === id);
102
+ const status = entry?.acquiredBy ? `acquired by PID ${entry.acquiredBy}` : 'free';
103
+ return `${id} ${status}`;
104
+ });
105
+ if (opts.json) {
106
+ const data = allDevices.map((id) => {
107
+ const entry = pool.devices.find((e) => e.deviceId === id);
108
+ return { deviceId: id, free: !entry?.acquiredBy, acquiredBy: entry?.acquiredBy };
109
+ });
110
+ console.log(JSON.stringify({ status: 'ok', devices: data }));
111
+ }
112
+ else {
113
+ if (rows.length === 0) {
114
+ console.log('No devices found.');
115
+ }
116
+ else {
117
+ console.log(rows.join('\n'));
118
+ }
119
+ }
120
+ return 0;
121
+ }
122
+ if (action === 'acquire') {
123
+ const allDevices = await discoverAllDevices();
124
+ if (allDevices.length === 0) {
125
+ (0, output_js_1.printError)('No devices available', opts);
126
+ return 1;
127
+ }
128
+ const result = await withLock(() => {
129
+ const state = readPool();
130
+ // Prune stale acquisitions (process no longer running)
131
+ for (const entry of state.devices) {
132
+ if (entry.acquiredBy) {
133
+ try {
134
+ process.kill(parseInt(entry.acquiredBy, 10), 0);
135
+ }
136
+ catch {
137
+ delete entry.acquiredBy;
138
+ delete entry.acquiredAt;
139
+ }
140
+ }
141
+ }
142
+ // Ensure all discovered devices are in the pool
143
+ for (const id of allDevices) {
144
+ if (!state.devices.find((e) => e.deviceId === id)) {
145
+ state.devices.push({ deviceId: id });
146
+ }
147
+ }
148
+ // Find a free device
149
+ const free = state.devices.find((e) => allDevices.includes(e.deviceId) && !e.acquiredBy);
150
+ if (!free)
151
+ return null;
152
+ free.acquiredBy = String(process.pid);
153
+ free.acquiredAt = Date.now();
154
+ writePool(state);
155
+ return free.deviceId;
156
+ });
157
+ if (!result) {
158
+ (0, output_js_1.printError)('No free devices available in pool', opts);
159
+ return 1;
160
+ }
161
+ if (opts.json) {
162
+ console.log(JSON.stringify({ status: 'ok', deviceId: result }));
163
+ }
164
+ else {
165
+ console.log(result);
166
+ }
167
+ return 0;
168
+ }
169
+ if (action === 'release') {
170
+ if (!releaseId) {
171
+ (0, output_js_1.printError)('device-pool --release requires a device ID', opts);
172
+ return 1;
173
+ }
174
+ await withLock(() => {
175
+ const state = readPool();
176
+ const entry = state.devices.find((e) => e.deviceId === releaseId);
177
+ if (entry) {
178
+ delete entry.acquiredBy;
179
+ delete entry.acquiredAt;
180
+ writePool(state);
181
+ }
182
+ });
183
+ (0, output_js_1.printSuccess)(`Released device ${releaseId}`, opts);
184
+ return 0;
185
+ }
186
+ (0, output_js_1.printError)('device-pool: unknown action', opts);
187
+ return 1;
188
+ }
189
+ function sleep(ms) {
190
+ return new Promise((r) => setTimeout(r, ms));
191
+ }
192
+ function spawnCapture(cmd, args) {
193
+ return new Promise((resolve, reject) => {
194
+ const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
195
+ let out = '';
196
+ proc.stdout?.on('data', (chunk) => {
197
+ out += chunk.toString();
198
+ });
199
+ proc.on('close', (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} failed (${code})`)));
200
+ proc.on('error', reject);
201
+ });
202
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.eraseText = eraseText;
4
+ const runner_js_1 = require("../runner.js");
5
+ const output_js_1 = require("../output.js");
6
+ const ios_js_1 = require("../drivers/ios.js");
7
+ const android_js_1 = require("../drivers/android.js");
8
+ async function eraseText(characters, opts = {}, sessionName = 'default') {
9
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
10
+ if (driver instanceof android_js_1.AndroidDriver) {
11
+ await driver.eraseAllText(characters);
12
+ }
13
+ else if (driver instanceof ios_js_1.IOSDriver) {
14
+ for (let i = 0; i < characters; i++)
15
+ await driver.pressKey('delete');
16
+ }
17
+ }, sessionName);
18
+ if (result.success) {
19
+ (0, output_js_1.printSuccess)(`erase-text ${characters} — done`, opts);
20
+ return 0;
21
+ }
22
+ else {
23
+ (0, output_js_1.printError)(`erase-text ${characters} — failed\n${result.stderr}`, opts);
24
+ return 1;
25
+ }
26
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.foregroundApp = foregroundApp;
4
+ const runner_js_1 = require("../runner.js");
5
+ const output_js_1 = require("../output.js");
6
+ const ios_js_1 = require("../drivers/ios.js");
7
+ const android_js_1 = require("../drivers/android.js");
8
+ async function resolveDeviceId(sessionName) {
9
+ if (sessionName !== 'default')
10
+ return sessionName;
11
+ return (0, runner_js_1.detectFirstDevice)();
12
+ }
13
+ async function getInstalledAppIds(deviceId) {
14
+ const result = await (0, runner_js_1.spawnCommand)('bash', [
15
+ '-c',
16
+ `xcrun simctl listapps ${deviceId} | plutil -convert json - -o -`,
17
+ ]);
18
+ if (!result.success)
19
+ return [];
20
+ try {
21
+ return Object.keys(JSON.parse(result.stdout));
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ async function foregroundApp(opts = {}, sessionName = 'default') {
28
+ try {
29
+ const driver = await (0, runner_js_1.getDriver)(sessionName);
30
+ let appId;
31
+ if (driver instanceof ios_js_1.IOSDriver) {
32
+ const deviceId = await resolveDeviceId(sessionName);
33
+ const appIds = deviceId ? await getInstalledAppIds(deviceId) : [];
34
+ appId = await driver.runningApp(appIds);
35
+ }
36
+ else if (driver instanceof android_js_1.AndroidDriver) {
37
+ appId = await driver.getForegroundApp();
38
+ }
39
+ else {
40
+ throw new Error('Unsupported driver');
41
+ }
42
+ (0, output_js_1.printSuccess)(appId, opts);
43
+ return 0;
44
+ }
45
+ catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ (0, output_js_1.printError)(`foreground-app failed\n${msg}`, opts);
48
+ return 1;
49
+ }
50
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hideKeyboard = hideKeyboard;
4
+ const runner_js_1 = require("../runner.js");
5
+ const output_js_1 = require("../output.js");
6
+ const ios_js_1 = require("../drivers/ios.js");
7
+ const android_js_1 = require("../drivers/android.js");
8
+ async function hideKeyboard(opts = {}, sessionName = 'default') {
9
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
10
+ if (driver instanceof ios_js_1.IOSDriver) {
11
+ await driver.pressKey('return').catch(() => {
12
+ /* no keyboard visible */
13
+ });
14
+ }
15
+ else if (driver instanceof android_js_1.AndroidDriver) {
16
+ await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
17
+ }
18
+ }, sessionName);
19
+ if (result.success) {
20
+ (0, output_js_1.printSuccess)('hide-keyboard — done', opts);
21
+ return 0;
22
+ }
23
+ else {
24
+ (0, output_js_1.printError)(`hide-keyboard — failed\n${result.stderr}`, opts);
25
+ return 1;
26
+ }
27
+ }