@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.
- package/.claude-plugin/plugin.json +6 -0
- package/README.md +39 -0
- package/dist/commands/assert-not-visible.js +47 -0
- package/dist/commands/assert-visible.js +58 -0
- package/dist/commands/back.js +25 -0
- package/dist/commands/cheat-sheet.js +100 -0
- package/dist/commands/daemon.js +61 -0
- package/dist/commands/device-pool.js +202 -0
- package/dist/commands/erase-text.js +26 -0
- package/dist/commands/foreground-app.js +50 -0
- package/dist/commands/hide-keyboard.js +27 -0
- package/dist/commands/inspect.js +37 -0
- package/dist/commands/install.js +64 -0
- package/dist/commands/launch-app.js +42 -0
- package/dist/commands/list-apps.js +60 -0
- package/dist/commands/list-devices.js +61 -0
- package/dist/commands/open-link.js +22 -0
- package/dist/commands/press-key.js +91 -0
- package/dist/commands/run-flow-inline.js +25 -0
- package/dist/commands/run-flow.js +29 -0
- package/dist/commands/run-parallel.js +143 -0
- package/dist/commands/screenshot.js +29 -0
- package/dist/commands/scroll-until-visible.js +69 -0
- package/dist/commands/scroll.js +36 -0
- package/dist/commands/session.js +49 -0
- package/dist/commands/set-location.js +18 -0
- package/dist/commands/set-orientation.js +23 -0
- package/dist/commands/start-device.js +178 -0
- package/dist/commands/stop-app.js +32 -0
- package/dist/commands/swipe.js +72 -0
- package/dist/commands/tap.js +69 -0
- package/dist/commands/type.js +22 -0
- package/dist/daemon/client.js +112 -0
- package/dist/daemon/protocol.js +25 -0
- package/dist/daemon/server.js +208 -0
- package/dist/drivers/android.js +343 -0
- package/dist/drivers/bootstrap.js +371 -0
- package/dist/drivers/element-resolver.js +371 -0
- package/dist/drivers/flow-runner.js +1309 -0
- package/dist/drivers/ios.js +328 -0
- package/dist/drivers/js-engine.js +150 -0
- package/dist/drivers/wait.js +211 -0
- package/dist/index.js +426 -0
- package/dist/output.js +36 -0
- package/dist/pkg-root.js +28 -0
- package/dist/postinstall.js +12 -0
- package/dist/runner.js +190 -0
- package/dist/session.js +66 -0
- package/dist/update-check.js +109 -0
- package/dist/utils.js +19 -0
- package/dist/verbose.js +17 -0
- package/drivers/android/conductor-app.apk +0 -0
- package/drivers/android/conductor-server.apk +0 -0
- package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
- package/drivers/ios/conductor-driver-ios.zip +0 -0
- package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
- package/package.json +52 -0
- package/proto/conductor_android.proto +116 -0
- package/skills/conductor/SKILL.md +677 -0
- package/skills/conductor/references/flow-syntax.md +179 -0
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
|
+
}
|