@houwert/conductor 0.5.0 → 0.7.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 +120 -32
- package/dist/daemon/web-server.js +892 -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 +74 -13
- 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/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
|
|
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
|
|
61
|
-
| `conductor install` | Re-register or update the global plugin |
|
|
62
|
-
| `conductor install --
|
|
63
|
-
| `conductor install
|
|
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
72
|
| App lifecycle | `launch-app`, `stop-app`, `clear-state`, `uninstall-app`, `install-app`, `foreground-app`, `copy-app` |
|
|
70
|
-
| Interaction | `tap`, `
|
|
71
|
-
| Inspection | `inspect`, `focused`, `screenshot`, `list-apps` |
|
|
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
|
}
|
package/dist/commands/back.js
CHANGED
|
@@ -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,7 @@ 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
|
|
37
38
|
install-app <path> Install .app / .ipa / .apk onto device
|
|
38
39
|
launch-app <appId> [--device <id>] Launch app and save to session
|
|
39
40
|
--clear-state Wipe app data/state before launching
|
|
@@ -44,12 +45,12 @@ APP CONTROL
|
|
|
44
45
|
uninstall-app <appId> Uninstall app from device
|
|
45
46
|
|
|
46
47
|
INTERACTIONS
|
|
47
|
-
tap <element>
|
|
48
|
+
tap-on <element> Tap element by text
|
|
48
49
|
--id <id> Match by accessibility ID instead of text
|
|
49
50
|
--index <n> Pick the nth match (0-based)
|
|
50
51
|
--long-press Hold instead of tap
|
|
51
52
|
--double-tap Double-tap the element
|
|
52
|
-
|
|
53
|
+
input-text <text> Type text into focused field
|
|
53
54
|
back Press back button (Android only)
|
|
54
55
|
press-key <key> Press a key (Enter, Backspace, Home, ...)
|
|
55
56
|
scroll [--direction down|up|left|right] Scroll (default: down)
|
|
@@ -65,7 +66,7 @@ ASSERTIONS
|
|
|
65
66
|
--optional Succeed even if element is not found
|
|
66
67
|
|
|
67
68
|
SCREENSHOTS & INSPECTION
|
|
68
|
-
screenshot [--output <path>]
|
|
69
|
+
take-screenshot [--output <path>] Take screenshot (default: ./screenshot-<ts>.png)
|
|
69
70
|
inspect Print UI hierarchy
|
|
70
71
|
|
|
71
72
|
FLOW EXECUTION
|
|
@@ -84,7 +85,9 @@ MULTI-AGENT / PARALLEL
|
|
|
84
85
|
run-parallel --flows-dir <path> Run flows in parallel across all devices
|
|
85
86
|
|
|
86
87
|
MISC
|
|
87
|
-
install
|
|
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
|
|
88
91
|
cheat-sheet Print this reference
|
|
89
92
|
|
|
90
93
|
GLOBAL FLAGS
|
|
@@ -95,11 +98,11 @@ GLOBAL FLAGS
|
|
|
95
98
|
|
|
96
99
|
EXAMPLES
|
|
97
100
|
conductor launch-app com.example.app
|
|
98
|
-
conductor tap "Sign In"
|
|
99
|
-
conductor tap --id "btn_login"
|
|
100
|
-
conductor
|
|
101
|
+
conductor tap-on "Sign In"
|
|
102
|
+
conductor tap-on --id "btn_login"
|
|
103
|
+
conductor input-text "hello@example.com"
|
|
101
104
|
conductor swipe --start 0.5,0.8 --end 0.5,0.2
|
|
102
105
|
conductor assert-visible "Dashboard" --timeout 30000
|
|
103
|
-
conductor screenshot --output /tmp/screen.png
|
|
106
|
+
conductor take-screenshot --output /tmp/screen.png
|
|
104
107
|
conductor run-flow ./flows/login.yaml
|
|
105
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
|
-
|
|
27
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
65
|
-
fs_1.default.
|
|
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 =
|
|
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
|
-
|
|
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');
|
package/dist/commands/focused.js
CHANGED
|
@@ -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);
|