@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,371 @@
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.detectPlatform = detectPlatform;
7
+ exports.getDriverPort = getDriverPort;
8
+ exports.installDriver = installDriver;
9
+ exports.isSimulatorBooted = isSimulatorBooted;
10
+ exports.isPortOpen = isPortOpen;
11
+ exports.startIOSDriver = startIOSDriver;
12
+ exports.stopIOSDriver = stopIOSDriver;
13
+ exports.startAndroidDriver = startAndroidDriver;
14
+ exports.stopAndroidDriver = stopAndroidDriver;
15
+ exports.uninstallDriver = uninstallDriver;
16
+ /**
17
+ * Driver lifecycle manager.
18
+ *
19
+ * Manages the underlying device driver processes:
20
+ * iOS: xcodebuild test-without-building → XCTest HTTP server on port 1075
21
+ * Android: adb forward + adb shell am instrument → gRPC server on port 3763
22
+ *
23
+ * Driver binaries are bundled inside the npm package under drivers/android/ and
24
+ * drivers/ios/ — no separate Conductor/JVM installation required.
25
+ */
26
+ const child_process_1 = require("child_process");
27
+ const net_1 = __importDefault(require("net"));
28
+ const os_1 = __importDefault(require("os"));
29
+ const fs_1 = __importDefault(require("fs"));
30
+ const path_1 = __importDefault(require("path"));
31
+ const verbose_js_1 = require("../verbose.js");
32
+ const utils_js_1 = require("../utils.js");
33
+ /** Cache: deviceId → platform */
34
+ const _platformCache = new Map();
35
+ async function detectPlatform(deviceId) {
36
+ if (_platformCache.has(deviceId))
37
+ return _platformCache.get(deviceId);
38
+ // Check if it looks like an iOS simulator UUID (8-4-4-4-12 hex chars)
39
+ const iosUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
40
+ if (iosUuidRe.test(deviceId)) {
41
+ _platformCache.set(deviceId, 'ios');
42
+ return 'ios';
43
+ }
44
+ // Otherwise assume Android (serial like emulator-5554 or real device)
45
+ _platformCache.set(deviceId, 'android');
46
+ return 'android';
47
+ }
48
+ // ── Port management ───────────────────────────────────────────────────────────
49
+ const IOS_BASE_PORT = 1075;
50
+ const ANDROID_BASE_PORT = 3763;
51
+ const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ports.json');
52
+ const PORT_LOCK = PORT_FILE + '.lock';
53
+ const PORT_LOCK_TIMEOUT_MS = 5000;
54
+ function readPortState() {
55
+ try {
56
+ return JSON.parse(fs_1.default.readFileSync(PORT_FILE, 'utf-8'));
57
+ }
58
+ catch {
59
+ return { assignments: {}, nextIosPort: IOS_BASE_PORT, nextAndroidPort: ANDROID_BASE_PORT };
60
+ }
61
+ }
62
+ function writePortState(state) {
63
+ fs_1.default.mkdirSync(path_1.default.dirname(PORT_FILE), { recursive: true });
64
+ fs_1.default.writeFileSync(PORT_FILE, JSON.stringify(state, null, 2));
65
+ }
66
+ async function withPortLock(fn) {
67
+ fs_1.default.mkdirSync(path_1.default.dirname(PORT_LOCK), { recursive: true });
68
+ const deadline = Date.now() + PORT_LOCK_TIMEOUT_MS;
69
+ while (Date.now() < deadline) {
70
+ try {
71
+ const fd = fs_1.default.openSync(PORT_LOCK, 'wx');
72
+ fs_1.default.closeSync(fd);
73
+ try {
74
+ return fn();
75
+ }
76
+ finally {
77
+ try {
78
+ fs_1.default.unlinkSync(PORT_LOCK);
79
+ }
80
+ catch {
81
+ /* ok */
82
+ }
83
+ }
84
+ }
85
+ catch {
86
+ await new Promise((r) => setTimeout(r, 50));
87
+ }
88
+ }
89
+ throw new Error('Could not acquire port registry lock');
90
+ }
91
+ /**
92
+ * Assign and persist a driver port for a device.
93
+ * Safe to call concurrently from multiple processes — uses a file lock.
94
+ * iOS devices get unique ports starting from 1075; Android from 3763.
95
+ */
96
+ async function getDriverPort(platform, deviceId) {
97
+ return withPortLock(() => {
98
+ const state = readPortState();
99
+ if (state.assignments[deviceId] !== undefined) {
100
+ return state.assignments[deviceId];
101
+ }
102
+ const port = platform === 'ios' ? state.nextIosPort++ : state.nextAndroidPort++;
103
+ state.assignments[deviceId] = port;
104
+ writePortState(state);
105
+ return port;
106
+ });
107
+ }
108
+ // ── Bundled driver paths ───────────────────────────────────────────────────────
109
+ /**
110
+ * Root of the bundled drivers directory (packages/cli/drivers/).
111
+ *
112
+ * Walk up from __dirname to find the package root (the directory containing
113
+ * package.json). This handles both the normal build (dist/drivers/bootstrap.js)
114
+ * and the test build (dist-tests/src/drivers/bootstrap.js) where __dirname has
115
+ * an extra src/ level, making a fixed relative path incorrect.
116
+ */
117
+ function findBundledDriversDir() {
118
+ let dir = __dirname;
119
+ while (true) {
120
+ if (fs_1.default.existsSync(path_1.default.join(dir, 'package.json'))) {
121
+ return path_1.default.join(dir, 'drivers');
122
+ }
123
+ const parent = path_1.default.dirname(dir);
124
+ if (parent === dir)
125
+ break;
126
+ dir = parent;
127
+ }
128
+ // Fallback to original relative path
129
+ return path_1.default.join(__dirname, '..', '..', 'drivers');
130
+ }
131
+ const BUNDLED_DRIVERS_DIR = findBundledDriversDir();
132
+ /**
133
+ * Install the Conductor Android driver APKs on the device.
134
+ * Reads pre-built APKs directly from the bundled drivers directory.
135
+ */
136
+ async function installDriver(deviceId) {
137
+ (0, verbose_js_1.log)(`installDriver: installing Android driver on ${deviceId}`);
138
+ const appApk = path_1.default.join(BUNDLED_DRIVERS_DIR, 'android', 'conductor-app.apk');
139
+ const serverApk = path_1.default.join(BUNDLED_DRIVERS_DIR, 'android', 'conductor-server.apk');
140
+ if (!fs_1.default.existsSync(appApk) || !fs_1.default.existsSync(serverApk)) {
141
+ throw new Error(`Conductor driver APKs not found at ${path_1.default.join(BUNDLED_DRIVERS_DIR, 'android')}.\n` +
142
+ `Run 'make package-cli' from the repo root to build and bundle the drivers.`);
143
+ }
144
+ await spawnAndWait('adb', ['-s', deviceId, 'install', '-r', '-t', '-g', appApk]);
145
+ await spawnAndWait('adb', ['-s', deviceId, 'install', '-r', '-t', '-g', serverApk]);
146
+ (0, verbose_js_1.log)(`installDriver: done`);
147
+ }
148
+ // ── iOS bootstrap ─────────────────────────────────────────────────────────────
149
+ const IOS_RUNNER_BUNDLE_ID = 'dev.houwert.conductor-driver-iosUITests.xctrunner';
150
+ const IOS_STARTUP_TIMEOUT_MS = 120000;
151
+ const IOS_STARTUP_POLL_MS = 500;
152
+ // Persistent cache for extracted iOS driver files (~/.conductor/ios-driver/).
153
+ // __TESTROOT__ in the xctestrun resolves to this directory, so both the xctestrun
154
+ // and the Debug-iphonesimulator/ folder must live here.
155
+ const IOS_DRIVER_CACHE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ios-driver');
156
+ /**
157
+ * Ensure the iOS driver files are extracted from the bundled zips into the cache
158
+ * dir. Re-extracts only when the bundled xctestrun has changed (tracked by mtime).
159
+ */
160
+ async function setupIOSDriverCache() {
161
+ const bundledXctestrun = path_1.default.join(BUNDLED_DRIVERS_DIR, 'ios', 'conductor-driver-ios-config.xctestrun');
162
+ const bundledDriverZip = path_1.default.join(BUNDLED_DRIVERS_DIR, 'ios', 'conductor-driver-ios.zip');
163
+ const bundledRunnerZip = path_1.default.join(BUNDLED_DRIVERS_DIR, 'ios', 'conductor-driver-iosUITests-Runner.zip');
164
+ if (!fs_1.default.existsSync(bundledXctestrun) ||
165
+ !fs_1.default.existsSync(bundledDriverZip) ||
166
+ !fs_1.default.existsSync(bundledRunnerZip)) {
167
+ throw new Error(`Conductor iOS driver files not found at ${path_1.default.join(BUNDLED_DRIVERS_DIR, 'ios')}.\n` +
168
+ `Run 'make package-cli' from the repo root to build and bundle the drivers.`);
169
+ }
170
+ const versionFile = path_1.default.join(IOS_DRIVER_CACHE, '.version');
171
+ const xctestrunMtime = String(fs_1.default.statSync(bundledXctestrun).mtimeMs);
172
+ let cachedMtime = '';
173
+ try {
174
+ cachedMtime = fs_1.default.readFileSync(versionFile, 'utf-8').trim();
175
+ }
176
+ catch {
177
+ /* first run */
178
+ }
179
+ const runnerApp = path_1.default.join(IOS_DRIVER_CACHE, 'Debug-iphonesimulator', 'conductor-driver-iosUITests-Runner.app');
180
+ if (cachedMtime === xctestrunMtime && fs_1.default.existsSync(runnerApp))
181
+ return;
182
+ (0, verbose_js_1.log)('Extracting iOS driver files to cache...');
183
+ fs_1.default.rmSync(IOS_DRIVER_CACHE, { recursive: true, force: true });
184
+ fs_1.default.mkdirSync(IOS_DRIVER_CACHE, { recursive: true });
185
+ // Copy xctestrun directly
186
+ fs_1.default.copyFileSync(bundledXctestrun, path_1.default.join(IOS_DRIVER_CACHE, 'conductor-driver-ios-config.xctestrun'));
187
+ // Unzip the two .app bundles
188
+ const appsDir = path_1.default.join(IOS_DRIVER_CACHE, 'Debug-iphonesimulator');
189
+ await spawnAndWait('unzip', ['-q', '-o', bundledDriverZip, '-d', appsDir]);
190
+ await spawnAndWait('unzip', ['-q', '-o', bundledRunnerZip, '-d', appsDir]);
191
+ fs_1.default.writeFileSync(versionFile, xctestrunMtime);
192
+ (0, verbose_js_1.log)('iOS driver cache ready');
193
+ }
194
+ /** Returns true if the iOS simulator with the given UDID is in the Booted state. */
195
+ async function isSimulatorBooted(deviceId) {
196
+ try {
197
+ const out = await spawnCapture('xcrun', ['simctl', 'list', 'devices', 'booted', '--json']);
198
+ const parsed = JSON.parse(out);
199
+ return Object.values(parsed.devices).some((sims) => sims.some((s) => s.udid === deviceId));
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ /** Check if something is listening on the given TCP port. */
206
+ function isPortOpen(port, host = '127.0.0.1') {
207
+ return new Promise((resolve) => {
208
+ const sock = net_1.default.createConnection({ host, port });
209
+ sock.setTimeout(500);
210
+ sock.on('connect', () => {
211
+ sock.destroy();
212
+ resolve(true);
213
+ });
214
+ sock.on('error', () => resolve(false));
215
+ sock.on('timeout', () => {
216
+ sock.destroy();
217
+ resolve(false);
218
+ });
219
+ });
220
+ }
221
+ /**
222
+ * Start the iOS XCTest driver via `xcodebuild test-without-building`.
223
+ *
224
+ * Unlike `xcrun simctl launch`, xcodebuild runs the XCTest runner as a
225
+ * background test process — no app appears in the simulator foreground.
226
+ * It also installs both driver apps silently via DependentProductPaths.
227
+ *
228
+ * The port is injected into the xctestrun EnvironmentVariables with plutil.
229
+ */
230
+ async function startIOSDriver(deviceId, port = IOS_BASE_PORT) {
231
+ if (await isPortOpen(port)) {
232
+ (0, verbose_js_1.log)(`iOS driver already running on port ${port}`);
233
+ return;
234
+ }
235
+ (0, verbose_js_1.log)(`Starting iOS XCTest driver for device ${deviceId} on port ${port}`);
236
+ await setupIOSDriverCache();
237
+ const xctestrun = path_1.default.join(IOS_DRIVER_CACHE, 'conductor-driver-ios-config.xctestrun');
238
+ const proc = (0, child_process_1.spawn)('xcodebuild', ['test-without-building', '-xctestrun', xctestrun, '-destination', `id=${deviceId}`], {
239
+ detached: true,
240
+ stdio: ['ignore', 'ignore', 'ignore'],
241
+ env: { ...process.env, TEST_RUNNER_PORT: String(port) },
242
+ });
243
+ proc.unref();
244
+ const deadline = Date.now() + IOS_STARTUP_TIMEOUT_MS;
245
+ while (Date.now() < deadline) {
246
+ await (0, utils_js_1.sleep)(IOS_STARTUP_POLL_MS);
247
+ if (await isPortOpen(port)) {
248
+ (0, verbose_js_1.log)(`iOS driver ready on port ${port}`);
249
+ return;
250
+ }
251
+ }
252
+ throw new Error(`iOS XCTest driver did not start within ${IOS_STARTUP_TIMEOUT_MS / 1000}s on port ${port}.`);
253
+ }
254
+ /**
255
+ * Stop the iOS XCTest driver by terminating the runner app.
256
+ */
257
+ async function stopIOSDriver(deviceId) {
258
+ await spawnAndWait('xcrun', ['simctl', 'terminate', deviceId, IOS_RUNNER_BUNDLE_ID]);
259
+ }
260
+ // ── Android bootstrap ─────────────────────────────────────────────────────────
261
+ const ANDROID_STARTUP_TIMEOUT_MS = 30000;
262
+ const ANDROID_STARTUP_POLL_MS = 500;
263
+ const CONDUCTOR_INSTRUMENTATION_CLASS = 'dev.houwert.conductor.ConductorDriverService#grpcServer';
264
+ const CONDUCTOR_TEST_RUNNER = 'dev.houwert.conductor.test/androidx.test.runner.AndroidJUnitRunner';
265
+ /**
266
+ * Start the Android gRPC driver for the given device.
267
+ * Runs: adb -s <id> forward tcp:3763 tcp:3763 + adb shell am instrument
268
+ */
269
+ async function startAndroidDriver(deviceId, port = ANDROID_BASE_PORT) {
270
+ if (await isPortOpen(port)) {
271
+ (0, verbose_js_1.log)(`Android driver already running on port ${port}`);
272
+ return;
273
+ }
274
+ (0, verbose_js_1.log)(`Starting Android driver for device ${deviceId} on port ${port}`);
275
+ // Step 1: ADB port forward
276
+ await spawnAndWait('adb', ['-s', deviceId, 'forward', `tcp:${port}`, `tcp:${port}`]);
277
+ // Step 2: Get device API level to decide instrumentation flags
278
+ const apiResult = await spawnCapture('adb', [
279
+ '-s',
280
+ deviceId,
281
+ 'shell',
282
+ 'getprop ro.build.version.sdk',
283
+ ]);
284
+ const apiLevel = parseInt(apiResult.trim(), 10);
285
+ const mFlag = apiLevel >= 26 ? ['-m'] : [];
286
+ // Step 3: Start instrumentation in background (it blocks the shell, so detach)
287
+ const instrArgs = [
288
+ '-s',
289
+ deviceId,
290
+ 'shell',
291
+ 'am',
292
+ 'instrument',
293
+ '-w',
294
+ ...mFlag,
295
+ '-e',
296
+ 'debug',
297
+ 'false',
298
+ '-e',
299
+ 'class',
300
+ CONDUCTOR_INSTRUMENTATION_CLASS,
301
+ '-e',
302
+ 'port',
303
+ String(port),
304
+ CONDUCTOR_TEST_RUNNER,
305
+ ];
306
+ const proc = (0, child_process_1.spawn)('adb', instrArgs, {
307
+ detached: true,
308
+ stdio: ['ignore', 'ignore', 'ignore'],
309
+ });
310
+ proc.unref();
311
+ // Wait for gRPC port to open
312
+ const deadline = Date.now() + ANDROID_STARTUP_TIMEOUT_MS;
313
+ while (Date.now() < deadline) {
314
+ await (0, utils_js_1.sleep)(ANDROID_STARTUP_POLL_MS);
315
+ if (await isPortOpen(port)) {
316
+ (0, verbose_js_1.log)(`Android driver ready on port ${port}`);
317
+ return;
318
+ }
319
+ }
320
+ throw new Error(`Android driver did not start within ${ANDROID_STARTUP_TIMEOUT_MS / 1000}s on port ${port}.\n` +
321
+ `Make sure the Conductor driver APK is installed on device ${deviceId}.\n` +
322
+ `Try running: conductor install --device ${deviceId}`);
323
+ }
324
+ async function stopAndroidDriver(deviceId) {
325
+ await spawnAndWait('adb', [
326
+ '-s',
327
+ deviceId,
328
+ 'shell',
329
+ 'am',
330
+ 'force-stop',
331
+ 'dev.houwert.conductor',
332
+ ]).catch(() => { });
333
+ await spawnAndWait('adb', ['-s', deviceId, 'forward', '--remove', 'tcp:3763']).catch(() => { });
334
+ }
335
+ /**
336
+ * Uninstall the Conductor driver app(s) from the device.
337
+ */
338
+ async function uninstallDriver(deviceId, platform) {
339
+ (0, verbose_js_1.log)(`uninstallDriver: removing ${platform} driver from ${deviceId}`);
340
+ if (platform === 'ios') {
341
+ await spawnAndWait('xcrun', [
342
+ 'simctl',
343
+ 'uninstall',
344
+ deviceId,
345
+ 'dev.houwert.conductor-driver-iosUITests.xctrunner',
346
+ ]).catch(() => { });
347
+ }
348
+ else {
349
+ await spawnAndWait('adb', ['-s', deviceId, 'uninstall', 'dev.houwert.conductor']).catch(() => { });
350
+ await spawnAndWait('adb', ['-s', deviceId, 'uninstall', 'dev.houwert.conductor.test']).catch(() => { });
351
+ }
352
+ }
353
+ // ── Helpers ───────────────────────────────────────────────────────────────────
354
+ function spawnAndWait(cmd, args) {
355
+ return new Promise((resolve, reject) => {
356
+ const proc = (0, child_process_1.spawn)(cmd, args, { stdio: 'ignore' });
357
+ proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`)));
358
+ proc.on('error', reject);
359
+ });
360
+ }
361
+ function spawnCapture(cmd, args) {
362
+ return new Promise((resolve, reject) => {
363
+ const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
364
+ let out = '';
365
+ proc.stdout?.on('data', (chunk) => {
366
+ out += chunk.toString();
367
+ });
368
+ proc.on('close', (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} failed (${code})`)));
369
+ proc.on('error', reject);
370
+ });
371
+ }