@houwert/conductor 0.7.0 → 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.
- package/dist/commands/delete-device.js +64 -2
- package/dist/commands/download-app.js +4 -0
- package/dist/commands/install-app.js +5 -1
- package/dist/commands/list-apps.js +5 -1
- package/dist/commands/list-devices.js +28 -20
- package/dist/commands/start-device.js +33 -11
- package/dist/commands/stop-device.js +113 -0
- package/dist/daemon/client.js +81 -8
- package/dist/daemon/protocol.js +4 -0
- package/dist/daemon/server.js +24 -4
- package/dist/daemon/web-server.js +197 -34
- package/dist/drivers/bootstrap.js +6 -1
- package/dist/index.js +9 -0
- package/dist/runner.js +11 -0
- 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 +1 -1
- package/skills/conductor/SKILL.md +1 -1
- package/skills/skills.yaml +1 -1
|
@@ -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>
|
|
7
|
-
--all
|
|
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 === '
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
(
|
|
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
|
+
}
|
package/dist/daemon/client.js
CHANGED
|
@@ -19,20 +19,36 @@ const verbose_js_1 = require("../verbose.js");
|
|
|
19
19
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
20
20
|
const STARTUP_POLL_MS = 200;
|
|
21
21
|
const STARTUP_MAX_WAIT_MS = 10000;
|
|
22
|
-
async function
|
|
22
|
+
async function fetchStatus(sessionName) {
|
|
23
23
|
return new Promise((resolve) => {
|
|
24
24
|
const req = http_1.default.get({ socketPath: (0, protocol_js_1.socketPath)(sessionName), path: '/status' }, (res) => {
|
|
25
|
-
res.
|
|
26
|
-
|
|
25
|
+
if (res.statusCode !== 200) {
|
|
26
|
+
res.resume();
|
|
27
|
+
resolve(null);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const chunks = [];
|
|
31
|
+
res.on('data', (c) => chunks.push(c));
|
|
32
|
+
res.on('end', () => {
|
|
33
|
+
try {
|
|
34
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
resolve(null);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
27
40
|
});
|
|
28
41
|
req.setTimeout(500);
|
|
29
42
|
req.on('timeout', () => {
|
|
30
43
|
req.destroy();
|
|
31
|
-
resolve(
|
|
44
|
+
resolve(null);
|
|
32
45
|
});
|
|
33
|
-
req.on('error', () => resolve(
|
|
46
|
+
req.on('error', () => resolve(null));
|
|
34
47
|
});
|
|
35
48
|
}
|
|
49
|
+
async function socketExists(sessionName) {
|
|
50
|
+
return (await fetchStatus(sessionName)) !== null;
|
|
51
|
+
}
|
|
36
52
|
async function waitForDaemon(sessionName) {
|
|
37
53
|
const deadline = Date.now() + STARTUP_MAX_WAIT_MS;
|
|
38
54
|
while (Date.now() < deadline) {
|
|
@@ -42,9 +58,61 @@ async function waitForDaemon(sessionName) {
|
|
|
42
58
|
}
|
|
43
59
|
return false;
|
|
44
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Wait until a process with the given PID is no longer running.
|
|
63
|
+
* Uses `process.kill(pid, 0)` which throws if the process is gone.
|
|
64
|
+
*/
|
|
65
|
+
async function waitForProcessExit(pid, timeoutMs = 5000) {
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* True when the running daemon's CDP attachment matches the current process's
|
|
79
|
+
* `CONDUCTOR_CDP_URL` / `CONDUCTOR_CDP_TARGET_ID` env. A mismatch means the
|
|
80
|
+
* daemon would control the wrong browser (e.g. a standalone Playwright
|
|
81
|
+
* instance when the caller expects to drive an embedded webview), so the
|
|
82
|
+
* daemon must be restarted with the correct env.
|
|
83
|
+
*/
|
|
84
|
+
function daemonMatchesCdpEnv(status) {
|
|
85
|
+
const expectedCdpUrl = process.env.CONDUCTOR_CDP_URL ?? '';
|
|
86
|
+
const expectedCdpTargetId = process.env.CONDUCTOR_CDP_TARGET_ID ?? '';
|
|
87
|
+
const actualCdpUrl = status.cdpUrl ?? '';
|
|
88
|
+
const actualCdpTargetId = status.cdpTargetId ?? '';
|
|
89
|
+
return actualCdpUrl === expectedCdpUrl && actualCdpTargetId === expectedCdpTargetId;
|
|
90
|
+
}
|
|
45
91
|
async function startDaemon(sessionName = 'default') {
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
const existing = await fetchStatus(sessionName);
|
|
93
|
+
if (existing) {
|
|
94
|
+
if (daemonMatchesCdpEnv(existing))
|
|
95
|
+
return true;
|
|
96
|
+
(0, verbose_js_1.log)(`daemon [${sessionName}] CDP env mismatch ` +
|
|
97
|
+
`(daemon cdpUrl="${existing.cdpUrl ?? ''}" targetId="${existing.cdpTargetId ?? ''}", ` +
|
|
98
|
+
`env cdpUrl="${process.env.CONDUCTOR_CDP_URL ?? ''}" targetId="${process.env.CONDUCTOR_CDP_TARGET_ID ?? ''}") — restarting`);
|
|
99
|
+
// Capture the PID before stopDaemon removes the pidfile so we can wait
|
|
100
|
+
// for the old process to actually exit before respawning. Otherwise the
|
|
101
|
+
// old daemon's cleanup handler may unlink the new daemon's socket.
|
|
102
|
+
let oldPid;
|
|
103
|
+
try {
|
|
104
|
+
const raw = fs_1.default.readFileSync((0, protocol_js_1.pidFile)(sessionName), 'utf-8').trim();
|
|
105
|
+
const n = parseInt(raw, 10);
|
|
106
|
+
if (!isNaN(n))
|
|
107
|
+
oldPid = n;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* no pid — continue */
|
|
111
|
+
}
|
|
112
|
+
await stopDaemon(sessionName);
|
|
113
|
+
if (oldPid !== undefined)
|
|
114
|
+
await waitForProcessExit(oldPid);
|
|
115
|
+
}
|
|
48
116
|
const serverScript = path_1.default.join(__dirname, 'server.js');
|
|
49
117
|
(0, verbose_js_1.log)(`daemon [${sessionName}] not running — spawning ${serverScript}`);
|
|
50
118
|
const child = (0, child_process_1.spawn)(process.execPath, [serverScript, sessionName], {
|
|
@@ -73,7 +141,12 @@ async function stopDaemon(sessionName = 'default') {
|
|
|
73
141
|
}
|
|
74
142
|
// Clean up the daemon directory regardless — removes stale dirs from crashed daemons
|
|
75
143
|
const dir = path_1.default.join(os_1.default.homedir(), '.conductor', 'daemons', sessionName);
|
|
76
|
-
for (const file of [
|
|
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
|
+
]) {
|
|
77
150
|
try {
|
|
78
151
|
fs_1.default.unlinkSync(file);
|
|
79
152
|
}
|
package/dist/daemon/protocol.js
CHANGED
|
@@ -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
|
package/dist/daemon/server.js
CHANGED
|
@@ -33,6 +33,14 @@ const sessionName = process.argv[2] ?? 'default';
|
|
|
33
33
|
* Set by the host IDE (Stagehand) via the agent subprocess environment.
|
|
34
34
|
*/
|
|
35
35
|
const cdpUrl = process.env.CONDUCTOR_CDP_URL || undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Optional CDP target ID to pick a specific page when the host app exposes
|
|
38
|
+
* multiple webviews over one CDP endpoint (e.g. one per workspace in
|
|
39
|
+
* Stagehand). When set, the web driver finds the page whose underlying
|
|
40
|
+
* `Target.targetId` matches and attaches to it, instead of falling back to
|
|
41
|
+
* URL heuristics.
|
|
42
|
+
*/
|
|
43
|
+
const cdpTargetId = process.env.CONDUCTOR_CDP_TARGET_ID || undefined;
|
|
36
44
|
const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
|
|
37
45
|
const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
|
|
38
46
|
const LOG_FILE = (0, protocol_js_1.logFile)(sessionName);
|
|
@@ -52,6 +60,7 @@ let logCollector = null;
|
|
|
52
60
|
const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
|
|
53
61
|
let _restartInProgress = false;
|
|
54
62
|
let _driverStarted = false;
|
|
63
|
+
let _driverStartError = null;
|
|
55
64
|
async function ensureDriverRunning() {
|
|
56
65
|
if (_restartInProgress || !_driverStarted)
|
|
57
66
|
return;
|
|
@@ -83,7 +92,7 @@ async function ensureDriverRunning() {
|
|
|
83
92
|
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
|
|
84
93
|
}
|
|
85
94
|
else if (driverPlatform === 'web') {
|
|
86
|
-
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
|
|
95
|
+
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
|
|
87
96
|
}
|
|
88
97
|
else {
|
|
89
98
|
await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
|
|
@@ -104,6 +113,7 @@ async function main() {
|
|
|
104
113
|
fs_1.default.mkdirSync(path_1.default.dirname(PID_FILE), { recursive: true });
|
|
105
114
|
fs_1.default.writeFileSync(PID_FILE, String(process.pid));
|
|
106
115
|
dlog(`daemon started pid=${process.pid} session=${sessionName}`);
|
|
116
|
+
dlog(`env CONDUCTOR_CDP_URL=${cdpUrl ?? '<unset>'} CONDUCTOR_CDP_TARGET_ID=${cdpTargetId ?? '<unset>'}`); // kept intentionally — useful for future diagnosis of CDP attachment issues
|
|
107
117
|
// Remove stale socket
|
|
108
118
|
try {
|
|
109
119
|
fs_1.default.unlinkSync(SOCKET_PATH);
|
|
@@ -220,7 +230,16 @@ async function main() {
|
|
|
220
230
|
resetIdleTimer();
|
|
221
231
|
const parsed = url_1.default.parse(req.url ?? '/', true);
|
|
222
232
|
if (req.method === 'GET' && parsed.pathname === '/status') {
|
|
223
|
-
jsonResponse(res, {
|
|
233
|
+
jsonResponse(res, {
|
|
234
|
+
ok: true,
|
|
235
|
+
platform: driverPlatform,
|
|
236
|
+
driverPort,
|
|
237
|
+
cdpUrl: cdpUrl ?? null,
|
|
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,
|
|
242
|
+
});
|
|
224
243
|
return;
|
|
225
244
|
}
|
|
226
245
|
if (req.method === 'GET' && parsed.pathname === '/logs') {
|
|
@@ -301,7 +320,7 @@ async function main() {
|
|
|
301
320
|
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
|
|
302
321
|
}
|
|
303
322
|
else if (platform === 'web') {
|
|
304
|
-
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
|
|
323
|
+
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
|
|
305
324
|
}
|
|
306
325
|
else {
|
|
307
326
|
await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
|
|
@@ -310,7 +329,8 @@ async function main() {
|
|
|
310
329
|
dlog(`Driver started successfully`);
|
|
311
330
|
}
|
|
312
331
|
catch (err) {
|
|
313
|
-
|
|
332
|
+
_driverStartError = err instanceof Error ? err.message : String(err);
|
|
333
|
+
dlog(`Driver startup error: ${_driverStartError}`);
|
|
314
334
|
}
|
|
315
335
|
}
|
|
316
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,33 +435,109 @@ let _server = null;
|
|
|
432
435
|
* — we only disconnect.
|
|
433
436
|
*/
|
|
434
437
|
let _cdpMode = false;
|
|
435
|
-
|
|
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
|
+
}
|
|
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) ───
|
|
438
469
|
dlog(`Connecting to existing browser via CDP: ${cdpUrl}`);
|
|
439
470
|
_browser = await playwright_core_1.chromium.connectOverCDP(cdpUrl);
|
|
440
471
|
_cdpMode = true;
|
|
441
|
-
// Use
|
|
442
|
-
//
|
|
472
|
+
// Use an existing context and page. The host app (e.g. Stagehand) already
|
|
473
|
+
// created them — we just take a handle.
|
|
443
474
|
const contexts = _browser.contexts();
|
|
444
475
|
if (contexts.length === 0) {
|
|
445
476
|
throw new Error('No browser contexts found via CDP — is the webview loaded?');
|
|
446
477
|
}
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
478
|
+
// Match the specific CDP target ID provided by the host app. Required —
|
|
479
|
+
// with multiple pages sharing one CDP port (Electron host window, webviews,
|
|
480
|
+
// DevTools), "guess the right one" is a foot-gun that ends in navigating
|
|
481
|
+
// the wrong window. If no targetId is supplied, or the supplied one can't
|
|
482
|
+
// be resolved to a Playwright Page, we throw rather than attaching to an
|
|
483
|
+
// arbitrary target.
|
|
484
|
+
if (!cdpTargetId) {
|
|
485
|
+
throw new Error('CDP mode requires CONDUCTOR_CDP_TARGET_ID — set it to the specific page target to control.');
|
|
486
|
+
}
|
|
487
|
+
dlog(`Selecting CDP target by ID: ${cdpTargetId}`);
|
|
488
|
+
const pageDiag = [];
|
|
489
|
+
outer: for (const ctx of contexts) {
|
|
490
|
+
for (const page of ctx.pages()) {
|
|
491
|
+
try {
|
|
492
|
+
const session = await ctx.newCDPSession(page);
|
|
493
|
+
const info = (await session.send('Target.getTargetInfo'));
|
|
494
|
+
await session.detach().catch(() => { });
|
|
495
|
+
const t = info.targetInfo;
|
|
496
|
+
pageDiag.push(`page targetId=${t?.targetId ?? '?'} type=${t?.type ?? '?'} url=${t?.url ?? page.url()}`);
|
|
497
|
+
if (t?.targetId === cdpTargetId) {
|
|
498
|
+
_context = ctx;
|
|
499
|
+
_page = page;
|
|
500
|
+
break outer;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
pageDiag.push(`page url=${page.url()} getTargetInfo failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
505
|
+
}
|
|
455
506
|
}
|
|
456
507
|
}
|
|
457
|
-
// Fallback: just use the first context's first page.
|
|
458
508
|
if (!_page) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
509
|
+
// Enumerate all CDP targets to produce an actionable error — in
|
|
510
|
+
// particular so we can distinguish "wrong targetId" from "target exists
|
|
511
|
+
// but Playwright can't see it as a Page" (the latter happens when the
|
|
512
|
+
// host embeds content as a <webview> guest — type="webview" in CDP —
|
|
513
|
+
// rather than as a top-level page).
|
|
514
|
+
let diagExtra = '';
|
|
515
|
+
let expectedType;
|
|
516
|
+
try {
|
|
517
|
+
const browserSession = await _browser.newBrowserCDPSession();
|
|
518
|
+
const all = (await browserSession.send('Target.getTargets'));
|
|
519
|
+
await browserSession.detach().catch(() => { });
|
|
520
|
+
const enumerated = all.targetInfos
|
|
521
|
+
?.map((t) => ` - ${t.type.padEnd(10)} ${t.targetId.slice(0, 8)} ${t.url}`)
|
|
522
|
+
.join('\n') ?? '(none)';
|
|
523
|
+
const expected = all.targetInfos?.find((t) => t.targetId === cdpTargetId);
|
|
524
|
+
expectedType = expected?.type;
|
|
525
|
+
diagExtra =
|
|
526
|
+
`\nPages visible to Playwright (${pageDiag.length}):\n` +
|
|
527
|
+
pageDiag.map((l) => ` - ${l}`).join('\n') +
|
|
528
|
+
`\nAll CDP targets (${all.targetInfos?.length ?? 0}):\n${enumerated}\n` +
|
|
529
|
+
(expected
|
|
530
|
+
? `Requested target exists but type="${expected.type}" — Playwright only surfaces type="page".`
|
|
531
|
+
: `Requested target not present in Target.getTargets — stale or wrong ID.`);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
diagExtra = `\nTarget enumeration failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
535
|
+
}
|
|
536
|
+
dlog(`CDP target id ${cdpTargetId} not matched by any Playwright Page.${diagExtra}`);
|
|
537
|
+
throw new Error(`CDP target ${cdpTargetId} not reachable as a Playwright Page` +
|
|
538
|
+
(expectedType
|
|
539
|
+
? ` (exists as type="${expectedType}" — Playwright only surfaces type="page")`
|
|
540
|
+
: ` (not in target list)`));
|
|
462
541
|
}
|
|
463
542
|
attachConsoleListeners(_page);
|
|
464
543
|
dlog(`CDP connected — page: ${_page.url()}`);
|
|
@@ -466,17 +545,25 @@ async function startWebServer(port, browserName = 'chromium', dlog = () => { },
|
|
|
466
545
|
else {
|
|
467
546
|
// ── Standalone mode: launch a fresh browser ───────────────────────────
|
|
468
547
|
const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
|
|
469
|
-
|
|
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})...`);
|
|
470
554
|
_browser = await browserType.launch({
|
|
471
|
-
headless
|
|
472
|
-
args: browserName === 'chromium'
|
|
555
|
+
headless,
|
|
556
|
+
args: browserName === 'chromium'
|
|
557
|
+
? [`--remote-debugging-port=${_cdpPort}`, '--disable-search-engine-choice-screen']
|
|
558
|
+
: undefined,
|
|
473
559
|
});
|
|
474
560
|
_context = await _browser.newContext({
|
|
475
561
|
viewport: DEFAULT_VIEWPORT,
|
|
476
562
|
});
|
|
477
563
|
_page = await _context.newPage();
|
|
478
564
|
attachConsoleListeners(_page);
|
|
479
|
-
|
|
565
|
+
_pageTargetId = await resolvePageTargetId();
|
|
566
|
+
dlog(`Browser ready, page created (targetId=${_pageTargetId ?? 'unknown'})`);
|
|
480
567
|
_cdpMode = false;
|
|
481
568
|
}
|
|
482
569
|
_server = http_1.default.createServer(async (req, res) => {
|
|
@@ -534,6 +621,32 @@ async function stopWebServer() {
|
|
|
534
621
|
}
|
|
535
622
|
}
|
|
536
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
|
+
}
|
|
537
650
|
}
|
|
538
651
|
/** Playwright / CDP errors when the tab, context, or session died but our JS refs still exist. */
|
|
539
652
|
function isClosedLikeError(err) {
|
|
@@ -709,6 +822,10 @@ async function handleRequest(req, res, dlog) {
|
|
|
709
822
|
jsonResponse(res, { url: (await getPage(dlog)).url() });
|
|
710
823
|
return;
|
|
711
824
|
}
|
|
825
|
+
case '/runningApp': {
|
|
826
|
+
jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
712
829
|
case '/title': {
|
|
713
830
|
jsonResponse(res, { title: await (await getPage(dlog)).title() });
|
|
714
831
|
return;
|
|
@@ -806,26 +923,29 @@ async function handleRequest(req, res, dlog) {
|
|
|
806
923
|
return;
|
|
807
924
|
}
|
|
808
925
|
await gotoWithRecovery(targetUrl, dlog);
|
|
809
|
-
|
|
926
|
+
const navState = await getNavState(await getPage(dlog));
|
|
927
|
+
jsonResponse(res, { ok: true, ...navState });
|
|
810
928
|
return;
|
|
811
929
|
}
|
|
812
930
|
case '/goBack': {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
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 });
|
|
817
935
|
return;
|
|
818
936
|
}
|
|
819
937
|
case '/goForward': {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
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 });
|
|
824
942
|
return;
|
|
825
943
|
}
|
|
826
944
|
case '/reload': {
|
|
827
|
-
|
|
828
|
-
|
|
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 });
|
|
829
949
|
return;
|
|
830
950
|
}
|
|
831
951
|
case '/clearCookies': {
|
|
@@ -859,10 +979,6 @@ async function handleRequest(req, res, dlog) {
|
|
|
859
979
|
jsonResponse(res, { ok: true });
|
|
860
980
|
return;
|
|
861
981
|
}
|
|
862
|
-
case '/runningApp': {
|
|
863
|
-
jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
982
|
case '/eraseText': {
|
|
867
983
|
const count = body['count'] ?? 50;
|
|
868
984
|
const p = await getPage(dlog);
|
|
@@ -872,6 +988,53 @@ async function handleRequest(req, res, dlog) {
|
|
|
872
988
|
jsonResponse(res, { ok: true });
|
|
873
989
|
return;
|
|
874
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
|
+
}
|
|
875
1038
|
case '/shutdown': {
|
|
876
1039
|
jsonResponse(res, { ok: true });
|
|
877
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 === '
|
|
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
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED