@houwert/conductor 0.30.0 → 0.31.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/install-app.js +16 -4
- package/dist/commands/list-apps.js +11 -0
- package/dist/commands/list-devices.js +25 -0
- package/dist/commands/press-key.js +21 -3
- package/dist/commands/stop-device.js +13 -2
- package/dist/commands/swipe.js +4 -2
- package/dist/daemon/input-backends.js +26 -1
- package/dist/daemon/log-collector.js +6 -0
- package/dist/daemon/server.js +40 -7
- package/dist/drivers/bootstrap.js +253 -4
- package/dist/drivers/devicectl.js +243 -0
- package/dist/drivers/flow-runner.js +10 -1
- package/dist/drivers/ios.js +96 -4
- package/dist/runner.js +16 -8
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +11 -1
- package/skills/conductor-device-setup/SKILL.md +28 -1
|
@@ -8,6 +8,7 @@ const sdk_js_1 = require("../android/sdk.js");
|
|
|
8
8
|
const session_js_1 = require("../session.js");
|
|
9
9
|
const output_js_1 = require("../output.js");
|
|
10
10
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
11
|
+
const devicectl_js_1 = require("../drivers/devicectl.js");
|
|
11
12
|
const cli_js_1 = require("../drivers/vega/cli.js");
|
|
12
13
|
async function resolveDeviceId(sessionName) {
|
|
13
14
|
if (sessionName !== 'default')
|
|
@@ -46,10 +47,21 @@ async function installApp(appPath, opts = {}, sessionName = 'default') {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
else if (platform === 'ios' || platform === 'tvos') {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
if ((await (0, bootstrap_js_1.detectDeviceKind)(deviceId)) === 'physical') {
|
|
51
|
+
try {
|
|
52
|
+
await (0, devicectl_js_1.installApp)(deviceId, appPath);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
(0, output_js_1.printError)(`install-app failed: ${e instanceof Error ? e.message : String(e)}`, opts);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'install', deviceId, appPath]);
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
(0, output_js_1.printError)(`install-app failed: ${result.stderr}`, opts);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
else {
|
|
@@ -8,6 +8,7 @@ const sdk_js_1 = require("../android/sdk.js");
|
|
|
8
8
|
const session_js_1 = require("../session.js");
|
|
9
9
|
const output_js_1 = require("../output.js");
|
|
10
10
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
11
|
+
const devicectl_js_1 = require("../drivers/devicectl.js");
|
|
11
12
|
const cli_js_1 = require("../drivers/vega/cli.js");
|
|
12
13
|
async function resolveDeviceId(sessionName) {
|
|
13
14
|
if (sessionName !== 'default')
|
|
@@ -38,6 +39,16 @@ async function listApps(opts = {}, sessionName = 'default') {
|
|
|
38
39
|
// Vega is driven through Amazon's CLI, not adb; strip the `vega:` id prefix.
|
|
39
40
|
appIds = (await new cli_js_1.VegaCli(deviceId.replace(/^vega:/, '')).listInstalledApps()).sort();
|
|
40
41
|
}
|
|
42
|
+
else if ((platform === 'ios' || platform === 'tvos') &&
|
|
43
|
+
(await (0, bootstrap_js_1.detectDeviceKind)(deviceId)) === 'physical') {
|
|
44
|
+
try {
|
|
45
|
+
appIds = (await (0, devicectl_js_1.listApps)(deviceId)).map((a) => a.id).sort();
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
(0, output_js_1.printError)(`list-apps failed: ${e instanceof Error ? e.message : String(e)}`, opts);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
41
52
|
else if (platform === 'ios' || platform === 'tvos') {
|
|
42
53
|
const result = await (0, runner_js_1.spawnCommand)('bash', [
|
|
43
54
|
'-c',
|
|
@@ -18,6 +18,7 @@ const protocol_js_1 = require("../daemon/protocol.js");
|
|
|
18
18
|
const cli_js_1 = require("../drivers/vega/cli.js");
|
|
19
19
|
const discovery_js_1 = require("../drivers/roku/discovery.js");
|
|
20
20
|
const cdp_discovery_js_1 = require("../drivers/cdp-discovery.js");
|
|
21
|
+
const devicectl_js_1 = require("../drivers/devicectl.js");
|
|
21
22
|
// Captured during discoverAvailableDevices so listDevices() can report it.
|
|
22
23
|
// Module-scoped because the discover function returns Device[]; bolting an
|
|
23
24
|
// extra return field onto the public type would ripple beyond this fix.
|
|
@@ -110,6 +111,18 @@ async function discoverBootedDevices() {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
}
|
|
114
|
+
// Physical iOS/tvOS devices: paired and reachable is the closest analogue to
|
|
115
|
+
// a booted simulator — that's when conductor can actually drive them.
|
|
116
|
+
for (const d of await (0, devicectl_js_1.listPhysicalDevices)()) {
|
|
117
|
+
if (!d.available)
|
|
118
|
+
continue;
|
|
119
|
+
devices.push({
|
|
120
|
+
id: d.identifier,
|
|
121
|
+
name: d.name,
|
|
122
|
+
platform: d.platform,
|
|
123
|
+
status: d.developerModeEnabled ? 'connected' : 'developer mode off',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
113
126
|
// Vega (Amazon Fire TV): booted devices reported by the vega CLI. Best-effort —
|
|
114
127
|
// the CLI is absent unless the Vega SDK is installed.
|
|
115
128
|
try {
|
|
@@ -166,6 +179,18 @@ async function discoverAvailableDevices() {
|
|
|
166
179
|
// ignore parse errors
|
|
167
180
|
}
|
|
168
181
|
}
|
|
182
|
+
// Physical devices that aren't currently reachable — listed so users can see
|
|
183
|
+
// why a device they expect is missing.
|
|
184
|
+
for (const d of await (0, devicectl_js_1.listPhysicalDevices)()) {
|
|
185
|
+
if (d.available)
|
|
186
|
+
continue;
|
|
187
|
+
devices.push({
|
|
188
|
+
id: d.identifier,
|
|
189
|
+
name: d.name,
|
|
190
|
+
platform: d.platform,
|
|
191
|
+
status: 'unavailable',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
169
194
|
// Android: list available AVDs
|
|
170
195
|
const emu = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('emulator'), ['-list-avds'], {
|
|
171
196
|
env: (0, sdk_js_1.androidSpawnEnv)(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ANDROID_KEYCODE = exports.VALID_KEYS = exports.HELP = void 0;
|
|
3
|
+
exports.ANDROID_KEYCODE = exports.TVOS_REMOTE_BUTTONS = exports.VALID_KEYS = exports.HELP = void 0;
|
|
4
4
|
exports.dispatchKey = dispatchKey;
|
|
5
5
|
exports.pressKey = pressKey;
|
|
6
6
|
exports.HELP = ` press-key <key> Press a key (Enter, Backspace, Home, ...)
|
|
@@ -59,6 +59,12 @@ exports.VALID_KEYS = [
|
|
|
59
59
|
'Remote Info',
|
|
60
60
|
'Remote Instant Replay',
|
|
61
61
|
'Remote Search',
|
|
62
|
+
'Remote Page Up',
|
|
63
|
+
'Remote Page Down',
|
|
64
|
+
'Remote Guide',
|
|
65
|
+
'Remote TV Provider',
|
|
66
|
+
'Remote One Two Three',
|
|
67
|
+
'Remote Four Colors',
|
|
62
68
|
];
|
|
63
69
|
// iOS XCTest pressKey accepts these values (maps to XCUIKeyboardKey)
|
|
64
70
|
const IOS_KEY_MAP = {
|
|
@@ -74,7 +80,9 @@ const IOS_BUTTON_MAP = {
|
|
|
74
80
|
Power: 'lock',
|
|
75
81
|
};
|
|
76
82
|
// tvOS remote: map key names to pressButton values
|
|
77
|
-
|
|
83
|
+
// Page Up/Down and Guide need tvOS 14.3; the last three need tvOS 18.1. The
|
|
84
|
+
// driver reports a precondition error when the device's OS is older.
|
|
85
|
+
exports.TVOS_REMOTE_BUTTONS = {
|
|
78
86
|
'Remote Dpad Up': 'up',
|
|
79
87
|
'Remote Dpad Down': 'down',
|
|
80
88
|
'Remote Dpad Left': 'left',
|
|
@@ -82,6 +90,12 @@ const TVOS_REMOTE_BUTTONS = {
|
|
|
82
90
|
'Remote Dpad Center': 'select',
|
|
83
91
|
'Remote Menu': 'menu',
|
|
84
92
|
'Remote Media Play Pause': 'playPause',
|
|
93
|
+
'Remote Page Up': 'pageUp',
|
|
94
|
+
'Remote Page Down': 'pageDown',
|
|
95
|
+
'Remote Guide': 'guide',
|
|
96
|
+
'Remote TV Provider': 'tvProvider',
|
|
97
|
+
'Remote One Two Three': 'oneTwoThree',
|
|
98
|
+
'Remote Four Colors': 'fourColors',
|
|
85
99
|
};
|
|
86
100
|
// vega (Amazon Fire TV) remote: map key names to VegaDriver button values.
|
|
87
101
|
const VEGA_REMOTE_BUTTONS = {
|
|
@@ -139,6 +153,10 @@ exports.ANDROID_KEYCODE = {
|
|
|
139
153
|
'Remote Info': 165,
|
|
140
154
|
'Remote Instant Replay': 273, // KEYCODE_MEDIA_SKIP_BACKWARD
|
|
141
155
|
'Remote Search': 84,
|
|
156
|
+
// Paging keys — the fast way through a long list. Whether a given app honours
|
|
157
|
+
// them varies, the same as on tvOS.
|
|
158
|
+
'Remote Page Up': 92,
|
|
159
|
+
'Remote Page Down': 93,
|
|
142
160
|
};
|
|
143
161
|
/**
|
|
144
162
|
* Send `key` on an already-connected driver. Split out of `pressKey` so
|
|
@@ -148,7 +166,7 @@ exports.ANDROID_KEYCODE = {
|
|
|
148
166
|
async function dispatchKey(driver, matched, holdSeconds) {
|
|
149
167
|
if (driver instanceof ios_js_1.IOSDriver) {
|
|
150
168
|
if (driver.platform === 'tvos') {
|
|
151
|
-
const tvosButton = TVOS_REMOTE_BUTTONS[matched];
|
|
169
|
+
const tvosButton = exports.TVOS_REMOTE_BUTTONS[matched];
|
|
152
170
|
const iosButton = IOS_BUTTON_MAP[matched];
|
|
153
171
|
if (tvosButton) {
|
|
154
172
|
await driver.pressButton(tvosButton, holdSeconds);
|
|
@@ -10,6 +10,7 @@ const sdk_js_1 = require("../android/sdk.js");
|
|
|
10
10
|
const output_js_1 = require("../output.js");
|
|
11
11
|
const client_js_1 = require("../daemon/client.js");
|
|
12
12
|
const list_devices_js_1 = require("./list-devices.js");
|
|
13
|
+
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
13
14
|
// ── iOS / tvOS ───────────────────────────────────────────────────────────────
|
|
14
15
|
async function shutdownSimulator(udid) {
|
|
15
16
|
const result = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'shutdown', udid]);
|
|
@@ -55,7 +56,12 @@ async function stopDevice(nameOrId, opts, flags) {
|
|
|
55
56
|
if (d.platform === 'roku' && !includeRoku)
|
|
56
57
|
continue;
|
|
57
58
|
try {
|
|
58
|
-
if (d.platform === 'ios' || d.platform === 'tvos')
|
|
59
|
+
if ((d.platform === 'ios' || d.platform === 'tvos') &&
|
|
60
|
+
(await (0, bootstrap_js_1.detectDeviceKind)(d.id)) === 'physical') {
|
|
61
|
+
// Real hardware can't be shut down from here — just release our driver.
|
|
62
|
+
await (0, client_js_1.stopDaemon)(d.id);
|
|
63
|
+
}
|
|
64
|
+
else if (d.platform === 'ios' || d.platform === 'tvos') {
|
|
59
65
|
await shutdownSimulator(d.id);
|
|
60
66
|
}
|
|
61
67
|
else if (d.platform === 'android') {
|
|
@@ -101,7 +107,12 @@ async function stopDevice(nameOrId, opts, flags) {
|
|
|
101
107
|
return 1;
|
|
102
108
|
}
|
|
103
109
|
try {
|
|
104
|
-
if (match.platform === 'ios' || match.platform === 'tvos')
|
|
110
|
+
if ((match.platform === 'ios' || match.platform === 'tvos') &&
|
|
111
|
+
(await (0, bootstrap_js_1.detectDeviceKind)(match.id)) === 'physical') {
|
|
112
|
+
// Real hardware can't be shut down from here — just release our driver.
|
|
113
|
+
await (0, client_js_1.stopDaemon)(match.id);
|
|
114
|
+
}
|
|
115
|
+
else if (match.platform === 'ios' || match.platform === 'tvos') {
|
|
105
116
|
await shutdownSimulator(match.id);
|
|
106
117
|
}
|
|
107
118
|
else if (match.platform === 'android') {
|
package/dist/commands/swipe.js
CHANGED
|
@@ -26,8 +26,10 @@ async function swipe(direction, opts = {}, sessionName = 'default', flags = {})
|
|
|
26
26
|
}
|
|
27
27
|
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
28
28
|
if (driver instanceof ios_js_1.IOSDriver && driver.platform === 'tvos') {
|
|
29
|
-
throw new Error('swipe is not supported on tvOS —
|
|
30
|
-
'
|
|
29
|
+
throw new Error('swipe is not supported on tvOS — XCTest has no Siri Remote touch-surface\n' +
|
|
30
|
+
'gesture ("Swipe events are only implemented for iOS, visionOS, and watchOS").\n' +
|
|
31
|
+
'Navigate with press-key "Remote Dpad Left"/"Right"/"Up"/"Down", or cover\n' +
|
|
32
|
+
'long lists faster with press-key "Remote Page Up"/"Remote Page Down".');
|
|
31
33
|
}
|
|
32
34
|
let startX, startY, endX, endY;
|
|
33
35
|
if (driver instanceof ios_js_1.IOSDriver) {
|
|
@@ -34,6 +34,15 @@ const IOS_BUTTON = {
|
|
|
34
34
|
ArrowDown: 'down',
|
|
35
35
|
ArrowLeft: 'left',
|
|
36
36
|
ArrowRight: 'right',
|
|
37
|
+
// Newer remote buttons; the driver rejects these on older tvOS.
|
|
38
|
+
pageUp: 'pageUp',
|
|
39
|
+
PageUp: 'pageUp',
|
|
40
|
+
pageDown: 'pageDown',
|
|
41
|
+
PageDown: 'pageDown',
|
|
42
|
+
guide: 'guide',
|
|
43
|
+
tvProvider: 'tvProvider',
|
|
44
|
+
oneTwoThree: 'oneTwoThree',
|
|
45
|
+
fourColors: 'fourColors',
|
|
37
46
|
};
|
|
38
47
|
function iosBackend(driver) {
|
|
39
48
|
let size = null;
|
|
@@ -52,7 +61,23 @@ function iosBackend(driver) {
|
|
|
52
61
|
touch: true,
|
|
53
62
|
drag: true,
|
|
54
63
|
multitouch: true,
|
|
55
|
-
|
|
64
|
+
// Advertise what this platform actually accepts so clients can hide
|
|
65
|
+
// controls rather than discover failures at press time.
|
|
66
|
+
buttons: tvos
|
|
67
|
+
? [
|
|
68
|
+
'home',
|
|
69
|
+
'menu',
|
|
70
|
+
'select',
|
|
71
|
+
'up',
|
|
72
|
+
'down',
|
|
73
|
+
'left',
|
|
74
|
+
'right',
|
|
75
|
+
'playPause',
|
|
76
|
+
'pageUp',
|
|
77
|
+
'pageDown',
|
|
78
|
+
'guide',
|
|
79
|
+
]
|
|
80
|
+
: ['home', 'lock'],
|
|
56
81
|
keyboard: true,
|
|
57
82
|
text: true,
|
|
58
83
|
tvRemote: tvos,
|
|
@@ -35,6 +35,7 @@ const android_js_1 = require("../drivers/log-sources/android.js");
|
|
|
35
35
|
const vega_js_1 = require("../drivers/log-sources/vega.js");
|
|
36
36
|
const metro_js_1 = require("../drivers/log-sources/metro.js");
|
|
37
37
|
const metro_discovery_js_1 = require("../drivers/log-sources/metro-discovery.js");
|
|
38
|
+
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
38
39
|
const MAX_BUFFER = 5000;
|
|
39
40
|
const RESTART_DELAY_MS = 2000;
|
|
40
41
|
const WEB_POLL_INTERVAL_MS = 500;
|
|
@@ -145,6 +146,11 @@ class LogCollector {
|
|
|
145
146
|
return;
|
|
146
147
|
try {
|
|
147
148
|
if (this.platform === 'ios' || this.platform === 'tvos') {
|
|
149
|
+
// `simctl spawn ... log stream` has no devicectl equivalent, so OS logs
|
|
150
|
+
// are simulator-only. Metro still streams over the network below, which
|
|
151
|
+
// is the useful source for React Native apps anyway.
|
|
152
|
+
if ((await (0, bootstrap_js_1.detectDeviceKind)(this.deviceId)) === 'physical')
|
|
153
|
+
return;
|
|
148
154
|
this.source = new ios_js_1.IOSLogSource(this.deviceId, this.appId);
|
|
149
155
|
}
|
|
150
156
|
else if (this.platform === 'android') {
|
package/dist/daemon/server.js
CHANGED
|
@@ -84,6 +84,20 @@ function dlog(msg) {
|
|
|
84
84
|
// ── Driver lifecycle ──────────────────────────────────────────────────────────
|
|
85
85
|
let driverPort = 1075;
|
|
86
86
|
let driverPlatform = 'ios';
|
|
87
|
+
/**
|
|
88
|
+
* Host the driver is reachable on. Simulators and every non-Apple platform use
|
|
89
|
+
* the host's loopback; a physical device runs the driver on its own, so we talk
|
|
90
|
+
* to it over the network. Memoised — resolution goes through mDNS.
|
|
91
|
+
*/
|
|
92
|
+
let _driverHost = null;
|
|
93
|
+
async function driverHost() {
|
|
94
|
+
if (_driverHost)
|
|
95
|
+
return _driverHost;
|
|
96
|
+
if (driverPlatform !== 'ios' && driverPlatform !== 'tvos')
|
|
97
|
+
return '127.0.0.1';
|
|
98
|
+
_driverHost = await (0, bootstrap_js_1.resolveDriverHost)(sessionName).catch(() => '127.0.0.1');
|
|
99
|
+
return _driverHost;
|
|
100
|
+
}
|
|
87
101
|
let logCollector = null;
|
|
88
102
|
let inputServer = null;
|
|
89
103
|
let inputPort = null;
|
|
@@ -109,7 +123,7 @@ async function startInputServerForPlatform() {
|
|
|
109
123
|
makeBackend = () => (0, input_backends_js_1.androidBackend)(driver);
|
|
110
124
|
}
|
|
111
125
|
else {
|
|
112
|
-
const driver = new ios_js_1.IOSDriver(driverPort,
|
|
126
|
+
const driver = new ios_js_1.IOSDriver(driverPort, await driverHost(), sessionName, driverPlatform, (await (0, bootstrap_js_1.detectDeviceKind)(sessionName)) === 'physical');
|
|
113
127
|
makeBackend = () => (0, input_backends_js_1.iosBackend)(driver);
|
|
114
128
|
// Opt-in native held-touch backend for live drags (iOS only; single-touch).
|
|
115
129
|
if (driverPlatform === 'ios' && process.env.CONDUCTOR_IOS_HID === '1') {
|
|
@@ -154,6 +168,12 @@ async function startVideoServerForPlatform() {
|
|
|
154
168
|
return;
|
|
155
169
|
if (driverPlatform !== 'ios' && driverPlatform !== 'tvos')
|
|
156
170
|
return;
|
|
171
|
+
// Capture attaches to the Simulator's framebuffer via SimulatorKit, which has
|
|
172
|
+
// no counterpart on real hardware.
|
|
173
|
+
if ((await (0, bootstrap_js_1.detectDeviceKind)(sessionName)) === 'physical') {
|
|
174
|
+
dlog('video capture is simulator-only — stream server disabled for this device');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
157
177
|
const binary = await (0, bootstrap_js_1.getCaptureBinaryPath)();
|
|
158
178
|
if (!binary) {
|
|
159
179
|
dlog('video capture binary not present — stream server disabled for this device');
|
|
@@ -189,11 +209,13 @@ async function ensureDriverRunning() {
|
|
|
189
209
|
probe.close();
|
|
190
210
|
}
|
|
191
211
|
else {
|
|
192
|
-
// 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive
|
|
193
|
-
|
|
212
|
+
// 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive.
|
|
213
|
+
// Physical devices answer on the LAN rather than the host's loopback.
|
|
214
|
+
alive = await (0, bootstrap_js_1.isPortOpen)(driverPort, await driverHost());
|
|
194
215
|
}
|
|
195
216
|
if (!alive) {
|
|
196
217
|
if ((driverPlatform === 'ios' || driverPlatform === 'tvos') &&
|
|
218
|
+
(await (0, bootstrap_js_1.detectDeviceKind)(sessionName)) === 'simulator' &&
|
|
197
219
|
!(await (0, bootstrap_js_1.isSimulatorBooted)(sessionName))) {
|
|
198
220
|
dlog(`Simulator ${sessionName} is not booted — skipping driver restart`);
|
|
199
221
|
return;
|
|
@@ -201,7 +223,11 @@ async function ensureDriverRunning() {
|
|
|
201
223
|
_restartInProgress = true;
|
|
202
224
|
dlog(`Driver on port ${driverPort} not responding — restarting`);
|
|
203
225
|
try {
|
|
204
|
-
if (driverPlatform === 'ios')
|
|
226
|
+
if ((driverPlatform === 'ios' || driverPlatform === 'tvos') &&
|
|
227
|
+
(await (0, bootstrap_js_1.detectDeviceKind)(sessionName)) === 'physical') {
|
|
228
|
+
await (0, bootstrap_js_1.startDeviceDriver)(sessionName, driverPlatform, driverPort);
|
|
229
|
+
}
|
|
230
|
+
else if (driverPlatform === 'ios') {
|
|
205
231
|
await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
|
|
206
232
|
}
|
|
207
233
|
else if (driverPlatform === 'tvos') {
|
|
@@ -333,7 +359,10 @@ async function main() {
|
|
|
333
359
|
else {
|
|
334
360
|
dlog(`Stopping driver on port ${driverPort}`);
|
|
335
361
|
try {
|
|
336
|
-
if (driverPlatform === 'ios') {
|
|
362
|
+
if (driverPlatform === 'ios' && (await (0, bootstrap_js_1.detectDeviceKind)(sessionName)) === 'physical') {
|
|
363
|
+
await (0, bootstrap_js_1.stopDeviceDriver)(sessionName, 'ios');
|
|
364
|
+
}
|
|
365
|
+
else if (driverPlatform === 'ios') {
|
|
337
366
|
await (0, bootstrap_js_1.stopIOSDriver)(sessionName);
|
|
338
367
|
}
|
|
339
368
|
else {
|
|
@@ -510,7 +539,7 @@ async function startDriverForPlatform(platform) {
|
|
|
510
539
|
}
|
|
511
540
|
else {
|
|
512
541
|
// 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive
|
|
513
|
-
driverAlive = await (0, bootstrap_js_1.isPortOpen)(driverPort);
|
|
542
|
+
driverAlive = await (0, bootstrap_js_1.isPortOpen)(driverPort, await driverHost());
|
|
514
543
|
}
|
|
515
544
|
if (driverAlive) {
|
|
516
545
|
_driverStarted = true;
|
|
@@ -533,7 +562,11 @@ async function startDriverForPlatform(platform) {
|
|
|
533
562
|
}
|
|
534
563
|
dlog(`Starting ${platform} driver on port ${driverPort}`);
|
|
535
564
|
try {
|
|
536
|
-
if (platform === 'ios')
|
|
565
|
+
if ((platform === 'ios' || platform === 'tvos') &&
|
|
566
|
+
(await (0, bootstrap_js_1.detectDeviceKind)(sessionName)) === 'physical') {
|
|
567
|
+
await (0, bootstrap_js_1.startDeviceDriver)(sessionName, platform, driverPort);
|
|
568
|
+
}
|
|
569
|
+
else if (platform === 'ios') {
|
|
537
570
|
await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
|
|
538
571
|
}
|
|
539
572
|
else if (platform === 'tvos') {
|
|
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.detectPlatform = detectPlatform;
|
|
7
|
+
exports.detectDeviceKind = detectDeviceKind;
|
|
8
|
+
exports.resolveDriverHost = resolveDriverHost;
|
|
7
9
|
exports.getDriverPort = getDriverPort;
|
|
8
10
|
exports.getInputPort = getInputPort;
|
|
9
11
|
exports.getStreamPort = getStreamPort;
|
|
@@ -18,6 +20,9 @@ exports.stopIOSDriver = stopIOSDriver;
|
|
|
18
20
|
exports.setupTvOSDriverCache = setupTvOSDriverCache;
|
|
19
21
|
exports.startTvOSDriver = startTvOSDriver;
|
|
20
22
|
exports.stopTvOSDriver = stopTvOSDriver;
|
|
23
|
+
exports.resolveTeamId = resolveTeamId;
|
|
24
|
+
exports.startDeviceDriver = startDeviceDriver;
|
|
25
|
+
exports.stopDeviceDriver = stopDeviceDriver;
|
|
21
26
|
exports.startAndroidDriver = startAndroidDriver;
|
|
22
27
|
exports.stopAndroidDriver = stopAndroidDriver;
|
|
23
28
|
exports.webBrowserName = webBrowserName;
|
|
@@ -48,6 +53,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
48
53
|
const verbose_js_1 = require("../verbose.js");
|
|
49
54
|
const utils_js_1 = require("../utils.js");
|
|
50
55
|
const sdk_js_1 = require("../android/sdk.js");
|
|
56
|
+
const devicectl_js_1 = require("./devicectl.js");
|
|
51
57
|
/** Cache: deviceId → platform */
|
|
52
58
|
const _platformCache = new Map();
|
|
53
59
|
async function detectPlatform(deviceId) {
|
|
@@ -68,9 +74,11 @@ async function detectPlatform(deviceId) {
|
|
|
68
74
|
_platformCache.set(deviceId, 'roku');
|
|
69
75
|
return 'roku';
|
|
70
76
|
}
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
// Simulator UDIDs and CoreDevice identifiers share the 8-4-4-4-12 UUID shape;
|
|
78
|
+
// physical devices additionally answer to their 40-hex hardware UDID.
|
|
79
|
+
const appleUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
|
80
|
+
const appleUdidRe = /^[0-9a-f]{40}$/i;
|
|
81
|
+
if (appleUuidRe.test(deviceId) || appleUdidRe.test(deviceId)) {
|
|
74
82
|
// Query simctl to determine whether this UUID belongs to a tvOS runtime
|
|
75
83
|
try {
|
|
76
84
|
const out = await spawnCapture('xcrun', ['simctl', 'list', 'devices', '--json']);
|
|
@@ -79,12 +87,20 @@ async function detectPlatform(deviceId) {
|
|
|
79
87
|
if (sims.some((s) => s.udid === deviceId)) {
|
|
80
88
|
const platform = runtime.includes('tvOS') ? 'tvos' : 'ios';
|
|
81
89
|
_platformCache.set(deviceId, platform);
|
|
90
|
+
_kindCache.set(deviceId, 'simulator');
|
|
82
91
|
return platform;
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
95
|
catch {
|
|
87
|
-
/* fall through to
|
|
96
|
+
/* fall through to devicectl */
|
|
97
|
+
}
|
|
98
|
+
// Not a simulator — ask CoreDevice whether it's a paired physical device.
|
|
99
|
+
const physical = await (0, devicectl_js_1.findPhysicalDevice)(deviceId);
|
|
100
|
+
if (physical) {
|
|
101
|
+
_platformCache.set(deviceId, physical.platform);
|
|
102
|
+
_kindCache.set(deviceId, 'physical');
|
|
103
|
+
return physical.platform;
|
|
88
104
|
}
|
|
89
105
|
_platformCache.set(deviceId, 'ios');
|
|
90
106
|
return 'ios';
|
|
@@ -93,6 +109,40 @@ async function detectPlatform(deviceId) {
|
|
|
93
109
|
_platformCache.set(deviceId, 'android');
|
|
94
110
|
return 'android';
|
|
95
111
|
}
|
|
112
|
+
/** Cache: deviceId → simulator vs physical. Populated as a side effect of detectPlatform. */
|
|
113
|
+
const _kindCache = new Map();
|
|
114
|
+
/**
|
|
115
|
+
* Whether the device is a real one rather than a simulator. Only meaningful for
|
|
116
|
+
* iOS/tvOS; everything else reports 'simulator' since the distinction is either
|
|
117
|
+
* absent (web) or already encoded in the driver (Android adb, Vega).
|
|
118
|
+
*/
|
|
119
|
+
async function detectDeviceKind(deviceId) {
|
|
120
|
+
if (_kindCache.has(deviceId))
|
|
121
|
+
return _kindCache.get(deviceId);
|
|
122
|
+
const platform = await detectPlatform(deviceId);
|
|
123
|
+
if (platform !== 'ios' && platform !== 'tvos') {
|
|
124
|
+
_kindCache.set(deviceId, 'simulator');
|
|
125
|
+
return 'simulator';
|
|
126
|
+
}
|
|
127
|
+
// detectPlatform records the kind for Apple devices it recognised; anything
|
|
128
|
+
// still unset never matched simctl or devicectl, so treat it as a simulator.
|
|
129
|
+
const kind = _kindCache.get(deviceId) ?? 'simulator';
|
|
130
|
+
_kindCache.set(deviceId, kind);
|
|
131
|
+
return kind;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Host the XCTest driver for this device is reachable on. Simulators share the
|
|
135
|
+
* host's loopback; physical devices run the driver on their own loopback, so we
|
|
136
|
+
* reach them over the LAN instead.
|
|
137
|
+
*/
|
|
138
|
+
async function resolveDriverHost(deviceId) {
|
|
139
|
+
if ((await detectDeviceKind(deviceId)) !== 'physical')
|
|
140
|
+
return '127.0.0.1';
|
|
141
|
+
const device = await (0, devicectl_js_1.findPhysicalDevice)(deviceId);
|
|
142
|
+
if (!device)
|
|
143
|
+
throw new Error(`Physical device ${deviceId} is no longer paired`);
|
|
144
|
+
return (0, devicectl_js_1.resolveDeviceHost)(device);
|
|
145
|
+
}
|
|
96
146
|
// ── Port management ───────────────────────────────────────────────────────────
|
|
97
147
|
const IOS_BASE_PORT = 1075;
|
|
98
148
|
const TVOS_BASE_PORT = 2075;
|
|
@@ -723,6 +773,179 @@ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, restoreFocusAfte
|
|
|
723
773
|
async function stopTvOSDriver(deviceId) {
|
|
724
774
|
await spawnAndWait('xcrun', ['simctl', 'terminate', deviceId, TVOS_RUNNER_BUNDLE_ID]);
|
|
725
775
|
}
|
|
776
|
+
// ── Physical device bootstrap ─────────────────────────────────────────────────
|
|
777
|
+
const DEVICE_STARTUP_TIMEOUT_MS = 300000;
|
|
778
|
+
const DEVICE_STARTUP_POLL_MS = 1000;
|
|
779
|
+
/**
|
|
780
|
+
* Resolve the Apple Developer team to sign the driver with.
|
|
781
|
+
*
|
|
782
|
+
* Unlike simulators, a real device only runs code signed for a team it's
|
|
783
|
+
* provisioned against, and we can't ship a signed driver — it has to be built
|
|
784
|
+
* on the user's machine with their credentials.
|
|
785
|
+
*/
|
|
786
|
+
async function resolveTeamId() {
|
|
787
|
+
const fromEnv = process.env.CONDUCTOR_TEAM_ID?.trim();
|
|
788
|
+
if (fromEnv)
|
|
789
|
+
return fromEnv;
|
|
790
|
+
// Fall back to the keychain when there's exactly one development team, which
|
|
791
|
+
// is the common single-account setup.
|
|
792
|
+
let identities;
|
|
793
|
+
try {
|
|
794
|
+
identities = await spawnCapture('security', ['find-identity', '-v', '-p', 'codesigning']);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
identities = '';
|
|
798
|
+
}
|
|
799
|
+
const teams = new Set([...identities.matchAll(/"Apple Development:[^"]*\(([A-Z0-9]{10})\)"/g)].map((m) => m[1]));
|
|
800
|
+
if (teams.size === 1)
|
|
801
|
+
return [...teams][0];
|
|
802
|
+
throw new Error(teams.size === 0
|
|
803
|
+
? 'No "Apple Development" signing identity found. Running on a physical device needs one — ' +
|
|
804
|
+
'sign in to Xcode ▸ Settings ▸ Accounts, then set CONDUCTOR_TEAM_ID=<team> if you have several teams.'
|
|
805
|
+
: `Multiple development teams found (${[...teams].join(', ')}). ` +
|
|
806
|
+
'Set CONDUCTOR_TEAM_ID=<team> to pick one.');
|
|
807
|
+
}
|
|
808
|
+
/** Where the signed device driver for a given platform + team is cached. */
|
|
809
|
+
function deviceDriverCache(platform, teamId) {
|
|
810
|
+
return path_1.default.join(os_1.default.homedir(), '.conductor', `${platform}-driver-device`, teamId);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Build and sign the XCTest driver for a physical device.
|
|
814
|
+
*
|
|
815
|
+
* The bundled simulator builds are unsigned and the wrong slice, so the driver
|
|
816
|
+
* is compiled from the sources shipped alongside them. Building against the
|
|
817
|
+
* device (rather than a generic destination) lets Xcode register it with the
|
|
818
|
+
* team's provisioning profile on first run.
|
|
819
|
+
*/
|
|
820
|
+
async function setupDeviceDriver(deviceId, platform, teamId) {
|
|
821
|
+
const driversDir = await getDriversDir();
|
|
822
|
+
const projectDir = path_1.default.join(driversDir, 'ios-driver-src');
|
|
823
|
+
const project = path_1.default.join(projectDir, 'conductor-driver-ios.xcodeproj');
|
|
824
|
+
if (!fs_1.default.existsSync(project)) {
|
|
825
|
+
throw new Error(`Conductor driver sources not found at ${projectDir}.\n` +
|
|
826
|
+
`Physical devices need a locally signed driver build. Run 'make package-drivers-tarball' ` +
|
|
827
|
+
`from the repo root, or reinstall conductor to fetch a driver bundle that includes sources.`);
|
|
828
|
+
}
|
|
829
|
+
const cache = deviceDriverCache(platform, teamId);
|
|
830
|
+
const scheme = platform === 'tvos' ? 'conductor-driver-tvos' : 'conductor-driver-ios';
|
|
831
|
+
// Rebuild when the sources or the signing team change; the xctestrun naming
|
|
832
|
+
// is derived from the SDK so glob for it rather than hardcoding a version.
|
|
833
|
+
const stamp = path_1.default.join(cache, '.version');
|
|
834
|
+
const sourceMtime = String(fs_1.default.statSync(path_1.default.join(project, 'project.pbxproj')).mtimeMs);
|
|
835
|
+
let cached = '';
|
|
836
|
+
try {
|
|
837
|
+
cached = fs_1.default.readFileSync(stamp, 'utf-8').trim();
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
/* first run */
|
|
841
|
+
}
|
|
842
|
+
if (cached === sourceMtime && findDeviceXctestrun(cache))
|
|
843
|
+
return findDeviceXctestrun(cache);
|
|
844
|
+
(0, verbose_js_1.log)(`Building signed ${platform} driver for team ${teamId} (first run takes a few minutes)...`);
|
|
845
|
+
const args = [
|
|
846
|
+
'build-for-testing',
|
|
847
|
+
'-project',
|
|
848
|
+
project,
|
|
849
|
+
'-scheme',
|
|
850
|
+
scheme,
|
|
851
|
+
'-destination',
|
|
852
|
+
`id=${deviceId}`,
|
|
853
|
+
'-derivedDataPath',
|
|
854
|
+
cache,
|
|
855
|
+
'-allowProvisioningUpdates',
|
|
856
|
+
`DEVELOPMENT_TEAM=${teamId}`,
|
|
857
|
+
];
|
|
858
|
+
// The first build for a team creates provisioning profiles as a side effect,
|
|
859
|
+
// and Xcode regularly references one before it lands on disk ("Build input
|
|
860
|
+
// file cannot be found: ….mobileprovision"). The profile exists by the retry.
|
|
861
|
+
try {
|
|
862
|
+
await spawnCaptureAll('xcodebuild', args);
|
|
863
|
+
}
|
|
864
|
+
catch (first) {
|
|
865
|
+
(0, verbose_js_1.log)(`Driver build failed, retrying once: ${first instanceof Error ? first.message : first}`);
|
|
866
|
+
try {
|
|
867
|
+
await spawnCaptureAll('xcodebuild', args);
|
|
868
|
+
}
|
|
869
|
+
catch (retry) {
|
|
870
|
+
throw new Error(`Could not build the ${platform} driver for team ${teamId}.\n` +
|
|
871
|
+
`${retry instanceof Error ? retry.message : String(retry)}\n` +
|
|
872
|
+
`Check that the device is registered to the team and that Xcode has an account for it.`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const xctestrun = findDeviceXctestrun(cache);
|
|
876
|
+
if (!xctestrun) {
|
|
877
|
+
throw new Error(`Driver build for ${platform} produced no xctestrun under ${cache}`);
|
|
878
|
+
}
|
|
879
|
+
fs_1.default.writeFileSync(stamp, sourceMtime);
|
|
880
|
+
(0, verbose_js_1.log)(`${platform} device driver ready`);
|
|
881
|
+
return xctestrun;
|
|
882
|
+
}
|
|
883
|
+
/** Locate the xctestrun a device build produced — its name embeds the SDK version. */
|
|
884
|
+
function findDeviceXctestrun(cache) {
|
|
885
|
+
const products = path_1.default.join(cache, 'Build', 'Products');
|
|
886
|
+
try {
|
|
887
|
+
const match = fs_1.default.readdirSync(products).find((f) => f.endsWith('.xctestrun'));
|
|
888
|
+
return match ? path_1.default.join(products, match) : null;
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Start the XCTest driver on a physical iOS/tvOS device.
|
|
896
|
+
*
|
|
897
|
+
* Mirrors startIOSDriver, with two differences forced by real hardware: the
|
|
898
|
+
* driver is built and signed locally, and it binds every interface because the
|
|
899
|
+
* device's loopback isn't shared with the host.
|
|
900
|
+
*/
|
|
901
|
+
async function startDeviceDriver(deviceId, platform, port) {
|
|
902
|
+
const host = await resolveDriverHost(deviceId);
|
|
903
|
+
if (await isPortOpen(port, host)) {
|
|
904
|
+
(0, verbose_js_1.log)(`${platform} device driver already running on ${host}:${port}`);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const device = await (0, devicectl_js_1.findPhysicalDevice)(deviceId);
|
|
908
|
+
if (device && !device.developerModeEnabled) {
|
|
909
|
+
throw new Error(`Developer Mode is disabled on "${device.name}". ` +
|
|
910
|
+
`Enable it in Settings ▸ Privacy & Security ▸ Developer Mode and re-pair the device.`);
|
|
911
|
+
}
|
|
912
|
+
const teamId = await resolveTeamId();
|
|
913
|
+
const xctestrun = await setupDeviceDriver(deviceId, platform, teamId);
|
|
914
|
+
const testTarget = platform === 'tvos' ? 'conductor-driver-tvosUITests' : 'conductor-driver-iosUITests';
|
|
915
|
+
await spawnAndWait('plutil', [
|
|
916
|
+
'-replace',
|
|
917
|
+
`${testTarget}.EnvironmentVariables.PORT`,
|
|
918
|
+
'-string',
|
|
919
|
+
String(port),
|
|
920
|
+
xctestrun,
|
|
921
|
+
]);
|
|
922
|
+
// The host reaches the driver over the network, so it can't bind loopback-only.
|
|
923
|
+
await spawnAndWait('plutil', [
|
|
924
|
+
'-replace',
|
|
925
|
+
`${testTarget}.EnvironmentVariables.BIND_ALL`,
|
|
926
|
+
'-string',
|
|
927
|
+
'1',
|
|
928
|
+
xctestrun,
|
|
929
|
+
]);
|
|
930
|
+
(0, verbose_js_1.log)(`Starting ${platform} driver on ${device?.name ?? deviceId} (${host}:${port})`);
|
|
931
|
+
const proc = (0, child_process_1.spawn)('xcodebuild', ['test-without-building', '-xctestrun', xctestrun, '-destination', `id=${deviceId}`], { detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
932
|
+
proc.unref();
|
|
933
|
+
const deadline = Date.now() + DEVICE_STARTUP_TIMEOUT_MS;
|
|
934
|
+
while (Date.now() < deadline) {
|
|
935
|
+
await (0, utils_js_1.sleep)(DEVICE_STARTUP_POLL_MS);
|
|
936
|
+
if (await isPortOpen(port, host)) {
|
|
937
|
+
(0, verbose_js_1.log)(`${platform} device driver ready on ${host}:${port}`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
throw new Error(`${platform} XCTest driver did not start within ${DEVICE_STARTUP_TIMEOUT_MS / 1000}s ` +
|
|
942
|
+
`on ${host}:${port}. Check that the device is awake and on the same network.`);
|
|
943
|
+
}
|
|
944
|
+
/** Stop the driver on a physical device by killing the runner process. */
|
|
945
|
+
async function stopDeviceDriver(deviceId, platform) {
|
|
946
|
+
const bundleId = platform === 'tvos' ? TVOS_RUNNER_BUNDLE_ID : IOS_RUNNER_BUNDLE_ID;
|
|
947
|
+
await (0, devicectl_js_1.terminateApp)(deviceId, bundleId).catch(() => { });
|
|
948
|
+
}
|
|
726
949
|
// ── Android bootstrap ─────────────────────────────────────────────────────────
|
|
727
950
|
const ANDROID_STARTUP_TIMEOUT_MS = 30000;
|
|
728
951
|
const ANDROID_STARTUP_POLL_MS = 500;
|
|
@@ -994,6 +1217,32 @@ function spawnAndWait(cmd, args) {
|
|
|
994
1217
|
proc.on('error', reject);
|
|
995
1218
|
});
|
|
996
1219
|
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Run a command, and on failure reject with the tail of its output. Used for
|
|
1222
|
+
* xcodebuild, where the exit code alone tells the user nothing actionable.
|
|
1223
|
+
*/
|
|
1224
|
+
function spawnCaptureAll(cmd, args) {
|
|
1225
|
+
return new Promise((resolve, reject) => {
|
|
1226
|
+
const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1227
|
+
let out = '';
|
|
1228
|
+
const append = (chunk) => {
|
|
1229
|
+
out += chunk.toString();
|
|
1230
|
+
};
|
|
1231
|
+
proc.stdout?.on('data', append);
|
|
1232
|
+
proc.stderr?.on('data', append);
|
|
1233
|
+
proc.on('close', (code) => {
|
|
1234
|
+
if (code === 0)
|
|
1235
|
+
return resolve(out);
|
|
1236
|
+
const errors = out
|
|
1237
|
+
.split('\n')
|
|
1238
|
+
.filter((l) => l.includes('error:'))
|
|
1239
|
+
.slice(0, 5)
|
|
1240
|
+
.join('\n');
|
|
1241
|
+
reject(new Error(`${cmd} exited ${code}${errors ? `:\n${errors}` : ''}`));
|
|
1242
|
+
});
|
|
1243
|
+
proc.on('error', reject);
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
997
1246
|
function spawnCapture(cmd, args) {
|
|
998
1247
|
return new Promise((resolve, reject) => {
|
|
999
1248
|
const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
@@ -0,0 +1,243 @@
|
|
|
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.parseDevicectlDevices = parseDevicectlDevices;
|
|
7
|
+
exports.listPhysicalDevices = listPhysicalDevices;
|
|
8
|
+
exports.findPhysicalDevice = findPhysicalDevice;
|
|
9
|
+
exports.bonjourHostname = bonjourHostname;
|
|
10
|
+
exports.deviceHostCandidates = deviceHostCandidates;
|
|
11
|
+
exports.resolveDeviceHost = resolveDeviceHost;
|
|
12
|
+
exports.installApp = installApp;
|
|
13
|
+
exports.uninstallApp = uninstallApp;
|
|
14
|
+
exports.launchApp = launchApp;
|
|
15
|
+
exports.terminateApp = terminateApp;
|
|
16
|
+
exports.listApps = listApps;
|
|
17
|
+
/**
|
|
18
|
+
* `xcrun devicectl` wrapper — the physical-device counterpart to `simctl`.
|
|
19
|
+
*
|
|
20
|
+
* Simulators and real devices share the XCTest driver (same HTTP protocol), but
|
|
21
|
+
* everything around it differs: discovery, app install/launch, and reachability.
|
|
22
|
+
* This module owns the devicectl half so the rest of the CLI can stay generic.
|
|
23
|
+
*/
|
|
24
|
+
const child_process_1 = require("child_process");
|
|
25
|
+
const fs_1 = __importDefault(require("fs"));
|
|
26
|
+
const os_1 = __importDefault(require("os"));
|
|
27
|
+
const path_1 = __importDefault(require("path"));
|
|
28
|
+
const promises_1 = __importDefault(require("dns/promises"));
|
|
29
|
+
const verbose_js_1 = require("../verbose.js");
|
|
30
|
+
function devicectl(args, timeoutMs = 30000) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const proc = (0, child_process_1.spawn)('xcrun', ['devicectl', ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
33
|
+
let out = '';
|
|
34
|
+
let err = '';
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
proc.kill('SIGKILL');
|
|
37
|
+
reject(new Error(`devicectl ${args[0]} ${args[1] ?? ''} timed out after ${timeoutMs}ms`));
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
proc.stdout?.on('data', (c) => {
|
|
40
|
+
out += c.toString();
|
|
41
|
+
});
|
|
42
|
+
proc.stderr?.on('data', (c) => {
|
|
43
|
+
err += c.toString();
|
|
44
|
+
});
|
|
45
|
+
proc.on('close', (code) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (code === 0) {
|
|
48
|
+
resolve(out);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
reject(new Error(`devicectl ${args.join(' ')} failed: ${err.trim() || out.trim()}`));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
proc.on('error', (e) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
reject(e);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run a devicectl subcommand that reports its result as JSON. devicectl only
|
|
62
|
+
* writes structured output to a file, never stdout, so every call round-trips
|
|
63
|
+
* through a temp file.
|
|
64
|
+
*/
|
|
65
|
+
async function devicectlJson(args, timeoutMs = 30000) {
|
|
66
|
+
const file = path_1.default.join(os_1.default.tmpdir(), `conductor-devicectl-${process.pid}-${Math.random().toString(36).slice(2)}.json`);
|
|
67
|
+
try {
|
|
68
|
+
await devicectl([...args, '--json-output', file], timeoutMs);
|
|
69
|
+
return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
fs_1.default.rmSync(file, { force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function toPlatform(raw) {
|
|
76
|
+
if (!raw)
|
|
77
|
+
return null;
|
|
78
|
+
const v = raw.toLowerCase();
|
|
79
|
+
if (v === 'ios' || v === 'ipados')
|
|
80
|
+
return 'ios';
|
|
81
|
+
if (v === 'tvos')
|
|
82
|
+
return 'tvos';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/** Map devicectl's device list onto PhysicalDevice, dropping anything we can't drive. */
|
|
86
|
+
function parseDevicectlDevices(parsed) {
|
|
87
|
+
const devices = [];
|
|
88
|
+
for (const d of parsed.result?.devices ?? []) {
|
|
89
|
+
const platform = toPlatform(d.hardwareProperties?.platform);
|
|
90
|
+
// Skip simulators (reality: "simulator") — simctl already covers those.
|
|
91
|
+
if (!platform || d.hardwareProperties?.reality !== 'physical')
|
|
92
|
+
continue;
|
|
93
|
+
devices.push({
|
|
94
|
+
identifier: d.identifier,
|
|
95
|
+
udid: d.hardwareProperties?.udid ?? d.identifier,
|
|
96
|
+
name: d.deviceProperties?.name ?? d.identifier,
|
|
97
|
+
platform,
|
|
98
|
+
// Paired isn't enough: a device that's off or on another network stays
|
|
99
|
+
// paired but reports no transport, and tunnelState goes 'unavailable'.
|
|
100
|
+
available: d.connectionProperties?.pairingState === 'paired' &&
|
|
101
|
+
d.connectionProperties?.tunnelState !== 'unavailable' &&
|
|
102
|
+
!!d.connectionProperties?.transportType,
|
|
103
|
+
potentialHostnames: d.connectionProperties?.potentialHostnames ?? [],
|
|
104
|
+
osVersion: d.deviceProperties?.osVersionNumber ?? '',
|
|
105
|
+
marketingName: d.hardwareProperties?.marketingName ?? '',
|
|
106
|
+
developerModeEnabled: d.deviceProperties?.developerModeStatus === 'enabled',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return devices;
|
|
110
|
+
}
|
|
111
|
+
/** List every paired physical iOS/tvOS device known to CoreDevice. */
|
|
112
|
+
async function listPhysicalDevices() {
|
|
113
|
+
try {
|
|
114
|
+
return parseDevicectlDevices(await devicectlJson(['list', 'devices']));
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
(0, verbose_js_1.log)(`devicectl list devices failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Cache: deviceId (identifier or udid) → device, or null when it isn't physical. */
|
|
122
|
+
const _deviceCache = new Map();
|
|
123
|
+
/** Look up a physical device by either its CoreDevice identifier or hardware UDID. */
|
|
124
|
+
async function findPhysicalDevice(deviceId) {
|
|
125
|
+
if (_deviceCache.has(deviceId))
|
|
126
|
+
return _deviceCache.get(deviceId);
|
|
127
|
+
const key = deviceId.toLowerCase();
|
|
128
|
+
const match = (await listPhysicalDevices()).find((d) => d.identifier.toLowerCase() === key || d.udid.toLowerCase() === key) ?? null;
|
|
129
|
+
_deviceCache.set(deviceId, match);
|
|
130
|
+
return match;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the address the host can reach the device's driver on.
|
|
134
|
+
*
|
|
135
|
+
* devicectl's `*.coredevice.local` hostnames only resolve inside Apple's
|
|
136
|
+
* CoreDevice tunnel, so they're useless for a plain TCP connect. The device's
|
|
137
|
+
* Bonjour name (`<name>.local`) is what actually resolves on the LAN, so we
|
|
138
|
+
* derive that from the device name and resolve it to an IP once, since mDNS
|
|
139
|
+
* lookups are slow enough to matter on every driver poll.
|
|
140
|
+
*/
|
|
141
|
+
const _hostCache = new Map();
|
|
142
|
+
/**
|
|
143
|
+
* The mDNS name a device advertises itself under, derived from its display name
|
|
144
|
+
* the same way the device does: apostrophes are dropped outright (so "Douwe's
|
|
145
|
+
* iPhone" is "Douwes-iPhone", not "Douwe-s-iPhone") and every other run of
|
|
146
|
+
* non-alphanumerics collapses to a single dash.
|
|
147
|
+
*/
|
|
148
|
+
function bonjourHostname(deviceName) {
|
|
149
|
+
return (deviceName
|
|
150
|
+
.replace(/['’]/g, '')
|
|
151
|
+
.replace(/[^A-Za-z0-9-]+/g, '-')
|
|
152
|
+
.replace(/-+/g, '-')
|
|
153
|
+
.replace(/^-|-$/g, '') + '.local');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Candidate LAN addresses, best first. devicectl's `potentialHostnames` already
|
|
157
|
+
* contain Apple's own sanitization of the device name, so re-pointing those at
|
|
158
|
+
* `.local` is more reliable than our derivation — we keep both because the
|
|
159
|
+
* hostname list is occasionally absent.
|
|
160
|
+
*/
|
|
161
|
+
function deviceHostCandidates(device) {
|
|
162
|
+
const fromDevicectl = device.potentialHostnames
|
|
163
|
+
.filter((h) => h.endsWith('.coredevice.local'))
|
|
164
|
+
.map((h) => h.replace(/\.coredevice\.local$/, '.local'));
|
|
165
|
+
return [
|
|
166
|
+
...new Set([bonjourHostname(device.name), ...fromDevicectl, ...device.potentialHostnames]),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
async function resolveDeviceHost(device) {
|
|
170
|
+
const cached = _hostCache.get(device.identifier);
|
|
171
|
+
if (cached)
|
|
172
|
+
return cached;
|
|
173
|
+
const candidates = deviceHostCandidates(device);
|
|
174
|
+
for (const host of candidates) {
|
|
175
|
+
try {
|
|
176
|
+
const { address } = await promises_1.default.lookup(host, { family: 4 });
|
|
177
|
+
(0, verbose_js_1.log)(`Resolved ${device.name} → ${host} (${address})`);
|
|
178
|
+
_hostCache.set(device.identifier, address);
|
|
179
|
+
return address;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* try the next candidate */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`Could not resolve a network address for "${device.name}".\n` +
|
|
186
|
+
`Tried: ${candidates.join(', ')}\n` +
|
|
187
|
+
`The device must be on the same network as this Mac for conductor to reach its driver.`);
|
|
188
|
+
}
|
|
189
|
+
// ── App lifecycle ─────────────────────────────────────────────────────────────
|
|
190
|
+
async function installApp(deviceId, appPath) {
|
|
191
|
+
await devicectl(['device', 'install', 'app', '--device', deviceId, appPath], 300000);
|
|
192
|
+
}
|
|
193
|
+
async function uninstallApp(deviceId, bundleId) {
|
|
194
|
+
await devicectl(['device', 'uninstall', 'app', '--device', deviceId, bundleId], 60000);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Launch an app. `env` is passed as devicectl's JSON environment dictionary,
|
|
198
|
+
* which is the device-side equivalent of simctl's SIMCTL_CHILD_ vars.
|
|
199
|
+
*/
|
|
200
|
+
async function launchApp(deviceId, bundleId, args = [], env) {
|
|
201
|
+
const flags = ['device', 'process', 'launch', '--device', deviceId, '--terminate-existing'];
|
|
202
|
+
if (env && Object.keys(env).length > 0) {
|
|
203
|
+
flags.push('--environment-variables', JSON.stringify(env));
|
|
204
|
+
}
|
|
205
|
+
await devicectl([...flags, bundleId, ...args], 120000);
|
|
206
|
+
}
|
|
207
|
+
/** Terminate an app by bundle id. devicectl only kills by PID, so resolve one first. */
|
|
208
|
+
async function terminateApp(deviceId, bundleId) {
|
|
209
|
+
const listed = await devicectlJson([
|
|
210
|
+
'device',
|
|
211
|
+
'info',
|
|
212
|
+
'processes',
|
|
213
|
+
'--device',
|
|
214
|
+
deviceId,
|
|
215
|
+
]);
|
|
216
|
+
const match = (listed.result?.runningProcesses ?? []).find((p) => p.executable?.includes(`${bundleId}`));
|
|
217
|
+
if (!match)
|
|
218
|
+
return;
|
|
219
|
+
await devicectl([
|
|
220
|
+
'device',
|
|
221
|
+
'process',
|
|
222
|
+
'signal',
|
|
223
|
+
'--device',
|
|
224
|
+
deviceId,
|
|
225
|
+
'--signal',
|
|
226
|
+
'SIGKILL',
|
|
227
|
+
'--pid',
|
|
228
|
+
String(match.processIdentifier),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
async function listApps(deviceId) {
|
|
232
|
+
const listed = await devicectlJson([
|
|
233
|
+
'device',
|
|
234
|
+
'info',
|
|
235
|
+
'apps',
|
|
236
|
+
'--device',
|
|
237
|
+
deviceId,
|
|
238
|
+
]);
|
|
239
|
+
return (listed.result?.apps ?? []).map((a) => ({
|
|
240
|
+
id: a.bundleIdentifier,
|
|
241
|
+
name: a.name ?? a.bundleIdentifier,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
@@ -137,7 +137,16 @@ function parseFlowString(content, extraEnv) {
|
|
|
137
137
|
else if (doc && typeof doc === 'object') {
|
|
138
138
|
// Single-document flow: either a header-only or treat as single command
|
|
139
139
|
const keys = Object.keys(doc);
|
|
140
|
-
const headerKeys = new Set([
|
|
140
|
+
const headerKeys = new Set([
|
|
141
|
+
'appId',
|
|
142
|
+
'url',
|
|
143
|
+
'name',
|
|
144
|
+
'env',
|
|
145
|
+
'tags',
|
|
146
|
+
'properties',
|
|
147
|
+
'onFlowStart',
|
|
148
|
+
'onFlowComplete',
|
|
149
|
+
]);
|
|
141
150
|
if (keys.every((k) => headerKeys.has(k))) {
|
|
142
151
|
header = doc;
|
|
143
152
|
rawCommands = [];
|
package/dist/drivers/ios.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -16,6 +49,7 @@ const promises_1 = __importDefault(require("fs/promises"));
|
|
|
16
49
|
const os_1 = __importDefault(require("os"));
|
|
17
50
|
const path_1 = __importDefault(require("path"));
|
|
18
51
|
const child_process_1 = require("child_process");
|
|
52
|
+
const devicectl = __importStar(require("./devicectl.js"));
|
|
19
53
|
/**
|
|
20
54
|
* How long a captured view hierarchy may be reused. Bounds the staleness of a
|
|
21
55
|
* cached snapshot when the screen changes without a driver-issued command
|
|
@@ -24,11 +58,18 @@ const child_process_1 = require("child_process");
|
|
|
24
58
|
*/
|
|
25
59
|
const HIERARCHY_CACHE_TTL_MS = 750;
|
|
26
60
|
class IOSDriver {
|
|
27
|
-
constructor(port = 1075, host = '127.0.0.1', deviceId, platform = 'ios'
|
|
61
|
+
constructor(port = 1075, host = '127.0.0.1', deviceId, platform = 'ios',
|
|
62
|
+
/**
|
|
63
|
+
* Physical devices route app lifecycle through devicectl instead of simctl,
|
|
64
|
+
* and can't offer the simulator-only conveniences (clipboard, location,
|
|
65
|
+
* TCC pre-approval, video capture).
|
|
66
|
+
*/
|
|
67
|
+
isPhysical = false) {
|
|
28
68
|
this.port = port;
|
|
29
69
|
this.host = host;
|
|
30
70
|
this.deviceId = deviceId;
|
|
31
71
|
this.platform = platform;
|
|
72
|
+
this.isPhysical = isPhysical;
|
|
32
73
|
this._recordingProcess = null;
|
|
33
74
|
/**
|
|
34
75
|
* Short-lived cache of the most recent view hierarchy, keyed by request
|
|
@@ -38,6 +79,12 @@ class IOSDriver {
|
|
|
38
79
|
*/
|
|
39
80
|
this.hierarchyCache = null;
|
|
40
81
|
}
|
|
82
|
+
/** Reject a simulator-only operation with a message that names the alternative. */
|
|
83
|
+
unsupportedOnDevice(operation, alternative) {
|
|
84
|
+
throw new Error(`${operation} is not supported on physical ${this.platform === 'tvos' ? 'tvOS' : 'iOS'} devices` +
|
|
85
|
+
(alternative ? ` — ${alternative}` : '') +
|
|
86
|
+
'.');
|
|
87
|
+
}
|
|
41
88
|
request(method, path, body) {
|
|
42
89
|
return new Promise((resolve, reject) => {
|
|
43
90
|
const bodyBuf = body !== undefined ? Buffer.from(JSON.stringify(body), 'utf-8') : undefined;
|
|
@@ -193,7 +240,18 @@ class IOSDriver {
|
|
|
193
240
|
for (const [key, value] of Object.entries(args ?? {})) {
|
|
194
241
|
argPairs.push(`-${key}`, value);
|
|
195
242
|
}
|
|
196
|
-
if (inject) {
|
|
243
|
+
if (this.isPhysical && (inject || argPairs.length > 0)) {
|
|
244
|
+
// devicectl is the device-side equivalent of `simctl launch`: it takes
|
|
245
|
+
// both launch arguments and an environment dictionary.
|
|
246
|
+
const deviceId = this.requireDeviceId();
|
|
247
|
+
await devicectl.launchApp(deviceId, bundleId, argPairs, inject
|
|
248
|
+
? {
|
|
249
|
+
DYLD_INSERT_LIBRARIES: inject.dylibPath,
|
|
250
|
+
CONDUCTOR_INPROC_PORT: String(inject.inprocPort),
|
|
251
|
+
}
|
|
252
|
+
: undefined);
|
|
253
|
+
}
|
|
254
|
+
else if (inject) {
|
|
197
255
|
// Injection requires simctl launch with SIMCTL_CHILD_ env — the XCTest
|
|
198
256
|
// /launchApp path only activates and can't set environment.
|
|
199
257
|
const deviceId = this.requireDeviceId();
|
|
@@ -220,6 +278,13 @@ class IOSDriver {
|
|
|
220
278
|
}
|
|
221
279
|
async clearAppState(bundleId) {
|
|
222
280
|
const deviceId = this.requireDeviceId();
|
|
281
|
+
if (this.isPhysical) {
|
|
282
|
+
// No get_app_container on device, so there's no bundle to reinstall from;
|
|
283
|
+
// the caller has to supply the .app again via install-app.
|
|
284
|
+
await devicectl.uninstallApp(deviceId, bundleId);
|
|
285
|
+
this.invalidateHierarchyCache();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
223
288
|
// Terminate first to prevent app from saving state after clear
|
|
224
289
|
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
225
290
|
// Capture the .app bundle path before uninstalling — uninstall deletes the UUID directory
|
|
@@ -240,26 +305,40 @@ class IOSDriver {
|
|
|
240
305
|
}
|
|
241
306
|
async uninstallApp(bundleId) {
|
|
242
307
|
const deviceId = this.requireDeviceId();
|
|
243
|
-
|
|
244
|
-
|
|
308
|
+
if (this.isPhysical) {
|
|
309
|
+
await devicectl.uninstallApp(deviceId, bundleId);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
313
|
+
await this.simctl(['uninstall', deviceId, bundleId]);
|
|
314
|
+
}
|
|
245
315
|
this.invalidateHierarchyCache();
|
|
246
316
|
}
|
|
247
317
|
async clearKeychain() {
|
|
318
|
+
if (this.isPhysical)
|
|
319
|
+
this.unsupportedOnDevice('clear-keychain');
|
|
248
320
|
const deviceId = this.requireDeviceId();
|
|
249
321
|
await this.simctl(['keychain', deviceId, 'reset']);
|
|
250
322
|
}
|
|
251
323
|
async openLink(url) {
|
|
324
|
+
if (this.isPhysical) {
|
|
325
|
+
this.unsupportedOnDevice('open-link', 'devicectl has no openurl equivalent');
|
|
326
|
+
}
|
|
252
327
|
const deviceId = this.requireDeviceId();
|
|
253
328
|
await this.simctl(['openurl', deviceId, url]);
|
|
254
329
|
this.invalidateHierarchyCache();
|
|
255
330
|
}
|
|
256
331
|
/** Read the simulator's clipboard. Uses `xcrun simctl pbpaste <udid>`. */
|
|
257
332
|
async clipboardRead() {
|
|
333
|
+
if (this.isPhysical)
|
|
334
|
+
this.unsupportedOnDevice('Reading the clipboard');
|
|
258
335
|
const deviceId = this.requireDeviceId();
|
|
259
336
|
return this.simctlCapture(['pbpaste', deviceId]);
|
|
260
337
|
}
|
|
261
338
|
/** Write to the simulator's clipboard. Uses `xcrun simctl pbcopy <udid>` over stdin. */
|
|
262
339
|
async clipboardWrite(text) {
|
|
340
|
+
if (this.isPhysical)
|
|
341
|
+
this.unsupportedOnDevice('Writing the clipboard');
|
|
263
342
|
const deviceId = this.requireDeviceId();
|
|
264
343
|
await new Promise((resolve, reject) => {
|
|
265
344
|
const proc = (0, child_process_1.spawn)('xcrun', ['simctl', 'pbcopy', deviceId], {
|
|
@@ -275,6 +354,8 @@ class IOSDriver {
|
|
|
275
354
|
});
|
|
276
355
|
}
|
|
277
356
|
async setLocation(latitude, longitude) {
|
|
357
|
+
if (this.isPhysical)
|
|
358
|
+
this.unsupportedOnDevice('set-location');
|
|
278
359
|
const deviceId = this.requireDeviceId();
|
|
279
360
|
await this.simctl(['location', deviceId, 'set', `${latitude},${longitude}`]);
|
|
280
361
|
}
|
|
@@ -339,6 +420,12 @@ class IOSDriver {
|
|
|
339
420
|
mediaLibrary: 'media-library',
|
|
340
421
|
siri: 'siri',
|
|
341
422
|
};
|
|
423
|
+
// On device there's no TCC pre-approval path, so the runner's interruption
|
|
424
|
+
// monitor is the only thing that can answer permission dialogs.
|
|
425
|
+
if (this.isPhysical) {
|
|
426
|
+
await this.post('setPermissions', { permissions: expanded });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
342
429
|
const deviceId = this.requireDeviceId();
|
|
343
430
|
if (allValue !== undefined) {
|
|
344
431
|
// Best-effort bulk grant/revoke. 'all' covers TCC-managed permissions but
|
|
@@ -370,6 +457,8 @@ class IOSDriver {
|
|
|
370
457
|
await this.post('setPermissions', { permissions: expanded });
|
|
371
458
|
}
|
|
372
459
|
async addMedia(filePath) {
|
|
460
|
+
if (this.isPhysical)
|
|
461
|
+
this.unsupportedOnDevice('add-media');
|
|
373
462
|
const deviceId = this.requireDeviceId();
|
|
374
463
|
await this.simctl(['addmedia', deviceId, filePath]);
|
|
375
464
|
}
|
|
@@ -380,6 +469,9 @@ class IOSDriver {
|
|
|
380
469
|
throw new Error('getAirplaneMode is not supported on iOS simulators');
|
|
381
470
|
}
|
|
382
471
|
async startRecording(outputPath) {
|
|
472
|
+
if (this.isPhysical) {
|
|
473
|
+
this.unsupportedOnDevice('Screen recording', 'capture stills with `conductor screenshot`');
|
|
474
|
+
}
|
|
383
475
|
const deviceId = this.requireDeviceId();
|
|
384
476
|
if (this._recordingProcess)
|
|
385
477
|
await this.stopRecording();
|
package/dist/runner.js
CHANGED
|
@@ -151,12 +151,16 @@ async function getDriver(sessionName = 'default') {
|
|
|
151
151
|
(0, verbose_js_1.log)(`getDriver: platform=${platform} deviceId=${deviceId} port=${port || '(deferred)'}`);
|
|
152
152
|
let driver;
|
|
153
153
|
if (platform === 'ios') {
|
|
154
|
-
|
|
154
|
+
// Physical devices serve the driver from their own loopback, so the CLI
|
|
155
|
+
// has to reach them over the network instead of the host's.
|
|
156
|
+
const host = await (0, bootstrap_js_1.resolveDriverHost)(deviceId);
|
|
157
|
+
if (!(await (0, bootstrap_js_1.isPortOpen)(port, host))) {
|
|
155
158
|
(0, verbose_js_1.log)(`Driver not running — starting daemon for ${deviceId}...`);
|
|
156
159
|
await (0, client_js_1.startDaemon)(deviceId);
|
|
157
|
-
await waitForPort(port);
|
|
160
|
+
await waitForPort(port, undefined, undefined, host);
|
|
158
161
|
}
|
|
159
|
-
const
|
|
162
|
+
const isPhysical = (await (0, bootstrap_js_1.detectDeviceKind)(deviceId)) === 'physical';
|
|
163
|
+
const iosDriver = new ios_js_1.IOSDriver(port, host, deviceId, 'ios', isPhysical);
|
|
160
164
|
if (!(await iosDriver.isAlive())) {
|
|
161
165
|
throw new Error(`iOS XCTest driver on port ${port} is not responding.\n` +
|
|
162
166
|
`Run: conductor daemon-start --device ${deviceId}`);
|
|
@@ -164,12 +168,16 @@ async function getDriver(sessionName = 'default') {
|
|
|
164
168
|
driver = iosDriver;
|
|
165
169
|
}
|
|
166
170
|
else if (platform === 'tvos') {
|
|
167
|
-
|
|
171
|
+
// Physical devices serve the driver from their own loopback, so the CLI
|
|
172
|
+
// has to reach them over the network instead of the host's.
|
|
173
|
+
const host = await (0, bootstrap_js_1.resolveDriverHost)(deviceId);
|
|
174
|
+
if (!(await (0, bootstrap_js_1.isPortOpen)(port, host))) {
|
|
168
175
|
(0, verbose_js_1.log)(`tvOS driver not running — starting daemon for ${deviceId}...`);
|
|
169
176
|
await (0, client_js_1.startDaemon)(deviceId);
|
|
170
|
-
await waitForPort(port);
|
|
177
|
+
await waitForPort(port, undefined, undefined, host);
|
|
171
178
|
}
|
|
172
|
-
const
|
|
179
|
+
const isPhysical = (await (0, bootstrap_js_1.detectDeviceKind)(deviceId)) === 'physical';
|
|
180
|
+
const tvosDriver = new ios_js_1.IOSDriver(port, host, deviceId, 'tvos', isPhysical);
|
|
173
181
|
if (!(await tvosDriver.isAlive())) {
|
|
174
182
|
throw new Error(`tvOS XCTest driver on port ${port} is not responding.\n` +
|
|
175
183
|
`Run: conductor daemon-start --device ${deviceId}`);
|
|
@@ -403,10 +411,10 @@ async function spawnCommand(cmd, args, options) {
|
|
|
403
411
|
});
|
|
404
412
|
}
|
|
405
413
|
/** Poll until a TCP port is open, or throw after timeout. */
|
|
406
|
-
async function waitForPort(port, timeoutMs = 180000, pollMs = 500) {
|
|
414
|
+
async function waitForPort(port, timeoutMs = 180000, pollMs = 500, host = '127.0.0.1') {
|
|
407
415
|
const deadline = Date.now() + timeoutMs;
|
|
408
416
|
while (Date.now() < deadline) {
|
|
409
|
-
if (await (0, bootstrap_js_1.isPortOpen)(port))
|
|
417
|
+
if (await (0, bootstrap_js_1.isPortOpen)(port, host))
|
|
410
418
|
return;
|
|
411
419
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
412
420
|
}
|
package/package.json
CHANGED
|
@@ -41,7 +41,7 @@ conductor assert-visible "Dashboard"
|
|
|
41
41
|
| `conductor copy-text-from <element>` | Print an element's text (and copy to the iOS clipboard) |
|
|
42
42
|
| `conductor input-text <text>` | Type into the focused field |
|
|
43
43
|
| `conductor erase-text [n]` | Erase n characters (default 50) |
|
|
44
|
-
| `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`) for tvOS / Android TV / vega / roku. `--long-press` / `--duration <seconds>` holds it; `--measure` times the response (see `conductor-profiler`) |
|
|
44
|
+
| `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`, `Remote Page Up/Down` on tvOS and Android TV, and `Remote Guide` on tvOS) for tvOS / Android TV / vega / roku. `--long-press` / `--duration <seconds>` holds it; `--measure` times the response (see `conductor-profiler`) |
|
|
45
45
|
| `conductor hide-keyboard` | Dismiss the on-screen keyboard |
|
|
46
46
|
| `conductor back` | Press back |
|
|
47
47
|
| `conductor scroll [--direction down\|up\|left\|right]` | Scroll |
|
|
@@ -127,6 +127,16 @@ relaunch without the flag. (See `conductor-device-setup`.)
|
|
|
127
127
|
|
|
128
128
|
- `--device <id>` / `--device-name <name>` targets a device; `--platform <ios|android|tvos|web|vega|roku>` scopes by platform.
|
|
129
129
|
- Vega (Amazon Fire TV) is D-pad driven: navigate with `press-key "Remote Dpad …"`; coordinate `tap-on` also works. `open-link`, `set-location`, gestures, and clipboard are unsupported. See `conductor-device-setup`.
|
|
130
|
+
- Apple TV (tvOS) is focus-driven and has **no touch surface automation**: XCTest
|
|
131
|
+
refuses remote swipe gestures ("Swipe events are only implemented for iOS,
|
|
132
|
+
visionOS, and watchOS"), so `swipe`/`scroll` are unavailable. Navigate with
|
|
133
|
+
`press-key "Remote Dpad Up/Down/Left/Right"` and `"Remote Dpad Center"`; for
|
|
134
|
+
long lists use `press-key "Remote Page Up"` / `"Remote Page Down"` (tvOS 14.3+;
|
|
135
|
+
also mapped on Android TV), which move a screenful at a time when the app
|
|
136
|
+
honours them. `"Remote Guide"` (14.3+) and
|
|
137
|
+
`"Remote TV Provider"` / `"Remote One Two Three"` / `"Remote Four Colors"`
|
|
138
|
+
(18.1+) are also available. `--duration <seconds>` holds a button for
|
|
139
|
+
accelerated scrolling.
|
|
130
140
|
- Roku is D-pad only — there is no touch. `tap-on <selector>` resolves the element but presses `Select`, which activates whatever currently holds **focus**, so navigate focus onto the target with `press-key "Remote Dpad …"` first and use `tap-on` to confirm. `scroll`/`swipe` become repeated D-pad presses in the direction the content moves. `open-link` needs an app id (it becomes a channel launch parameter). Only sideloaded dev-mode channels are inspectable. See `conductor-device-setup`.
|
|
131
141
|
- Add `--json` for machine-readable output; failed assertions exit non-zero.
|
|
132
142
|
- Run a per-session daemon for many commands (see `conductor-device-setup`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: conductor-device-setup
|
|
3
|
-
description: Boot, list, and manage devices and app installs for the conductor CLI — iOS simulators, Android emulators, tvOS simulators, Vega (Amazon Fire TV) virtual devices, Roku devices, and Playwright web browsers — plus sessions, the warm-driver daemon, and the parallel device pool. Use when starting or stopping a simulator/emulator/browser, attaching to a Vega VVD or a Roku device, installing or launching an app, setting up the web driver, attaching to an already-running browser over CDP (e.g. an Electron app / its webviews), keeping the driver warm, or coordinating multiple devices for parallel agents.
|
|
3
|
+
description: Boot, list, and manage devices and app installs for the conductor CLI — iOS simulators, Android emulators, tvOS simulators, physical iOS/tvOS devices, Vega (Amazon Fire TV) virtual devices, Roku devices, and Playwright web browsers — plus sessions, the warm-driver daemon, and the parallel device pool. Use when starting or stopping a simulator/emulator/browser, attaching to a Vega VVD or a Roku device, driving a physical iPhone/iPad/Apple TV, installing or launching an app, setting up the web driver, attaching to an already-running browser over CDP (e.g. an Electron app / its webviews), keeping the driver warm, or coordinating multiple devices for parallel agents.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Conductor — device & app setup
|
|
@@ -102,6 +102,33 @@ Unsupported on Roku: `install-app`/`uninstall-app` (sideload via the device's de
|
|
|
102
102
|
web server at `http://<device-ip>`), `list-apps`, `clear-state`, gestures,
|
|
103
103
|
screen recording, clipboard, `set-location`, memory/CPU profiling, and device logs.
|
|
104
104
|
|
|
105
|
+
### Physical iOS / tvOS devices
|
|
106
|
+
|
|
107
|
+
Real iPhones, iPads, and Apple TVs work alongside simulators. They're discovered
|
|
108
|
+
through `devicectl`, show up in `list-devices` with status `connected`, and are
|
|
109
|
+
addressed by their CoreDevice identifier (`--device <uuid>`).
|
|
110
|
+
|
|
111
|
+
Requirements:
|
|
112
|
+
|
|
113
|
+
- The device is **paired** with this Mac and on the **same network** — conductor
|
|
114
|
+
reaches the driver over the LAN, not the host's loopback.
|
|
115
|
+
- **Developer Mode** is enabled on the device.
|
|
116
|
+
- A signing team: conductor builds and signs the XCTest driver locally on first
|
|
117
|
+
use (a few minutes; cached per team in `~/.conductor/<platform>-driver-device/`).
|
|
118
|
+
Set `CONDUCTOR_TEAM_ID=<team>` when the Mac has more than one development team
|
|
119
|
+
— conductor refuses to guess rather than sign with the wrong one.
|
|
120
|
+
|
|
121
|
+
Everything driven through the XCTest driver behaves the same as on a simulator:
|
|
122
|
+
`inspect`, `capture-ui`, `tap-on`, `press-key`, `swipe`, `launch-app`,
|
|
123
|
+
`terminate-app`, `install-app`, `uninstall-app`, flows.
|
|
124
|
+
|
|
125
|
+
Simulator-only — these fail with an explicit message on a physical device:
|
|
126
|
+
`set-location`, `open-link`, clipboard read/write, `clear-keychain`, `add-media`,
|
|
127
|
+
screen recording and the live video stream, and OS log collection (`conductor
|
|
128
|
+
logs` still gets **Metro** logs, which is the useful source for React Native).
|
|
129
|
+
`clear-state` uninstalls on device without reinstalling — reinstall with
|
|
130
|
+
`install-app` afterwards.
|
|
131
|
+
|
|
105
132
|
## App lifecycle
|
|
106
133
|
|
|
107
134
|
| Command | Purpose |
|