@houwert/conductor 0.7.1 → 0.8.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.
@@ -1,12 +1,19 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.deleteDevice = deleteDevice;
5
8
  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`;
9
+ --platform <ios|tvos|android|web> Scope to a single platform
10
+ --all Delete all shutdown simulators / non-running AVDs / web sessions`;
11
+ const fs_1 = __importDefault(require("fs"));
8
12
  const runner_js_1 = require("../runner.js");
9
13
  const output_js_1 = require("../output.js");
14
+ const client_js_1 = require("../daemon/client.js");
15
+ const protocol_js_1 = require("../daemon/protocol.js");
16
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
10
17
  async function listSimulators() {
11
18
  const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'list', 'devices', '--json']);
12
19
  if (!result.success)
@@ -89,6 +96,7 @@ async function deleteDevice(nameOrId, opts, flags) {
89
96
  const includeIOS = !platform || platform === 'ios';
90
97
  const includeTvOS = !platform || platform === 'tvos';
91
98
  const includeAndroid = !platform || platform === 'android';
99
+ const includeWeb = !platform || platform === 'web';
92
100
  const deleted = [];
93
101
  // ── --all mode ───────────────────────────────────────────────────────────
94
102
  if (flags.all) {
@@ -135,6 +143,26 @@ async function deleteDevice(nameOrId, opts, flags) {
135
143
  }
136
144
  }
137
145
  }
146
+ // Web: stop all running web daemon sessions
147
+ if (includeWeb) {
148
+ const sessions = (0, client_js_1.listDaemonSessions)();
149
+ for (const session of sessions) {
150
+ if (!(session === 'web' || session.startsWith('web:')))
151
+ continue;
152
+ const status = await (0, client_js_1.daemonStatus)(session);
153
+ if (!status.running)
154
+ continue;
155
+ try {
156
+ await (0, client_js_1.stopDaemon)(session);
157
+ const browser = (0, bootstrap_js_1.webBrowserName)(session);
158
+ const label = browser.charAt(0).toUpperCase() + browser.slice(1);
159
+ deleted.push({ id: session, name: label, platform: 'web' });
160
+ }
161
+ catch (e) {
162
+ (0, output_js_1.printError)(`Failed to stop web session ${session}: ${e instanceof Error ? e.message : String(e)}`, opts);
163
+ }
164
+ }
165
+ }
138
166
  if (deleted.length === 0) {
139
167
  (0, output_js_1.printError)('No devices to delete.', opts);
140
168
  return 1;
@@ -217,6 +245,40 @@ async function deleteDevice(nameOrId, opts, flags) {
217
245
  return 0;
218
246
  }
219
247
  }
248
+ // Try Web session (match by session ID or persisted name)
249
+ if (includeWeb && nameOrId) {
250
+ const sessions = (0, client_js_1.listDaemonSessions)();
251
+ const match = sessions.find((s) => {
252
+ if (!(s === 'web' || s.startsWith('web:')))
253
+ return false;
254
+ if (s === nameOrId)
255
+ return true;
256
+ try {
257
+ return fs_1.default.readFileSync((0, protocol_js_1.nameFile)(s), 'utf-8').trim() === nameOrId;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ });
263
+ if (match) {
264
+ try {
265
+ await (0, client_js_1.stopDaemon)(match);
266
+ }
267
+ catch (e) {
268
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
269
+ return 1;
270
+ }
271
+ const browser = (0, bootstrap_js_1.webBrowserName)(match);
272
+ const label = browser.charAt(0).toUpperCase() + browser.slice(1);
273
+ if (opts.json) {
274
+ (0, output_js_1.printData)({ status: 'ok', deleted: [{ id: match, name: label, platform: 'web' }] }, opts);
275
+ }
276
+ else {
277
+ (0, output_js_1.printSuccess)(`Stopped web session: ${label} (${match})`, opts);
278
+ }
279
+ return 0;
280
+ }
281
+ }
220
282
  (0, output_js_1.printError)(`Device "${nameOrId}" not found.`, opts);
221
283
  return 1;
222
284
  }
@@ -28,6 +28,10 @@ async function downloadApp(appId, output, opts = {}, sessionName = 'default') {
28
28
  return 1;
29
29
  }
30
30
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
31
+ if (platform === 'web') {
32
+ (0, output_js_1.printError)('download-app is not supported on web.', opts);
33
+ return 1;
34
+ }
31
35
  if (platform === 'ios' || platform === 'tvos') {
32
36
  // Get the .app bundle path from the simulator
33
37
  const getPath = await (0, runner_js_1.spawnCommand)('xcrun', [
@@ -24,7 +24,11 @@ async function installApp(appPath, opts = {}, sessionName = 'default') {
24
24
  return 1;
25
25
  }
26
26
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
27
- if (platform === 'ios' || platform === 'tvos') {
27
+ if (platform === 'web') {
28
+ (0, output_js_1.printError)('install-app is not supported on web. Use launch-app with a URL instead.', opts);
29
+ return 1;
30
+ }
31
+ else if (platform === 'ios' || platform === 'tvos') {
28
32
  const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'install', deviceId, appPath]);
29
33
  if (!result.success) {
30
34
  (0, output_js_1.printError)(`install-app failed: ${result.stderr}`, opts);
@@ -21,7 +21,11 @@ async function listApps(opts = {}, sessionName = 'default') {
21
21
  }
22
22
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
23
23
  let appIds;
24
- if (platform === 'ios' || platform === 'tvos') {
24
+ if (platform === 'web') {
25
+ (0, output_js_1.printError)('list-apps is not supported on web. Use foreground-app to get the current URL.', opts);
26
+ return 1;
27
+ }
28
+ else if (platform === 'ios' || platform === 'tvos') {
25
29
  const result = await (0, runner_js_1.spawnCommand)('bash', [
26
30
  '-c',
27
31
  `xcrun simctl listapps ${deviceId} | plutil -convert json - -o -`,
@@ -1,14 +1,19 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.discoverBootedDevices = discoverBootedDevices;
5
8
  exports.discoverAvailableDevices = discoverAvailableDevices;
6
9
  exports.listDevices = listDevices;
7
10
  exports.HELP = ` list-devices List booted and available devices/simulators`;
11
+ const fs_1 = __importDefault(require("fs"));
8
12
  const runner_js_1 = require("../runner.js");
9
13
  const output_js_1 = require("../output.js");
10
14
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
11
15
  const client_js_1 = require("../daemon/client.js");
16
+ const protocol_js_1 = require("../daemon/protocol.js");
12
17
  async function discoverBootedDevices() {
13
18
  const devices = [];
14
19
  // Try adb devices (Android)
@@ -58,9 +63,15 @@ async function discoverBootedDevices() {
58
63
  const status = await (0, client_js_1.daemonStatus)(session);
59
64
  if (status.running) {
60
65
  const browser = (0, bootstrap_js_1.webBrowserName)(session);
61
- const parts = session.split(':');
62
66
  const label = browser.charAt(0).toUpperCase() + browser.slice(1);
63
- const name = parts.length > 2 ? `${label} (${parts[2]})` : label;
67
+ let name;
68
+ try {
69
+ const saved = fs_1.default.readFileSync((0, protocol_js_1.nameFile)(session), 'utf-8').trim();
70
+ name = saved || label;
71
+ }
72
+ catch {
73
+ name = label;
74
+ }
64
75
  devices.push({
65
76
  id: session,
66
77
  name,
@@ -106,6 +117,19 @@ async function discoverAvailableDevices() {
106
117
  }
107
118
  }
108
119
  }
120
+ // Web: installed Playwright browsers that aren't currently running
121
+ const runningSessions = (0, client_js_1.listDaemonSessions)();
122
+ const runningBrowsers = new Set(runningSessions.filter((s) => s === 'web' || s.startsWith('web:')).map((s) => (0, bootstrap_js_1.webBrowserName)(s)));
123
+ for (const browser of ['chromium', 'firefox', 'webkit']) {
124
+ if ((0, bootstrap_js_1.isPlaywrightBrowserInstalled)(browser)) {
125
+ devices.push({
126
+ id: browser === 'chromium' ? 'web' : `web:${browser}`,
127
+ name: browser.charAt(0).toUpperCase() + browser.slice(1),
128
+ platform: 'web',
129
+ status: runningBrowsers.has(browser) ? 'installed' : 'available',
130
+ });
131
+ }
132
+ }
109
133
  return devices;
110
134
  }
111
135
  async function listDevices(opts) {
@@ -113,20 +137,12 @@ async function listDevices(opts) {
113
137
  discoverBootedDevices(),
114
138
  discoverAvailableDevices(),
115
139
  ]);
116
- // Detect installed Playwright browsers for web support
117
- const webBrowsers = ['chromium', 'firefox', 'webkit'].filter((b) => (0, bootstrap_js_1.isPlaywrightBrowserInstalled)(b));
118
- if (devices.length === 0 && availableDevices.length === 0 && webBrowsers.length === 0) {
140
+ if (devices.length === 0 && availableDevices.length === 0) {
119
141
  (0, output_js_1.printError)('No devices found. Start an emulator or simulator first.', opts);
120
142
  return 1;
121
143
  }
122
144
  if (opts.json) {
123
- const webDevices = webBrowsers.map((b) => ({
124
- id: b === 'chromium' ? 'web' : `web:${b}`,
125
- name: b.charAt(0).toUpperCase() + b.slice(1),
126
- platform: 'web',
127
- status: 'available',
128
- }));
129
- (0, output_js_1.printData)({ status: 'ok', devices, availableDevices: [...availableDevices, ...webDevices] }, opts);
145
+ (0, output_js_1.printData)({ status: 'ok', devices, availableDevices }, opts);
130
146
  }
131
147
  else {
132
148
  if (devices.length > 0) {
@@ -148,14 +164,6 @@ async function listDevices(opts) {
148
164
  else {
149
165
  console.log('No available devices.');
150
166
  }
151
- if (webBrowsers.length > 0) {
152
- console.log('');
153
- console.log('Web browsers:');
154
- for (const b of webBrowsers) {
155
- const deviceId = b === 'chromium' ? 'web' : `web:${b}`;
156
- console.log(` web available ${deviceId.padEnd(16)} ${b.charAt(0).toUpperCase() + b.slice(1)}`);
157
- }
158
- }
159
167
  }
160
168
  return 0;
161
169
  }
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.startDevice = startDevice;
@@ -6,12 +9,15 @@ exports.HELP = ` start-device
6
9
  --platform <ios|android|tvos|web> Boot a simulator/emulator, or start the web driver (Playwright)
7
10
  --os-version <n> iOS/tvOS version (e.g. 18) or Android API level (e.g. 33)
8
11
  --avd <name> Android AVD name (default: first available)
9
- --name <name> Set a custom name on the simulator after boot (iOS/tvOS only)
12
+ --name <name> Set a custom name on the device after creation (iOS/tvOS/web)
10
13
  --device-type <name> iOS/tvOS device type (e.g. "iPhone 16 Pro", "Apple TV 4K"); creates if needed
11
14
  --browser <chromium|firefox|webkit> Web only: which Playwright browser to launch (default: chromium)`;
15
+ const fs_1 = __importDefault(require("fs"));
12
16
  const child_process_1 = require("child_process");
13
17
  const runner_js_1 = require("../runner.js");
14
18
  const client_js_1 = require("../daemon/client.js");
19
+ const protocol_js_1 = require("../daemon/protocol.js");
20
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
15
21
  const output_js_1 = require("../output.js");
16
22
  const utils_js_1 = require("../utils.js");
17
23
  const IOS_BOOT_TIMEOUT_MS = 120000;
@@ -462,34 +468,50 @@ async function startAndroid(avdName, opts) {
462
468
  (0, output_js_1.printSuccess)(`Emulator ready: ${target} (${deviceId})`, opts);
463
469
  return 0;
464
470
  }
465
- function webSessionIdForBrowser(browserArg) {
471
+ function resolveWebBrowser(browserArg) {
466
472
  const b = (browserArg ?? 'chromium').toLowerCase();
467
473
  switch (b) {
468
474
  case 'chromium':
469
- return { session: 'web' };
470
475
  case 'firefox':
471
- return { session: 'web:firefox' };
472
476
  case 'webkit':
473
- return { session: 'web:webkit' };
477
+ return { browser: b };
474
478
  default:
475
479
  return {
476
480
  error: `Unknown web browser "${browserArg}". Use chromium, firefox, or webkit.`,
477
481
  };
478
482
  }
479
483
  }
480
- async function startWebDriver(opts, browser) {
481
- const resolved = webSessionIdForBrowser(browser);
484
+ async function startWebDriver(opts, browser, name) {
485
+ const resolved = resolveWebBrowser(browser);
482
486
  if ('error' in resolved) {
483
487
  (0, output_js_1.printError)(resolved.error, opts);
484
488
  return 1;
485
489
  }
486
- const ready = await (0, client_js_1.startDaemon)(resolved.session);
490
+ // Check for an existing running session of this browser type
491
+ const existing = await (0, client_js_1.findRunningWebSession)(resolved.browser);
492
+ if (existing && !name) {
493
+ (0, output_js_1.printSuccess)(`Web driver already running: ${existing}`, opts);
494
+ return 0;
495
+ }
496
+ // Generate a unique session ID for this instance
497
+ const session = (0, bootstrap_js_1.generateWebSessionId)(resolved.browser);
498
+ const ready = await (0, client_js_1.startDaemon)(session);
487
499
  if (!ready) {
488
- (0, output_js_1.printError)(`Web driver did not become ready for session ${resolved.session}. ` +
500
+ (0, output_js_1.printError)(`Web driver did not become ready for session ${session}. ` +
489
501
  'Install a browser with `conductor install-web` if needed, then retry.', opts);
490
502
  return 1;
491
503
  }
492
- (0, output_js_1.printSuccess)(`Web driver ready (${resolved.session})`, opts);
504
+ if (name) {
505
+ try {
506
+ fs_1.default.writeFileSync((0, protocol_js_1.nameFile)(session), name, 'utf-8');
507
+ }
508
+ catch {
509
+ /* best-effort — display name won't persist but the session works */
510
+ }
511
+ }
512
+ const label = resolved.browser.charAt(0).toUpperCase() + resolved.browser.slice(1);
513
+ const displayName = name ? `${name} (${label})` : label;
514
+ (0, output_js_1.printSuccess)(`Web driver ready: ${displayName} (${session})`, opts);
493
515
  return 0;
494
516
  }
495
517
  // ── Entry point ───────────────────────────────────────────────────────────────
@@ -506,7 +528,7 @@ async function startDevice(platform, opts, flags) {
506
528
  case 'android':
507
529
  return startAndroid(flags.avd, opts);
508
530
  case 'web':
509
- return startWebDriver(opts, flags.browser);
531
+ return startWebDriver(opts, flags.browser, flags.name);
510
532
  default:
511
533
  (0, output_js_1.printError)(`Unknown platform "${platform}". Use ios, android, tvos, or web.`, opts);
512
534
  return 1;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.stopDevice = stopDevice;
5
+ exports.HELP = ` stop-device [<name-or-id>]
6
+ --platform <ios|tvos|android|web> Scope to a single platform
7
+ --all Stop all booted simulators / running emulators / web sessions`;
8
+ const runner_js_1 = require("../runner.js");
9
+ const output_js_1 = require("../output.js");
10
+ const client_js_1 = require("../daemon/client.js");
11
+ const list_devices_js_1 = require("./list-devices.js");
12
+ // ── iOS / tvOS ───────────────────────────────────────────────────────────────
13
+ async function shutdownSimulator(udid) {
14
+ const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'shutdown', udid]);
15
+ if (!result.success && !result.stderr.includes('current state: Shutdown')) {
16
+ throw new Error(`Failed to shutdown simulator: ${result.stderr.trim()}`);
17
+ }
18
+ }
19
+ // ── Android ──────────────────────────────────────────────────────────────────
20
+ async function killEmulator(serial) {
21
+ const result = await (0, runner_js_1.spawnCommand)('adb', ['-s', serial, 'emu', 'kill']);
22
+ if (!result.success) {
23
+ throw new Error(`Failed to kill emulator ${serial}: ${result.stderr.trim()}`);
24
+ }
25
+ }
26
+ // ── Entry point ──────────────────────────────────────────────────────────────
27
+ async function stopDevice(nameOrId, opts, flags) {
28
+ if (!nameOrId && !flags.all) {
29
+ (0, output_js_1.printError)('stop-device requires a device name/ID, or --all', opts);
30
+ return 1;
31
+ }
32
+ const platform = flags.platform?.toLowerCase();
33
+ const includeIOS = !platform || platform === 'ios';
34
+ const includeTvOS = !platform || platform === 'tvos';
35
+ const includeAndroid = !platform || platform === 'android';
36
+ const includeWeb = !platform || platform === 'web';
37
+ const stopped = [];
38
+ // ── --all mode ───────────────────────────────────────────────────────────
39
+ if (flags.all) {
40
+ const devices = await (0, list_devices_js_1.discoverBootedDevices)();
41
+ for (const d of devices) {
42
+ if (d.platform === 'ios' && !includeIOS)
43
+ continue;
44
+ if (d.platform === 'tvos' && !includeTvOS)
45
+ continue;
46
+ if (d.platform === 'android' && !includeAndroid)
47
+ continue;
48
+ if (d.platform === 'web' && !includeWeb)
49
+ continue;
50
+ try {
51
+ if (d.platform === 'ios' || d.platform === 'tvos') {
52
+ await shutdownSimulator(d.id);
53
+ }
54
+ else if (d.platform === 'android') {
55
+ await killEmulator(d.id);
56
+ }
57
+ else if (d.platform === 'web') {
58
+ await (0, client_js_1.stopDaemon)(d.id);
59
+ }
60
+ stopped.push({ id: d.id, name: d.name, platform: d.platform });
61
+ }
62
+ catch (e) {
63
+ (0, output_js_1.printError)(`Failed to stop ${d.name} (${d.id}): ${e instanceof Error ? e.message : String(e)}`, opts);
64
+ }
65
+ }
66
+ if (stopped.length === 0) {
67
+ (0, output_js_1.printError)('No running devices to stop.', opts);
68
+ return 1;
69
+ }
70
+ if (opts.json) {
71
+ (0, output_js_1.printData)({ status: 'ok', stopped }, opts);
72
+ }
73
+ else {
74
+ for (const d of stopped) {
75
+ (0, output_js_1.printSuccess)(`Stopped ${d.platform} device: ${d.name} (${d.id})`, opts);
76
+ }
77
+ }
78
+ return 0;
79
+ }
80
+ // ── Single device mode ─────────────────────────────────────────────────
81
+ const devices = await (0, list_devices_js_1.discoverBootedDevices)();
82
+ const match = devices.find((d) => {
83
+ if (platform && d.platform !== platform)
84
+ return false;
85
+ return d.id === nameOrId || d.name === nameOrId;
86
+ });
87
+ if (!match) {
88
+ (0, output_js_1.printError)(`No running device found matching "${nameOrId}".`, opts);
89
+ return 1;
90
+ }
91
+ try {
92
+ if (match.platform === 'ios' || match.platform === 'tvos') {
93
+ await shutdownSimulator(match.id);
94
+ }
95
+ else if (match.platform === 'android') {
96
+ await killEmulator(match.id);
97
+ }
98
+ else if (match.platform === 'web') {
99
+ await (0, client_js_1.stopDaemon)(match.id);
100
+ }
101
+ }
102
+ catch (e) {
103
+ (0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
104
+ return 1;
105
+ }
106
+ if (opts.json) {
107
+ (0, output_js_1.printData)({ status: 'ok', stopped: [{ id: match.id, name: match.name, platform: match.platform }] }, opts);
108
+ }
109
+ else {
110
+ (0, output_js_1.printSuccess)(`Stopped ${match.platform} device: ${match.name} (${match.id})`, opts);
111
+ }
112
+ return 0;
113
+ }
@@ -141,7 +141,12 @@ async function stopDaemon(sessionName = 'default') {
141
141
  }
142
142
  // Clean up the daemon directory regardless — removes stale dirs from crashed daemons
143
143
  const dir = path_1.default.join(os_1.default.homedir(), '.conductor', 'daemons', sessionName);
144
- for (const file of [(0, protocol_js_1.socketPath)(sessionName), (0, protocol_js_1.pidFile)(sessionName), (0, protocol_js_1.logFile)(sessionName)]) {
144
+ for (const file of [
145
+ (0, protocol_js_1.socketPath)(sessionName),
146
+ (0, protocol_js_1.pidFile)(sessionName),
147
+ (0, protocol_js_1.logFile)(sessionName),
148
+ (0, protocol_js_1.nameFile)(sessionName),
149
+ ]) {
145
150
  try {
146
151
  fs_1.default.unlinkSync(file);
147
152
  }
@@ -7,6 +7,7 @@ exports.IDLE_TIMEOUT_MS = void 0;
7
7
  exports.socketPath = socketPath;
8
8
  exports.pidFile = pidFile;
9
9
  exports.logFile = logFile;
10
+ exports.nameFile = nameFile;
10
11
  const os_1 = __importDefault(require("os"));
11
12
  const path_1 = __importDefault(require("path"));
12
13
  const DIR = path_1.default.join(os_1.default.homedir(), '.conductor');
@@ -22,4 +23,7 @@ function pidFile(sessionName = 'default') {
22
23
  function logFile(sessionName = 'default') {
23
24
  return path_1.default.join(daemonDir(sessionName), 'daemon.log');
24
25
  }
26
+ function nameFile(sessionName = 'default') {
27
+ return path_1.default.join(daemonDir(sessionName), 'name');
28
+ }
25
29
  exports.IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -60,6 +60,7 @@ let logCollector = null;
60
60
  const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
61
61
  let _restartInProgress = false;
62
62
  let _driverStarted = false;
63
+ let _driverStartError = null;
63
64
  async function ensureDriverRunning() {
64
65
  if (_restartInProgress || !_driverStarted)
65
66
  return;
@@ -235,6 +236,9 @@ async function main() {
235
236
  driverPort,
236
237
  cdpUrl: cdpUrl ?? null,
237
238
  cdpTargetId: cdpTargetId ?? null,
239
+ chromiumCdpPort: driverPlatform === 'web' ? (0, web_server_js_1.getCdpPort)() : null,
240
+ pageTargetId: driverPlatform === 'web' ? (0, web_server_js_1.getPageTargetId)() : null,
241
+ driverStartError: _driverStartError,
238
242
  });
239
243
  return;
240
244
  }
@@ -325,7 +329,8 @@ async function main() {
325
329
  dlog(`Driver started successfully`);
326
330
  }
327
331
  catch (err) {
328
- dlog(`Driver startup error: ${err instanceof Error ? err.message : String(err)}`);
332
+ _driverStartError = err instanceof Error ? err.message : String(err);
333
+ dlog(`Driver startup error: ${_driverStartError}`);
329
334
  }
330
335
  }
331
336
  // Start collecting logs once the driver is (or was already) running.
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseAriaSnapshot = parseAriaSnapshot;
7
7
  exports.startWebServer = startWebServer;
8
8
  exports.stopWebServer = stopWebServer;
9
+ exports.getCdpPort = getCdpPort;
10
+ exports.getPageTargetId = getPageTargetId;
9
11
  /**
10
12
  * Daemon-embedded HTTP server wrapping Playwright for web browser control.
11
13
  *
@@ -17,6 +19,7 @@ exports.stopWebServer = stopWebServer;
17
19
  * happen here so the CLI remains a thin HTTP client.
18
20
  */
19
21
  const http_1 = __importDefault(require("http"));
22
+ const net_1 = require("net");
20
23
  const url_1 = __importDefault(require("url"));
21
24
  const playwright_core_1 = require("playwright-core");
22
25
  const MAX_CONSOLE_BUFFER = 1000;
@@ -432,6 +435,34 @@ let _server = null;
432
435
  * — we only disconnect.
433
436
  */
434
437
  let _cdpMode = false;
438
+ let _cdpPort = 0;
439
+ let _pageTargetId = null;
440
+ /** Find a free TCP port by briefly binding to port 0. */
441
+ function findFreePort() {
442
+ return new Promise((resolve, reject) => {
443
+ const srv = (0, net_1.createServer)();
444
+ srv.listen(0, '127.0.0.1', () => {
445
+ const addr = srv.address();
446
+ const port = typeof addr === 'object' ? (addr?.port ?? 0) : 0;
447
+ srv.close(() => resolve(port));
448
+ });
449
+ srv.on('error', reject);
450
+ });
451
+ }
452
+ /** Resolve the CDP target ID of the current page via a temporary CDP session. */
453
+ async function resolvePageTargetId() {
454
+ if (!_context || !_page)
455
+ return null;
456
+ try {
457
+ const cdpSession = await _context.newCDPSession(_page);
458
+ const info = (await cdpSession.send('Target.getTargetInfo'));
459
+ await cdpSession.detach();
460
+ return info.targetInfo?.targetId ?? null;
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
435
466
  async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl, cdpTargetId) {
436
467
  if (cdpUrl) {
437
468
  // ── CDP mode: attach to an existing browser (e.g. Electron webview) ───
@@ -514,17 +545,25 @@ async function startWebServer(port, browserName = 'chromium', dlog = () => { },
514
545
  else {
515
546
  // ── Standalone mode: launch a fresh browser ───────────────────────────
516
547
  const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
517
- dlog(`Launching ${browserName} browser...`);
548
+ if (browserName === 'chromium') {
549
+ _cdpPort = await findFreePort();
550
+ dlog(`Allocated CDP port ${_cdpPort} for remote debugging`);
551
+ }
552
+ const headless = process.env.CONDUCTOR_HEADLESS === '1' || process.env.CONDUCTOR_HEADLESS === 'true';
553
+ dlog(`Launching ${browserName} browser (headless=${headless})...`);
518
554
  _browser = await browserType.launch({
519
- headless: false,
520
- args: browserName === 'chromium' ? ['--disable-search-engine-choice-screen'] : undefined,
555
+ headless,
556
+ args: browserName === 'chromium'
557
+ ? [`--remote-debugging-port=${_cdpPort}`, '--disable-search-engine-choice-screen']
558
+ : undefined,
521
559
  });
522
560
  _context = await _browser.newContext({
523
561
  viewport: DEFAULT_VIEWPORT,
524
562
  });
525
563
  _page = await _context.newPage();
526
564
  attachConsoleListeners(_page);
527
- dlog(`Browser ready, page created`);
565
+ _pageTargetId = await resolvePageTargetId();
566
+ dlog(`Browser ready, page created (targetId=${_pageTargetId ?? 'unknown'})`);
528
567
  _cdpMode = false;
529
568
  }
530
569
  _server = http_1.default.createServer(async (req, res) => {
@@ -582,6 +621,32 @@ async function stopWebServer() {
582
621
  }
583
622
  }
584
623
  _cdpMode = false;
624
+ _cdpPort = 0;
625
+ _pageTargetId = null;
626
+ }
627
+ /** Get the CDP remote debugging port (0 if not running or non-Chromium). */
628
+ function getCdpPort() {
629
+ return _cdpPort;
630
+ }
631
+ /** Get the CDP target ID for the current page (null if unavailable). */
632
+ function getPageTargetId() {
633
+ return _pageTargetId;
634
+ }
635
+ /** Query the page's navigation history via CDP to determine back/forward state. */
636
+ async function getNavState(page) {
637
+ try {
638
+ const session = await page.context().newCDPSession(page);
639
+ const { currentIndex, entries } = await session.send('Page.getNavigationHistory');
640
+ await session.detach().catch(() => { });
641
+ return {
642
+ url: page.url(),
643
+ canGoBack: currentIndex > 0,
644
+ canGoForward: currentIndex < entries.length - 1,
645
+ };
646
+ }
647
+ catch {
648
+ return { url: page.url(), canGoBack: false, canGoForward: false };
649
+ }
585
650
  }
586
651
  /** Playwright / CDP errors when the tab, context, or session died but our JS refs still exist. */
587
652
  function isClosedLikeError(err) {
@@ -858,26 +923,29 @@ async function handleRequest(req, res, dlog) {
858
923
  return;
859
924
  }
860
925
  await gotoWithRecovery(targetUrl, dlog);
861
- jsonResponse(res, { ok: true });
926
+ const navState = await getNavState(await getPage(dlog));
927
+ jsonResponse(res, { ok: true, ...navState });
862
928
  return;
863
929
  }
864
930
  case '/goBack': {
865
- await (await getPage(dlog))
866
- .goBack({ waitUntil: 'domcontentloaded', timeout: 10000 })
867
- .catch(() => { });
868
- jsonResponse(res, { ok: true });
931
+ const p = await getPage(dlog);
932
+ await p.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => { });
933
+ const navState = await getNavState(p);
934
+ jsonResponse(res, { ok: true, ...navState });
869
935
  return;
870
936
  }
871
937
  case '/goForward': {
872
- await (await getPage(dlog))
873
- .goForward({ waitUntil: 'domcontentloaded', timeout: 10000 })
874
- .catch(() => { });
875
- jsonResponse(res, { ok: true });
938
+ const p = await getPage(dlog);
939
+ await p.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => { });
940
+ const navState = await getNavState(p);
941
+ jsonResponse(res, { ok: true, ...navState });
876
942
  return;
877
943
  }
878
944
  case '/reload': {
879
- await (await getPage(dlog)).reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
880
- jsonResponse(res, { ok: true });
945
+ const p = await getPage(dlog);
946
+ await p.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
947
+ const navState = await getNavState(p);
948
+ jsonResponse(res, { ok: true, ...navState });
881
949
  return;
882
950
  }
883
951
  case '/clearCookies': {
@@ -920,6 +988,53 @@ async function handleRequest(req, res, dlog) {
920
988
  jsonResponse(res, { ok: true });
921
989
  return;
922
990
  }
991
+ case '/setViewport': {
992
+ if (_cdpMode) {
993
+ jsonResponse(res, { error: 'setViewport not supported in CDP mode' }, 400);
994
+ return;
995
+ }
996
+ if (!_browser) {
997
+ jsonResponse(res, { error: 'No browser running' }, 400);
998
+ return;
999
+ }
1000
+ const vpWidth = body['width'] ?? DEFAULT_VIEWPORT.width;
1001
+ const vpHeight = body['height'] ?? DEFAULT_VIEWPORT.height;
1002
+ const vpUserAgent = body['userAgent'];
1003
+ const vpIsMobile = body['isMobile'] ?? false;
1004
+ const vpScaleFactor = body['deviceScaleFactor'] ?? (vpIsMobile ? 2 : 1);
1005
+ const vpColorScheme = body['colorScheme'];
1006
+ const savedUrl = _page ? _page.url() : '';
1007
+ if (_page)
1008
+ await _page.close().catch(() => { });
1009
+ if (_context)
1010
+ await _context.close().catch(() => { });
1011
+ _context = await _browser.newContext({
1012
+ viewport: { width: vpWidth, height: vpHeight },
1013
+ userAgent: vpUserAgent,
1014
+ deviceScaleFactor: vpScaleFactor,
1015
+ isMobile: vpIsMobile,
1016
+ hasTouch: vpIsMobile,
1017
+ colorScheme: vpColorScheme,
1018
+ });
1019
+ _page = await _context.newPage();
1020
+ attachConsoleListeners(_page);
1021
+ _pageTargetId = await resolvePageTargetId();
1022
+ if (savedUrl && savedUrl !== 'about:blank') {
1023
+ await gotoWithRecovery(savedUrl, dlog);
1024
+ }
1025
+ jsonResponse(res, { ok: true, cdpTargetId: _pageTargetId });
1026
+ return;
1027
+ }
1028
+ case '/setColorScheme': {
1029
+ const scheme = body['colorScheme'];
1030
+ if (scheme !== 'dark' && scheme !== 'light') {
1031
+ jsonResponse(res, { error: 'colorScheme must be "dark" or "light"' }, 400);
1032
+ return;
1033
+ }
1034
+ await (await getPage(dlog)).emulateMedia({ colorScheme: scheme });
1035
+ jsonResponse(res, { ok: true });
1036
+ return;
1037
+ }
923
1038
  case '/shutdown': {
924
1039
  jsonResponse(res, { ok: true });
925
1040
  // Graceful shutdown after response is sent
@@ -608,7 +608,12 @@ async function stopWebDriver(port) {
608
608
  */
609
609
  async function uninstallDriver(deviceId, platform) {
610
610
  (0, verbose_js_1.log)(`uninstallDriver: removing ${platform} driver from ${deviceId}`);
611
- if (platform === 'ios') {
611
+ if (platform === 'web') {
612
+ // Web has no persistent driver to uninstall — the browser is managed by the daemon.
613
+ // Stopping the web server is handled by the daemon cleanup.
614
+ return;
615
+ }
616
+ else if (platform === 'ios') {
612
617
  await spawnAndWait('xcrun', [
613
618
  'simctl',
614
619
  'uninstall',
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ const scroll_until_visible_js_1 = require("./commands/scroll-until-visible.js");
42
42
  const set_location_js_1 = require("./commands/set-location.js");
43
43
  const set_orientation_js_1 = require("./commands/set-orientation.js");
44
44
  const start_device_js_1 = require("./commands/start-device.js");
45
+ const stop_device_js_1 = require("./commands/stop-device.js");
45
46
  const delete_device_js_1 = require("./commands/delete-device.js");
46
47
  const logs_js_1 = require("./commands/logs.js");
47
48
  const device_picker_js_1 = require("./device-picker.js");
@@ -51,6 +52,7 @@ const fs_1 = __importDefault(require("fs"));
51
52
  const path_1 = __importDefault(require("path"));
52
53
  const COMMAND_HELP = {
53
54
  'start-device': start_device_js_1.HELP,
55
+ 'stop-device': stop_device_js_1.HELP,
54
56
  'delete-device': delete_device_js_1.HELP,
55
57
  'list-devices': list_devices_js_1.HELP,
56
58
  'foreground-app': foreground_app_js_1.HELP,
@@ -187,6 +189,7 @@ async function main() {
187
189
  const NO_DEVICE_COMMANDS = new Set([
188
190
  'list-devices',
189
191
  'start-device',
192
+ 'stop-device',
190
193
  'delete-device',
191
194
  'cheat-sheet',
192
195
  'install-plugin',
@@ -244,6 +247,12 @@ async function main() {
244
247
  case 'list-devices':
245
248
  exitCode = await (0, list_devices_js_1.listDevices)(opts);
246
249
  break;
250
+ case 'stop-device':
251
+ exitCode = await (0, stop_device_js_1.stopDevice)(rest[0], opts, {
252
+ platform: argv['platform'],
253
+ all: argv['all'],
254
+ });
255
+ break;
247
256
  case 'delete-device':
248
257
  exitCode = await (0, delete_device_js_1.deleteDevice)(rest[0], opts, {
249
258
  platform: argv['platform'],
package/dist/runner.js CHANGED
@@ -60,6 +60,17 @@ async function detectFirstDevice() {
60
60
  /* ignore */
61
61
  }
62
62
  }
63
+ // Web: check for running daemon web sessions
64
+ for (const session of (0, client_js_1.listDaemonSessions)()) {
65
+ if (!(session === 'web' || session.startsWith('web:')))
66
+ continue;
67
+ const status = await (0, client_js_1.daemonStatus)(session);
68
+ if (status.running) {
69
+ (0, verbose_js_1.log)(`detectFirstDevice: found running web session "${session}"`);
70
+ _cachedDeviceId = session;
71
+ return session;
72
+ }
73
+ }
63
74
  _cachedDeviceId = null;
64
75
  return undefined;
65
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor
3
- version: 0.7.1
3
+ version: 0.8.0
4
4
  description: "Token-efficient CLI for mobile UI testing (iOS simulator + Android emulator), designed for AI agents"
5
5
  metadata.openclaw:
6
6
  category: service
@@ -3,6 +3,6 @@ skills:
3
3
  path: conductor/SKILL.md
4
4
  description: "Token-efficient CLI for mobile UI testing, designed for AI agents"
5
5
  category: service
6
- version: 0.7.1
6
+ version: 0.8.0
7
7
  requires:
8
8
  bins: [conductor]