@unrulysystems/rn-playwright-driver-runner 0.1.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/rn-driver.ts +17 -0
- package/dist/cli.d.mts +3 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1898 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.mjs +1891 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +281 -0
- package/dist/index.d.ts +281 -0
- package/dist/index.js +1063 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1057 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
import { mkdtemp, readFile, writeFile, chmod, rm } from 'fs/promises';
|
|
2
|
+
import net from 'net';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { promisify, parseArgs } from 'util';
|
|
6
|
+
import { existsSync, createReadStream, openSync } from 'fs';
|
|
7
|
+
import { pathToFileURL } from 'url';
|
|
8
|
+
import { execFile, spawn } from 'child_process';
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
|
|
11
|
+
// src/cli.ts
|
|
12
|
+
|
|
13
|
+
// src/constants.ts
|
|
14
|
+
var ENV = {
|
|
15
|
+
metroUrl: "RN_METRO_URL",
|
|
16
|
+
deviceName: "RN_DEVICE_NAME",
|
|
17
|
+
timeout: "RN_TIMEOUT",
|
|
18
|
+
touchBackend: "RN_TOUCH_BACKEND",
|
|
19
|
+
xctestPort: "RN_TOUCH_XCTEST_PORT",
|
|
20
|
+
xctestTokenFile: "RN_TOUCH_XCTEST_TOKEN_FILE",
|
|
21
|
+
instrumentationPort: "RN_TOUCH_INSTRUMENTATION_PORT",
|
|
22
|
+
instrumentationTokenFile: "RN_TOUCH_INSTRUMENTATION_TOKEN_FILE",
|
|
23
|
+
androidSerial: "ANDROID_SERIAL"
|
|
24
|
+
};
|
|
25
|
+
var TOUCH_BACKEND = {
|
|
26
|
+
ios: "xctest",
|
|
27
|
+
android: "instrumentation"
|
|
28
|
+
};
|
|
29
|
+
var DEFAULTS = {
|
|
30
|
+
metroHost: "127.0.0.1",
|
|
31
|
+
metroPort: 8081,
|
|
32
|
+
metroReadyTimeoutMs: 9e4,
|
|
33
|
+
companionPort: 9999,
|
|
34
|
+
/** Covers a cold `xcodebuild test` build, not just process startup (FU-2). */
|
|
35
|
+
iosCompanionReadyTimeoutMs: 3e5,
|
|
36
|
+
androidCompanionReadyTimeoutMs: 45e3,
|
|
37
|
+
hermesTargetTimeoutMs: 6e4,
|
|
38
|
+
appLaunchAttempts: 3,
|
|
39
|
+
driverTimeoutMs: 3e4,
|
|
40
|
+
androidGradleTasks: [":app:assembleDebug", ":app:assembleDebugAndroidTest"],
|
|
41
|
+
androidAppApkPath: "android/app/build/outputs/apk/debug/app-debug.apk",
|
|
42
|
+
androidTestApkPath: "android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
|
|
43
|
+
instrumentationClass: "com.rndriver.touchcompanion.RNDriverTouchCompanion",
|
|
44
|
+
/** UI-test method the iOS companion exposes as a long-running server. */
|
|
45
|
+
xctestServerTest: "RNDriverTouchCompanionTests/testRunServer",
|
|
46
|
+
/** Device-private filename the Android companion reads its token from. */
|
|
47
|
+
androidTokenFileName: "rn-driver-touch-token"
|
|
48
|
+
};
|
|
49
|
+
var SECRET_PLACEHOLDER = "<token-file>";
|
|
50
|
+
|
|
51
|
+
// src/plan/env.ts
|
|
52
|
+
function buildIosDriverEnv(resolved, metro, timeoutMs) {
|
|
53
|
+
return {
|
|
54
|
+
[ENV.touchBackend]: TOUCH_BACKEND.ios,
|
|
55
|
+
[ENV.metroUrl]: metro.url,
|
|
56
|
+
[ENV.deviceName]: resolved.simName,
|
|
57
|
+
[ENV.timeout]: String(timeoutMs ?? DEFAULTS.driverTimeoutMs),
|
|
58
|
+
[ENV.xctestPort]: String(resolved.touchPort),
|
|
59
|
+
[ENV.xctestTokenFile]: resolved.tokenFile
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function buildAndroidDriverEnv(resolved, metro, deviceName, timeoutMs) {
|
|
63
|
+
return {
|
|
64
|
+
[ENV.touchBackend]: TOUCH_BACKEND.android,
|
|
65
|
+
[ENV.metroUrl]: metro.url,
|
|
66
|
+
[ENV.deviceName]: deviceName,
|
|
67
|
+
[ENV.androidSerial]: resolved.serial,
|
|
68
|
+
[ENV.timeout]: String(timeoutMs ?? DEFAULTS.driverTimeoutMs),
|
|
69
|
+
[ENV.instrumentationPort]: String(resolved.touchPort),
|
|
70
|
+
[ENV.instrumentationTokenFile]: resolved.tokenFile
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/plan/shared.ts
|
|
75
|
+
function cmd(command, args) {
|
|
76
|
+
return { command, args };
|
|
77
|
+
}
|
|
78
|
+
function npx(args) {
|
|
79
|
+
return { command: "npx", args };
|
|
80
|
+
}
|
|
81
|
+
function shell(command) {
|
|
82
|
+
return { command: "sh", args: ["-c", command] };
|
|
83
|
+
}
|
|
84
|
+
function metroStartStep(metro) {
|
|
85
|
+
return {
|
|
86
|
+
id: "metro.start",
|
|
87
|
+
stage: "metro",
|
|
88
|
+
description: metro.reuseExisting ? `Start Metro (reuse if running) ${metro.url}` : `Start Metro ${metro.url}`,
|
|
89
|
+
action: {
|
|
90
|
+
type: "command",
|
|
91
|
+
background: true,
|
|
92
|
+
processKey: "metro",
|
|
93
|
+
command: shell(metro.command ?? `npx expo start --localhost --port ${metro.port}`)
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function playwrightCommand(playwright, specs, passthrough) {
|
|
98
|
+
const args = ["playwright", "test"];
|
|
99
|
+
if (playwright?.config) args.push("--config", playwright.config);
|
|
100
|
+
const effectiveSpecs = specs.length > 0 ? specs : playwright?.specs ?? [];
|
|
101
|
+
args.push(...effectiveSpecs, ...passthrough);
|
|
102
|
+
args.push("--reporter=line");
|
|
103
|
+
return npx(args);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/plan/android.ts
|
|
107
|
+
function planAndroid(input) {
|
|
108
|
+
const { android, metro, resolved, playwright, timeoutMs, specs, passthrough, hermesDeviceName } = input;
|
|
109
|
+
const serial = resolved.serial;
|
|
110
|
+
const gradleTasks = android.gradleTasks ?? [...DEFAULTS.androidGradleTasks];
|
|
111
|
+
const appApk = android.appApkPath ?? DEFAULTS.androidAppApkPath;
|
|
112
|
+
const testApk = android.testApkPath ?? DEFAULTS.androidTestApkPath;
|
|
113
|
+
const steps = [];
|
|
114
|
+
const push = (step) => steps.push(step);
|
|
115
|
+
push({
|
|
116
|
+
id: "android.prebuild",
|
|
117
|
+
stage: "build",
|
|
118
|
+
description: "Generate Android project (expo prebuild)",
|
|
119
|
+
action: {
|
|
120
|
+
type: "command",
|
|
121
|
+
command: npx(["expo", "prebuild", "--platform", "android", "--no-install"])
|
|
122
|
+
},
|
|
123
|
+
skippable: true
|
|
124
|
+
});
|
|
125
|
+
push({
|
|
126
|
+
id: "android.gradle",
|
|
127
|
+
stage: "build",
|
|
128
|
+
description: `Build app + androidTest APKs (${gradleTasks.join(" ")})`,
|
|
129
|
+
action: {
|
|
130
|
+
type: "command",
|
|
131
|
+
command: { command: "./gradlew", args: gradleTasks, cwd: "android" }
|
|
132
|
+
},
|
|
133
|
+
skippable: true
|
|
134
|
+
});
|
|
135
|
+
push({
|
|
136
|
+
id: "android.install-app",
|
|
137
|
+
stage: "build",
|
|
138
|
+
description: `Install app APK`,
|
|
139
|
+
action: { type: "command", command: adb(serial, ["install", "-r", appApk]) },
|
|
140
|
+
skippable: true
|
|
141
|
+
});
|
|
142
|
+
push({
|
|
143
|
+
id: "android.install-test",
|
|
144
|
+
stage: "build",
|
|
145
|
+
description: `Install androidTest APK`,
|
|
146
|
+
action: { type: "command", command: adb(serial, ["install", "-r", "-t", testApk]) },
|
|
147
|
+
skippable: true
|
|
148
|
+
});
|
|
149
|
+
push({
|
|
150
|
+
id: "android.install-token",
|
|
151
|
+
stage: "companion",
|
|
152
|
+
description: "Install companion token into app private files",
|
|
153
|
+
action: {
|
|
154
|
+
type: "command",
|
|
155
|
+
command: {
|
|
156
|
+
...adbShellScript(
|
|
157
|
+
serial,
|
|
158
|
+
`run-as ${android.packageName} sh -c 'mkdir -p files && cat > files/${resolved.deviceTokenFileName} && chmod 600 files/${resolved.deviceTokenFileName}'`
|
|
159
|
+
),
|
|
160
|
+
stdinFromFile: resolved.tokenFile
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
push(metroStartStep(metro));
|
|
165
|
+
push({
|
|
166
|
+
id: "metro.ready",
|
|
167
|
+
stage: "metro",
|
|
168
|
+
description: `Wait for Metro at ${metro.url}`,
|
|
169
|
+
action: {
|
|
170
|
+
type: "probe",
|
|
171
|
+
probe: { kind: "metro-status", metroUrl: metro.url, timeoutMs: metro.readyTimeoutMs }
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
push({
|
|
175
|
+
id: "android.reverse-metro",
|
|
176
|
+
stage: "device",
|
|
177
|
+
description: `adb reverse tcp:${metro.port}`,
|
|
178
|
+
action: {
|
|
179
|
+
type: "command",
|
|
180
|
+
command: adb(serial, ["reverse", `tcp:${metro.port}`, `tcp:${metro.port}`])
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
if (metro.port !== DEFAULTS.metroPort) {
|
|
184
|
+
push({
|
|
185
|
+
id: "android.reverse-default",
|
|
186
|
+
stage: "device",
|
|
187
|
+
description: `adb reverse tcp:${DEFAULTS.metroPort} -> tcp:${metro.port}`,
|
|
188
|
+
action: {
|
|
189
|
+
type: "command",
|
|
190
|
+
command: adb(serial, ["reverse", `tcp:${DEFAULTS.metroPort}`, `tcp:${metro.port}`])
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
push({
|
|
195
|
+
id: "android.debug-host",
|
|
196
|
+
stage: "device",
|
|
197
|
+
description: "Write app debug_http_host",
|
|
198
|
+
action: {
|
|
199
|
+
type: "command",
|
|
200
|
+
command: {
|
|
201
|
+
// Single adb-shell arg so the redirect runs inside the run-as'd app-uid
|
|
202
|
+
// shell (the shared_prefs path is app-private; the outer `shell` uid
|
|
203
|
+
// cannot write it).
|
|
204
|
+
...adbShellScript(
|
|
205
|
+
serial,
|
|
206
|
+
`run-as ${android.packageName} sh -c 'mkdir -p /data/data/${android.packageName}/shared_prefs && cat > /data/data/${android.packageName}/shared_prefs/${android.packageName}_preferences.xml'`
|
|
207
|
+
),
|
|
208
|
+
stdinContents: debugHostXml(`localhost:${metro.port}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
const launchCommand = launchCommandFor(android, serial);
|
|
213
|
+
push(launchStep("android.launch-1", android, serial));
|
|
214
|
+
push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launchCommand));
|
|
215
|
+
push({
|
|
216
|
+
id: "android.forward-clean",
|
|
217
|
+
stage: "companion",
|
|
218
|
+
description: `Clear stale adb forward tcp:${resolved.touchPort}`,
|
|
219
|
+
action: {
|
|
220
|
+
type: "command",
|
|
221
|
+
command: adb(serial, ["forward", "--remove", `tcp:${resolved.touchPort}`]),
|
|
222
|
+
allowFailure: true
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
push({
|
|
226
|
+
id: "android.forward",
|
|
227
|
+
stage: "companion",
|
|
228
|
+
description: `adb forward tcp:${resolved.touchPort}`,
|
|
229
|
+
action: {
|
|
230
|
+
type: "command",
|
|
231
|
+
command: adb(serial, ["forward", `tcp:${resolved.touchPort}`, `tcp:${resolved.touchPort}`])
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
push({
|
|
235
|
+
id: "android.instrument-start",
|
|
236
|
+
stage: "companion",
|
|
237
|
+
description: `Start instrumentation companion on port ${resolved.touchPort}`,
|
|
238
|
+
action: {
|
|
239
|
+
type: "command",
|
|
240
|
+
background: true,
|
|
241
|
+
processKey: "companion",
|
|
242
|
+
command: adb(serial, [
|
|
243
|
+
"shell",
|
|
244
|
+
"am",
|
|
245
|
+
"instrument",
|
|
246
|
+
"-e",
|
|
247
|
+
"rnDriverAuthTokenFile",
|
|
248
|
+
resolved.deviceTokenFileName,
|
|
249
|
+
"-e",
|
|
250
|
+
"rnDriverPort",
|
|
251
|
+
String(resolved.touchPort),
|
|
252
|
+
"-w",
|
|
253
|
+
resolved.instrumentationTarget
|
|
254
|
+
])
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
push({
|
|
258
|
+
id: "android.instrument-ready",
|
|
259
|
+
stage: "companion",
|
|
260
|
+
description: "Wait for companion to accept a hello",
|
|
261
|
+
action: {
|
|
262
|
+
type: "probe",
|
|
263
|
+
probe: {
|
|
264
|
+
kind: "instrumentation-hello",
|
|
265
|
+
port: resolved.touchPort,
|
|
266
|
+
tokenFile: resolved.tokenFile,
|
|
267
|
+
timeoutMs: resolved.companionReadyTimeoutMs
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
push(launchStep("android.launch-2", android, serial));
|
|
272
|
+
push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launchCommand));
|
|
273
|
+
const cleanup = [
|
|
274
|
+
{
|
|
275
|
+
type: "kill-process",
|
|
276
|
+
processKey: "companion",
|
|
277
|
+
description: "Stop instrumentation companion"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
type: "command",
|
|
281
|
+
command: adb(serial, ["reverse", "--remove", `tcp:${metro.port}`]),
|
|
282
|
+
description: "Remove metro reverse"
|
|
283
|
+
},
|
|
284
|
+
// Mirror android.reverse-default: when Metro is off 8081 the plan also adds a
|
|
285
|
+
// `tcp:8081 -> tcp:<port>` fallback reverse, so cleanup must remove BOTH or a
|
|
286
|
+
// stale 8081 mapping wedges the next run (REQ-CLEAN-003).
|
|
287
|
+
...metro.port === DEFAULTS.metroPort ? [] : [
|
|
288
|
+
{
|
|
289
|
+
type: "command",
|
|
290
|
+
command: adb(serial, ["reverse", "--remove", `tcp:${DEFAULTS.metroPort}`]),
|
|
291
|
+
description: "Remove fallback 8081 metro reverse"
|
|
292
|
+
}
|
|
293
|
+
],
|
|
294
|
+
{
|
|
295
|
+
type: "command",
|
|
296
|
+
command: adb(serial, ["forward", "--remove", `tcp:${resolved.touchPort}`]),
|
|
297
|
+
description: "Remove companion forward"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
type: "command",
|
|
301
|
+
command: adb(serial, [
|
|
302
|
+
"shell",
|
|
303
|
+
"run-as",
|
|
304
|
+
android.packageName,
|
|
305
|
+
"rm",
|
|
306
|
+
"-f",
|
|
307
|
+
`files/${resolved.deviceTokenFileName}`
|
|
308
|
+
]),
|
|
309
|
+
description: "Remove device token file"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
type: "command",
|
|
313
|
+
command: adb(serial, ["shell", "am", "force-stop", android.packageName]),
|
|
314
|
+
description: "Force-stop app"
|
|
315
|
+
},
|
|
316
|
+
{ type: "kill-process", processKey: "metro", description: "Stop runner-owned Metro" },
|
|
317
|
+
{ type: "remove-file", path: resolved.tokenFile, description: "Remove per-run token file" }
|
|
318
|
+
];
|
|
319
|
+
return {
|
|
320
|
+
platform: "android",
|
|
321
|
+
steps,
|
|
322
|
+
cleanup,
|
|
323
|
+
driverEnv: buildAndroidDriverEnv(resolved, metro, hermesDeviceName, timeoutMs),
|
|
324
|
+
playwright: playwrightCommand(playwright, specs, passthrough)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function launchCommandFor(android, serial) {
|
|
328
|
+
return adb(serial, [
|
|
329
|
+
"shell",
|
|
330
|
+
"am",
|
|
331
|
+
"start",
|
|
332
|
+
"-W",
|
|
333
|
+
"-n",
|
|
334
|
+
`${android.packageName}/${android.activity}`
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
function launchStep(id, android, serial) {
|
|
338
|
+
return {
|
|
339
|
+
id,
|
|
340
|
+
stage: "app-launch",
|
|
341
|
+
description: `Launch ${android.packageName}/${android.activity}`,
|
|
342
|
+
action: { type: "command", command: launchCommandFor(android, serial) }
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function hermesStep(id, android, metro, resolved, deviceNameMatch, launchCommand) {
|
|
346
|
+
return {
|
|
347
|
+
id,
|
|
348
|
+
stage: "hermes-target",
|
|
349
|
+
description: "Wait for Hermes target",
|
|
350
|
+
action: {
|
|
351
|
+
type: "probe",
|
|
352
|
+
probe: {
|
|
353
|
+
kind: "hermes-target",
|
|
354
|
+
platform: "android",
|
|
355
|
+
metroUrl: metro.url,
|
|
356
|
+
appId: android.packageName,
|
|
357
|
+
deviceNameMatch,
|
|
358
|
+
timeoutMs: resolved.hermesTimeoutMs
|
|
359
|
+
},
|
|
360
|
+
// REQ-AND-005: on a transient miss, re-issue `am start` and re-probe.
|
|
361
|
+
// `appLaunchAttempts` total attempts ⇒ that many minus the first = retries.
|
|
362
|
+
retry: { command: launchCommand, max: DEFAULTS.appLaunchAttempts - 1 }
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function debugHostXml(host) {
|
|
367
|
+
return `<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
|
|
368
|
+
<map>
|
|
369
|
+
<string name="debug_http_host">${host}</string>
|
|
370
|
+
</map>
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
function adb(serial, args) {
|
|
374
|
+
return { command: "adb", args: ["-s", serial, ...args] };
|
|
375
|
+
}
|
|
376
|
+
function adbShellScript(serial, remote) {
|
|
377
|
+
return { command: "adb", args: ["-s", serial, "shell", remote] };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/plan/ios.ts
|
|
381
|
+
function planIos(input) {
|
|
382
|
+
const { ios, metro, resolved, playwright, timeoutMs, specs, passthrough } = input;
|
|
383
|
+
const isDevClient = ios.launch.kind === "expo-dev-client";
|
|
384
|
+
const steps = [];
|
|
385
|
+
const push = (step) => steps.push(step);
|
|
386
|
+
push({
|
|
387
|
+
id: "ios.boot",
|
|
388
|
+
stage: "device",
|
|
389
|
+
description: `Boot simulator ${resolved.simName}`,
|
|
390
|
+
action: {
|
|
391
|
+
type: "command",
|
|
392
|
+
command: xcrun(["simctl", "boot", resolved.simUdid]),
|
|
393
|
+
allowFailure: true
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
push({
|
|
397
|
+
id: "ios.boot-wait",
|
|
398
|
+
stage: "device",
|
|
399
|
+
description: `Wait for ${resolved.simName} to finish booting`,
|
|
400
|
+
action: { type: "command", command: xcrun(["simctl", "bootstatus", resolved.simUdid, "-b"]) }
|
|
401
|
+
});
|
|
402
|
+
push({
|
|
403
|
+
id: "ios.prebuild",
|
|
404
|
+
stage: "build",
|
|
405
|
+
description: "Generate iOS project (expo prebuild)",
|
|
406
|
+
action: {
|
|
407
|
+
type: "command",
|
|
408
|
+
command: npx(["expo", "prebuild", "--platform", "ios", "--no-install"])
|
|
409
|
+
},
|
|
410
|
+
skippable: true
|
|
411
|
+
});
|
|
412
|
+
push({
|
|
413
|
+
id: "ios.scaffold",
|
|
414
|
+
stage: "build",
|
|
415
|
+
description: "Scaffold XCTest companion target",
|
|
416
|
+
action: {
|
|
417
|
+
type: "command",
|
|
418
|
+
// Pass the resolved UI-test scheme so a custom `ios.uitestScheme` scaffolds
|
|
419
|
+
// the SAME target that companion startup later builds (default is
|
|
420
|
+
// `${appScheme}UITests`).
|
|
421
|
+
command: npx([
|
|
422
|
+
"rn-driver-xctest-scaffold",
|
|
423
|
+
"--ios-dir",
|
|
424
|
+
"ios",
|
|
425
|
+
"--project-name",
|
|
426
|
+
ios.appScheme,
|
|
427
|
+
"--uitest-scheme",
|
|
428
|
+
resolved.uitestScheme
|
|
429
|
+
])
|
|
430
|
+
},
|
|
431
|
+
skippable: true
|
|
432
|
+
});
|
|
433
|
+
push({
|
|
434
|
+
id: "ios.runtime-config",
|
|
435
|
+
stage: "build",
|
|
436
|
+
description: "Write companion runtime config (port + token-file ref)",
|
|
437
|
+
action: {
|
|
438
|
+
type: "write-file",
|
|
439
|
+
path: resolved.runtimeConfigFile,
|
|
440
|
+
contents: runtimeConfigJson(resolved, ios),
|
|
441
|
+
mode: 384
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
push({
|
|
445
|
+
id: "ios.pods",
|
|
446
|
+
stage: "build",
|
|
447
|
+
description: "Install CocoaPods",
|
|
448
|
+
action: { type: "command", command: cmd("pod", ["install", "--project-directory=ios"]) },
|
|
449
|
+
skippable: true
|
|
450
|
+
});
|
|
451
|
+
push(metroStartStep(metro));
|
|
452
|
+
push({
|
|
453
|
+
id: "metro.ready",
|
|
454
|
+
stage: "metro",
|
|
455
|
+
description: `Wait for Metro at ${metro.url}`,
|
|
456
|
+
action: {
|
|
457
|
+
type: "probe",
|
|
458
|
+
probe: { kind: "metro-status", metroUrl: metro.url, timeoutMs: metro.readyTimeoutMs }
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
push({
|
|
462
|
+
id: "ios.packager-host-location",
|
|
463
|
+
stage: "device",
|
|
464
|
+
description: "Point app at Metro (RCT_jsLocation)",
|
|
465
|
+
action: {
|
|
466
|
+
type: "command",
|
|
467
|
+
command: xcrun([
|
|
468
|
+
"simctl",
|
|
469
|
+
"spawn",
|
|
470
|
+
resolved.simUdid,
|
|
471
|
+
"defaults",
|
|
472
|
+
"write",
|
|
473
|
+
ios.bundleId,
|
|
474
|
+
"RCT_jsLocation",
|
|
475
|
+
`${metro.host}:${metro.port}`
|
|
476
|
+
]),
|
|
477
|
+
allowFailure: true
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
push({
|
|
481
|
+
id: "ios.packager-host-scheme",
|
|
482
|
+
stage: "device",
|
|
483
|
+
description: "Point app at Metro (RCT_packager_scheme)",
|
|
484
|
+
action: {
|
|
485
|
+
type: "command",
|
|
486
|
+
command: xcrun([
|
|
487
|
+
"simctl",
|
|
488
|
+
"spawn",
|
|
489
|
+
resolved.simUdid,
|
|
490
|
+
"defaults",
|
|
491
|
+
"write",
|
|
492
|
+
ios.bundleId,
|
|
493
|
+
"RCT_packager_scheme",
|
|
494
|
+
"http"
|
|
495
|
+
]),
|
|
496
|
+
allowFailure: true
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
push({
|
|
500
|
+
id: "ios.build-app",
|
|
501
|
+
stage: "build",
|
|
502
|
+
description: `Build app scheme ${ios.appScheme}`,
|
|
503
|
+
action: {
|
|
504
|
+
type: "command",
|
|
505
|
+
command: xcodebuild([
|
|
506
|
+
"build",
|
|
507
|
+
"-workspace",
|
|
508
|
+
ios.workspace,
|
|
509
|
+
"-scheme",
|
|
510
|
+
ios.appScheme,
|
|
511
|
+
"-destination",
|
|
512
|
+
resolved.destination,
|
|
513
|
+
`RCT_METRO_PORT=${metro.port}`
|
|
514
|
+
])
|
|
515
|
+
},
|
|
516
|
+
skippable: true
|
|
517
|
+
});
|
|
518
|
+
for (const [key, value] of Object.entries(ios.defaults ?? {})) {
|
|
519
|
+
push({
|
|
520
|
+
id: `ios.seed.${key}`,
|
|
521
|
+
stage: "device",
|
|
522
|
+
description: `Seed default ${key}`,
|
|
523
|
+
action: {
|
|
524
|
+
type: "command",
|
|
525
|
+
command: xcrun([
|
|
526
|
+
"simctl",
|
|
527
|
+
"spawn",
|
|
528
|
+
resolved.simUdid,
|
|
529
|
+
"defaults",
|
|
530
|
+
"write",
|
|
531
|
+
ios.bundleId,
|
|
532
|
+
...defaultsArgs(key, value)
|
|
533
|
+
]),
|
|
534
|
+
allowFailure: true
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
push({
|
|
539
|
+
id: "ios.free-port",
|
|
540
|
+
stage: "companion",
|
|
541
|
+
description: `Free stale listener on port ${resolved.touchPort}`,
|
|
542
|
+
action: { type: "free-port", port: resolved.touchPort }
|
|
543
|
+
});
|
|
544
|
+
push({
|
|
545
|
+
id: "ios.companion-start",
|
|
546
|
+
stage: "companion",
|
|
547
|
+
description: `Start XCTest companion on port ${resolved.touchPort}`,
|
|
548
|
+
action: {
|
|
549
|
+
type: "command",
|
|
550
|
+
background: true,
|
|
551
|
+
processKey: "companion",
|
|
552
|
+
command: xcodebuild(
|
|
553
|
+
[
|
|
554
|
+
"test",
|
|
555
|
+
"-workspace",
|
|
556
|
+
ios.workspace,
|
|
557
|
+
"-scheme",
|
|
558
|
+
resolved.uitestScheme,
|
|
559
|
+
"-destination",
|
|
560
|
+
resolved.destination,
|
|
561
|
+
`-only-testing:${resolved.uitestScheme}/${DEFAULTS.xctestServerTest}`,
|
|
562
|
+
`RCT_METRO_PORT=${metro.port}`
|
|
563
|
+
],
|
|
564
|
+
{
|
|
565
|
+
RN_TOUCH_XCTEST_PORT: String(resolved.touchPort),
|
|
566
|
+
RN_TOUCH_XCTEST_CONFIG_FILE: resolved.runtimeConfigFile
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
push({
|
|
572
|
+
id: "ios.companion-ready",
|
|
573
|
+
stage: "companion",
|
|
574
|
+
description: "Wait for companion to accept a hello",
|
|
575
|
+
action: {
|
|
576
|
+
type: "probe",
|
|
577
|
+
probe: {
|
|
578
|
+
kind: "xctest-hello",
|
|
579
|
+
port: resolved.touchPort,
|
|
580
|
+
tokenFile: resolved.tokenFile,
|
|
581
|
+
timeoutMs: resolved.companionReadyTimeoutMs
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
if (isDevClient) {
|
|
586
|
+
push({
|
|
587
|
+
id: "ios.terminate-before-launch",
|
|
588
|
+
stage: "app-launch",
|
|
589
|
+
description: "Terminate any running instance (cold launch requires it)",
|
|
590
|
+
action: {
|
|
591
|
+
type: "command",
|
|
592
|
+
command: xcrun(["simctl", "terminate", resolved.simUdid, ios.bundleId]),
|
|
593
|
+
allowFailure: true
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
push({
|
|
597
|
+
id: "ios.launch",
|
|
598
|
+
stage: "app-launch",
|
|
599
|
+
description: `Cold-launch dev client via --initialUrl ${resolved.initialUrl}`,
|
|
600
|
+
action: {
|
|
601
|
+
type: "command",
|
|
602
|
+
command: xcrun([
|
|
603
|
+
"simctl",
|
|
604
|
+
"launch",
|
|
605
|
+
resolved.simUdid,
|
|
606
|
+
ios.bundleId,
|
|
607
|
+
"--initialUrl",
|
|
608
|
+
resolved.initialUrl
|
|
609
|
+
])
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
push({
|
|
614
|
+
id: "ios.hermes",
|
|
615
|
+
stage: "hermes-target",
|
|
616
|
+
description: "Wait for Hermes target",
|
|
617
|
+
action: {
|
|
618
|
+
type: "probe",
|
|
619
|
+
probe: {
|
|
620
|
+
kind: "hermes-target",
|
|
621
|
+
platform: "ios",
|
|
622
|
+
metroUrl: metro.url,
|
|
623
|
+
appId: ios.bundleId,
|
|
624
|
+
deviceNameMatch: resolved.simName,
|
|
625
|
+
timeoutMs: resolved.hermesTimeoutMs
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
const cleanup = [
|
|
630
|
+
{ type: "kill-process", processKey: "companion", description: "Stop XCTest companion" },
|
|
631
|
+
{
|
|
632
|
+
type: "free-port",
|
|
633
|
+
port: resolved.touchPort,
|
|
634
|
+
description: "Free companion port (reap sim-hosted child)"
|
|
635
|
+
},
|
|
636
|
+
{ type: "kill-process", processKey: "metro", description: "Stop runner-owned Metro" },
|
|
637
|
+
{ type: "remove-file", path: resolved.tokenFile, description: "Remove per-run token file" },
|
|
638
|
+
// REQ-SEC-004: the per-run runtime config (port + token-file ref) is written
|
|
639
|
+
// into the UI-test target every run; remove it so no generated artifact is
|
|
640
|
+
// left in the app project.
|
|
641
|
+
{
|
|
642
|
+
type: "remove-file",
|
|
643
|
+
path: resolved.runtimeConfigFile,
|
|
644
|
+
description: "Remove per-run companion runtime config"
|
|
645
|
+
}
|
|
646
|
+
];
|
|
647
|
+
return {
|
|
648
|
+
platform: "ios",
|
|
649
|
+
steps,
|
|
650
|
+
cleanup,
|
|
651
|
+
driverEnv: buildIosDriverEnv(resolved, metro, timeoutMs),
|
|
652
|
+
playwright: playwrightCommand(playwright, specs, passthrough)
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function runtimeConfigJson(resolved, ios) {
|
|
656
|
+
return JSON.stringify({
|
|
657
|
+
port: resolved.touchPort,
|
|
658
|
+
authTokenFile: resolved.tokenFile,
|
|
659
|
+
launch: ios.launch.mode
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
function defaultsArgs(key, value) {
|
|
663
|
+
if (typeof value === "boolean") return [key, "-bool", value ? "YES" : "NO"];
|
|
664
|
+
if (typeof value === "number") return [key, "-int", String(value)];
|
|
665
|
+
return [key, value];
|
|
666
|
+
}
|
|
667
|
+
function xcrun(args) {
|
|
668
|
+
return { command: "xcrun", args };
|
|
669
|
+
}
|
|
670
|
+
function xcodebuild(args, env) {
|
|
671
|
+
return env ? { command: "env", args: ["-u", "LD", "xcodebuild", ...args], env } : { command: "env", args: ["-u", "LD", "xcodebuild", ...args] };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/plan/resolved.ts
|
|
675
|
+
function resolveMetro(metro, overrides = {}) {
|
|
676
|
+
const host = overrides.host ?? metro?.host ?? DEFAULTS.metroHost;
|
|
677
|
+
const fromUrl = parseMetroUrl(overrides.url ?? metro?.url);
|
|
678
|
+
const port = fromUrl?.port ?? overrides.port ?? metro?.port ?? DEFAULTS.metroPort;
|
|
679
|
+
const resolvedHost = fromUrl?.host ?? host;
|
|
680
|
+
const url = fromUrl?.url ?? `http://${resolvedHost}:${port}`;
|
|
681
|
+
return {
|
|
682
|
+
url,
|
|
683
|
+
host: resolvedHost,
|
|
684
|
+
port,
|
|
685
|
+
command: metro?.command,
|
|
686
|
+
reuseExisting: metro?.reuseExisting ?? false,
|
|
687
|
+
readyTimeoutMs: metro?.readyTimeoutMs ?? DEFAULTS.metroReadyTimeoutMs
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function parseMetroUrl(raw) {
|
|
691
|
+
if (!raw) return void 0;
|
|
692
|
+
const parsed = new URL(raw);
|
|
693
|
+
const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80;
|
|
694
|
+
return { url: raw.replace(/\/$/, ""), host: parsed.hostname, port };
|
|
695
|
+
}
|
|
696
|
+
function uitestScheme(ios) {
|
|
697
|
+
return ios.uitestScheme ?? `${ios.appScheme}UITests`;
|
|
698
|
+
}
|
|
699
|
+
function instrumentationTarget(android) {
|
|
700
|
+
return android.instrumentationTarget ?? `${android.packageName}.test/${DEFAULTS.instrumentationClass}`;
|
|
701
|
+
}
|
|
702
|
+
function placeholderIos(ios, metro) {
|
|
703
|
+
return {
|
|
704
|
+
simUdid: "<sim-udid>",
|
|
705
|
+
simName: "<sim-name>",
|
|
706
|
+
destination: ios.destination ?? "platform=iOS Simulator,id=<sim-udid>",
|
|
707
|
+
uitestScheme: uitestScheme(ios),
|
|
708
|
+
touchPort: ios.companion?.port ?? DEFAULTS.companionPort,
|
|
709
|
+
companionReadyTimeoutMs: ios.companion?.readyTimeoutMs ?? DEFAULTS.iosCompanionReadyTimeoutMs,
|
|
710
|
+
hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
|
|
711
|
+
tokenFile: SECRET_PLACEHOLDER,
|
|
712
|
+
runtimeConfigFile: "<runtime-config>",
|
|
713
|
+
initialUrl: ios.launch.initialUrl ?? metro.url
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function placeholderAndroid(android, _metro) {
|
|
717
|
+
return {
|
|
718
|
+
serial: "<android-serial>",
|
|
719
|
+
touchPort: android.companion?.port ?? DEFAULTS.companionPort,
|
|
720
|
+
companionReadyTimeoutMs: android.companion?.readyTimeoutMs ?? DEFAULTS.androidCompanionReadyTimeoutMs,
|
|
721
|
+
hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
|
|
722
|
+
tokenFile: SECRET_PLACEHOLDER,
|
|
723
|
+
deviceTokenFileName: DEFAULTS.androidTokenFileName,
|
|
724
|
+
instrumentationTarget: instrumentationTarget(android)
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/build-plan.ts
|
|
729
|
+
function buildDryRunPlan(config, platform, opts = {}) {
|
|
730
|
+
const metro = resolveMetro(config.metro, opts.metroOverrides ?? {});
|
|
731
|
+
const specs = opts.specs ?? [];
|
|
732
|
+
const passthrough = opts.passthrough ?? [];
|
|
733
|
+
if (platform === "ios") {
|
|
734
|
+
const ios = config.ios;
|
|
735
|
+
if (!ios) throw new Error("config.ios is required to plan the ios platform");
|
|
736
|
+
return planIos({
|
|
737
|
+
ios,
|
|
738
|
+
metro,
|
|
739
|
+
resolved: placeholderIos(ios, metro),
|
|
740
|
+
playwright: config.playwright,
|
|
741
|
+
timeoutMs: config.timeoutMs,
|
|
742
|
+
specs,
|
|
743
|
+
passthrough
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const android = config.android;
|
|
747
|
+
if (!android) throw new Error("config.android is required to plan the android platform");
|
|
748
|
+
return planAndroid({
|
|
749
|
+
android,
|
|
750
|
+
metro,
|
|
751
|
+
resolved: placeholderAndroid(android),
|
|
752
|
+
playwright: config.playwright,
|
|
753
|
+
timeoutMs: config.timeoutMs,
|
|
754
|
+
specs,
|
|
755
|
+
passthrough,
|
|
756
|
+
hermesDeviceName: "<android-device>"
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
var DEFAULT_CONFIG_NAMES = [
|
|
760
|
+
"rn-driver.config.ts",
|
|
761
|
+
"rn-driver.config.mts",
|
|
762
|
+
"rn-driver.config.mjs",
|
|
763
|
+
"rn-driver.config.js"
|
|
764
|
+
];
|
|
765
|
+
var ConfigNotFoundError = class extends Error {
|
|
766
|
+
constructor(searchedFrom, configPath) {
|
|
767
|
+
super(
|
|
768
|
+
configPath ? `Config file not found: ${configPath}` : `No rn-driver.config.{ts,mts,mjs,js} found searching up from ${searchedFrom}`
|
|
769
|
+
);
|
|
770
|
+
this.name = "ConfigNotFoundError";
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
var defaultImporter = (absolutePath) => import(pathToFileURL(absolutePath).href);
|
|
774
|
+
async function loadConfig(opts) {
|
|
775
|
+
const importer = opts.importer ?? defaultImporter;
|
|
776
|
+
const fileExists = opts.fileExists ?? existsSync;
|
|
777
|
+
const resolvedPath = opts.configPath ? path.resolve(opts.cwd, opts.configPath) : findConfigUp(opts.cwd, fileExists);
|
|
778
|
+
if (!resolvedPath || !fileExists(resolvedPath)) {
|
|
779
|
+
throw new ConfigNotFoundError(opts.cwd, opts.configPath);
|
|
780
|
+
}
|
|
781
|
+
const namespace = await importer(resolvedPath);
|
|
782
|
+
const config = extractDefault(namespace);
|
|
783
|
+
return { path: resolvedPath, config };
|
|
784
|
+
}
|
|
785
|
+
function extractDefault(namespace) {
|
|
786
|
+
if (namespace && typeof namespace === "object" && "default" in namespace) {
|
|
787
|
+
return namespace.default;
|
|
788
|
+
}
|
|
789
|
+
return namespace;
|
|
790
|
+
}
|
|
791
|
+
function findConfigUp(startDir, fileExists) {
|
|
792
|
+
let dir = path.resolve(startDir);
|
|
793
|
+
for (; ; ) {
|
|
794
|
+
for (const name of DEFAULT_CONFIG_NAMES) {
|
|
795
|
+
const candidate = path.join(dir, name);
|
|
796
|
+
if (fileExists(candidate)) return candidate;
|
|
797
|
+
}
|
|
798
|
+
const parent = path.dirname(dir);
|
|
799
|
+
if (parent === dir) return void 0;
|
|
800
|
+
dir = parent;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/print-plan.ts
|
|
805
|
+
function renderPlan(plan) {
|
|
806
|
+
const lines = [];
|
|
807
|
+
lines.push(`Plan (${plan.platform}) \u2014 ${plan.steps.length} steps`);
|
|
808
|
+
for (const step of plan.steps) {
|
|
809
|
+
const tag = `[${step.stage}]`.padEnd(15);
|
|
810
|
+
const skip = step.skippable ? " (skip-build: skipped)" : "";
|
|
811
|
+
lines.push(` ${tag}${step.id} \u2014 ${step.description}${skip}`);
|
|
812
|
+
lines.push(` ${renderAction(step.action)}`);
|
|
813
|
+
}
|
|
814
|
+
lines.push("");
|
|
815
|
+
lines.push("Driver env (handed to Playwright):");
|
|
816
|
+
for (const [key, value] of Object.entries(plan.driverEnv)) {
|
|
817
|
+
lines.push(` ${key}=${value}`);
|
|
818
|
+
}
|
|
819
|
+
lines.push("");
|
|
820
|
+
lines.push("Playwright:");
|
|
821
|
+
lines.push(` ${renderCommand(plan.playwright)}`);
|
|
822
|
+
lines.push("");
|
|
823
|
+
lines.push("Cleanup (defensive, idempotent):");
|
|
824
|
+
for (const action of plan.cleanup) {
|
|
825
|
+
lines.push(` - ${action.description}: ${renderCleanup(action)}`);
|
|
826
|
+
}
|
|
827
|
+
return lines.join("\n");
|
|
828
|
+
}
|
|
829
|
+
function renderAction(action) {
|
|
830
|
+
switch (action.type) {
|
|
831
|
+
case "command":
|
|
832
|
+
return `${action.background ? "spawn " : "$ "}${renderCommand(action.command)}${action.allowFailure ? " (best-effort)" : ""}`;
|
|
833
|
+
case "write-file":
|
|
834
|
+
return `write ${action.path}${action.mode ? ` (mode ${action.mode.toString(8)})` : ""}`;
|
|
835
|
+
case "free-port":
|
|
836
|
+
return `free-port ${action.port}`;
|
|
837
|
+
case "probe":
|
|
838
|
+
return `probe ${renderProbe(action.probe)}`;
|
|
839
|
+
default: {
|
|
840
|
+
const _exhaustive = action;
|
|
841
|
+
throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function renderCleanup(action) {
|
|
846
|
+
switch (action.type) {
|
|
847
|
+
case "kill-process":
|
|
848
|
+
return `kill ${action.processKey}`;
|
|
849
|
+
case "free-port":
|
|
850
|
+
return `free-port ${action.port}`;
|
|
851
|
+
case "remove-file":
|
|
852
|
+
return `rm ${action.path}`;
|
|
853
|
+
case "command":
|
|
854
|
+
return `$ ${renderCommand(action.command)}`;
|
|
855
|
+
default: {
|
|
856
|
+
const _exhaustive = action;
|
|
857
|
+
throw new Error(`unhandled cleanup: ${JSON.stringify(_exhaustive)}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function renderCommand(command) {
|
|
862
|
+
const env = command.env ? `${Object.entries(command.env).map(([k, v]) => `${k}=${v}`).join(" ")} ` : "";
|
|
863
|
+
const stdin = command.stdinFromFile ? ` < ${command.stdinFromFile}` : command.stdinContents ? " < <stdin>" : "";
|
|
864
|
+
const cwd = command.cwd ? ` (cwd: ${command.cwd})` : "";
|
|
865
|
+
return `${env}${command.command} ${command.args.join(" ")}${stdin}${cwd}`.trim();
|
|
866
|
+
}
|
|
867
|
+
function renderProbe(probe) {
|
|
868
|
+
switch (probe.kind) {
|
|
869
|
+
case "metro-status":
|
|
870
|
+
return `metro-status ${probe.metroUrl} (\u2264${probe.timeoutMs}ms)`;
|
|
871
|
+
case "hermes-target":
|
|
872
|
+
return `hermes-target ${probe.appId}${probe.deviceNameMatch ? ` @ ${probe.deviceNameMatch}` : ""} (\u2264${probe.timeoutMs}ms)`;
|
|
873
|
+
case "xctest-hello":
|
|
874
|
+
return `xctest-hello :${probe.port} (\u2264${probe.timeoutMs}ms)`;
|
|
875
|
+
case "instrumentation-hello":
|
|
876
|
+
return `instrumentation-hello :${probe.port} (\u2264${probe.timeoutMs}ms)`;
|
|
877
|
+
default: {
|
|
878
|
+
const _exhaustive = probe;
|
|
879
|
+
throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/runner/execute.ts
|
|
885
|
+
var StageError = class extends Error {
|
|
886
|
+
stage;
|
|
887
|
+
stepId;
|
|
888
|
+
constructor(stage, stepId, message) {
|
|
889
|
+
super(`[${stage}] ${stepId}: ${message}`);
|
|
890
|
+
this.name = "StageError";
|
|
891
|
+
this.stage = stage;
|
|
892
|
+
this.stepId = stepId;
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
async function executePlan(plan, runner, opts) {
|
|
896
|
+
const processes = /* @__PURE__ */ new Map();
|
|
897
|
+
const isAlive = (key) => {
|
|
898
|
+
const handle = processes.get(key);
|
|
899
|
+
return handle ? runner.isAlive(handle) : true;
|
|
900
|
+
};
|
|
901
|
+
try {
|
|
902
|
+
for (const step of plan.steps) {
|
|
903
|
+
if (opts.skipBuild && step.skippable) {
|
|
904
|
+
if (opts.verbose) runner.log(`skip (skip-build): ${step.id}`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (opts.skipStep && await opts.skipStep(step)) {
|
|
908
|
+
if (opts.verbose) runner.log(`skip: ${step.id}`);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
await runStep(step, runner, opts, processes, isAlive);
|
|
912
|
+
}
|
|
913
|
+
runner.log(`Running Playwright: ${plan.playwright.command} ${plan.playwright.args.join(" ")}`);
|
|
914
|
+
const result = await runner.exec({
|
|
915
|
+
...plan.playwright,
|
|
916
|
+
env: { ...plan.playwright.env, ...plan.driverEnv }
|
|
917
|
+
});
|
|
918
|
+
return { playwrightCode: result.code };
|
|
919
|
+
} finally {
|
|
920
|
+
await runCleanup(plan.cleanup, runner, opts, processes);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function runStep(step, runner, opts, processes, isAlive) {
|
|
924
|
+
if (opts.verbose) runner.log(`\u2192 ${step.id}: ${step.description}`);
|
|
925
|
+
const action = step.action;
|
|
926
|
+
switch (action.type) {
|
|
927
|
+
case "command": {
|
|
928
|
+
if (action.background) {
|
|
929
|
+
const key = action.processKey ?? step.id;
|
|
930
|
+
const handle = runner.spawn(action.command, { key, logPath: logPathFor(opts.logDir, key) });
|
|
931
|
+
processes.set(key, handle);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const result = await runner.exec(action.command);
|
|
935
|
+
if (result.code !== 0 && !action.allowFailure) {
|
|
936
|
+
throw new StageError(step.stage, step.id, `command exited ${result.code}`);
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
case "write-file": {
|
|
941
|
+
await runner.writeFile(action.path, action.contents, action.mode);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
case "free-port": {
|
|
945
|
+
await runner.freePort(action.port);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
case "probe": {
|
|
949
|
+
const key = processKeyForProbe(action.probe);
|
|
950
|
+
const aliveFn = key === null ? () => true : () => isAlive(key);
|
|
951
|
+
let ready = await runner.probe(action.probe, aliveFn);
|
|
952
|
+
let remaining = action.retry?.max ?? 0;
|
|
953
|
+
while (!ready && remaining > 0) {
|
|
954
|
+
remaining -= 1;
|
|
955
|
+
if (action.retry) {
|
|
956
|
+
runner.log(`retry ${step.id}: re-running launch (${remaining} attempt(s) left)`);
|
|
957
|
+
await runner.exec(action.retry.command);
|
|
958
|
+
}
|
|
959
|
+
ready = await runner.probe(action.probe, aliveFn);
|
|
960
|
+
}
|
|
961
|
+
if (!ready) {
|
|
962
|
+
throw new StageError(
|
|
963
|
+
step.stage,
|
|
964
|
+
step.id,
|
|
965
|
+
`readiness timed out after ${action.probe.timeoutMs}ms`
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
default: {
|
|
971
|
+
const _exhaustive = action;
|
|
972
|
+
throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async function runCleanup(actions, runner, opts, processes) {
|
|
977
|
+
for (const action of actions) {
|
|
978
|
+
if (opts.skipCleanup && opts.skipCleanup(action)) continue;
|
|
979
|
+
try {
|
|
980
|
+
switch (action.type) {
|
|
981
|
+
case "kill-process": {
|
|
982
|
+
const handle = processes.get(action.processKey);
|
|
983
|
+
if (handle) await runner.kill(handle);
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case "free-port":
|
|
987
|
+
await runner.freePort(action.port);
|
|
988
|
+
break;
|
|
989
|
+
case "remove-file":
|
|
990
|
+
await runner.removeFile(action.path);
|
|
991
|
+
break;
|
|
992
|
+
case "command":
|
|
993
|
+
await runner.exec(action.command);
|
|
994
|
+
break;
|
|
995
|
+
default: {
|
|
996
|
+
const _exhaustive = action;
|
|
997
|
+
throw new Error(`unhandled cleanup: ${JSON.stringify(_exhaustive)}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
runner.log(`cleanup ${action.description} failed: ${String(error)}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function processKeyForProbe(probe) {
|
|
1006
|
+
switch (probe.kind) {
|
|
1007
|
+
case "metro-status":
|
|
1008
|
+
return "metro";
|
|
1009
|
+
case "xctest-hello":
|
|
1010
|
+
case "instrumentation-hello":
|
|
1011
|
+
return "companion";
|
|
1012
|
+
case "hermes-target":
|
|
1013
|
+
return null;
|
|
1014
|
+
default: {
|
|
1015
|
+
const _exhaustive = probe;
|
|
1016
|
+
throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
function logPathFor(logDir, key) {
|
|
1021
|
+
return `${logDir}/${key}.log`;
|
|
1022
|
+
}
|
|
1023
|
+
var PROBE_INTERVAL_MS = 1e3;
|
|
1024
|
+
var NodeProcessRunner = class {
|
|
1025
|
+
children = /* @__PURE__ */ new Map();
|
|
1026
|
+
exec(spec, _opts) {
|
|
1027
|
+
return new Promise((resolve, reject) => {
|
|
1028
|
+
const usesStdin = Boolean(spec.stdinFromFile) || spec.stdinContents !== void 0;
|
|
1029
|
+
const child = spawn(spec.command, [...spec.args], {
|
|
1030
|
+
cwd: spec.cwd,
|
|
1031
|
+
env: { ...process.env, ...spec.env },
|
|
1032
|
+
stdio: [usesStdin ? "pipe" : "inherit", "inherit", "inherit"]
|
|
1033
|
+
});
|
|
1034
|
+
child.on("error", reject);
|
|
1035
|
+
if (usesStdin && child.stdin) {
|
|
1036
|
+
if (spec.stdinFromFile) {
|
|
1037
|
+
createReadStream(spec.stdinFromFile).pipe(child.stdin);
|
|
1038
|
+
} else {
|
|
1039
|
+
child.stdin.end(spec.stdinContents ?? "");
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr: "" }));
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
spawn(spec, opts) {
|
|
1046
|
+
const fd = openSync(opts.logPath, "a");
|
|
1047
|
+
const child = spawn(spec.command, [...spec.args], {
|
|
1048
|
+
cwd: spec.cwd,
|
|
1049
|
+
env: { ...process.env, ...spec.env },
|
|
1050
|
+
detached: true,
|
|
1051
|
+
stdio: ["ignore", fd, fd]
|
|
1052
|
+
});
|
|
1053
|
+
child.unref();
|
|
1054
|
+
this.children.set(opts.key, child);
|
|
1055
|
+
return { key: opts.key, pid: child.pid };
|
|
1056
|
+
}
|
|
1057
|
+
isAlive(handle) {
|
|
1058
|
+
const child = this.children.get(handle.key);
|
|
1059
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return false;
|
|
1060
|
+
if (handle.pid === void 0) return false;
|
|
1061
|
+
try {
|
|
1062
|
+
process.kill(handle.pid, 0);
|
|
1063
|
+
return true;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async kill(handle) {
|
|
1069
|
+
const child = this.children.get(handle.key);
|
|
1070
|
+
if (!child || handle.pid === void 0) return;
|
|
1071
|
+
killGroup(handle.pid, "SIGTERM");
|
|
1072
|
+
await delay(500);
|
|
1073
|
+
if (this.isAlive(handle)) killGroup(handle.pid, "SIGKILL");
|
|
1074
|
+
this.children.delete(handle.key);
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Best-effort synchronous teardown for signal handlers: SIGKILL every tracked
|
|
1078
|
+
* child's process group so a Ctrl-C / SIGTERM does not orphan the detached
|
|
1079
|
+
* Metro/companion processes (REQ-CLEAN-001, signal clause). Synchronous because
|
|
1080
|
+
* a signal handler cannot await before `process.exit`.
|
|
1081
|
+
*/
|
|
1082
|
+
killAll() {
|
|
1083
|
+
for (const child of this.children.values()) {
|
|
1084
|
+
if (child.pid !== void 0) killGroup(child.pid, "SIGKILL");
|
|
1085
|
+
}
|
|
1086
|
+
this.children.clear();
|
|
1087
|
+
}
|
|
1088
|
+
async writeFile(path4, contents, mode) {
|
|
1089
|
+
await writeFile(path4, contents);
|
|
1090
|
+
if (mode !== void 0) await chmod(path4, mode);
|
|
1091
|
+
}
|
|
1092
|
+
async removeFile(path4) {
|
|
1093
|
+
await rm(path4, { force: true });
|
|
1094
|
+
}
|
|
1095
|
+
async freePort(port) {
|
|
1096
|
+
const pids = await this.lsofPids(port);
|
|
1097
|
+
for (const pid of pids) {
|
|
1098
|
+
try {
|
|
1099
|
+
process.kill(pid, "SIGTERM");
|
|
1100
|
+
} catch {
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (pids.length > 0) await delay(1e3);
|
|
1104
|
+
}
|
|
1105
|
+
probe(probe, isAlive) {
|
|
1106
|
+
const deadline = Date.now() + probe.timeoutMs;
|
|
1107
|
+
const attempt = async () => {
|
|
1108
|
+
for (; ; ) {
|
|
1109
|
+
if (!isAlive()) return false;
|
|
1110
|
+
if (await probeOnce(probe)) return true;
|
|
1111
|
+
if (Date.now() >= deadline) return false;
|
|
1112
|
+
await delay(PROBE_INTERVAL_MS);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
return attempt();
|
|
1116
|
+
}
|
|
1117
|
+
log(line) {
|
|
1118
|
+
process.stderr.write(`${line}
|
|
1119
|
+
`);
|
|
1120
|
+
}
|
|
1121
|
+
lsofPids(port) {
|
|
1122
|
+
return new Promise((resolve) => {
|
|
1123
|
+
const child = spawn("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
1124
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1125
|
+
});
|
|
1126
|
+
let out = "";
|
|
1127
|
+
child.stdout?.on("data", (chunk) => {
|
|
1128
|
+
out += chunk.toString();
|
|
1129
|
+
});
|
|
1130
|
+
child.on("error", () => resolve([]));
|
|
1131
|
+
child.on("close", () => {
|
|
1132
|
+
const pids = out.split("\n").map((line) => Number.parseInt(line.trim(), 10)).filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
1133
|
+
resolve(pids);
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
function killGroup(pid, signal) {
|
|
1139
|
+
try {
|
|
1140
|
+
process.kill(-pid, signal);
|
|
1141
|
+
} catch {
|
|
1142
|
+
try {
|
|
1143
|
+
process.kill(pid, signal);
|
|
1144
|
+
} catch {
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function delay(ms) {
|
|
1149
|
+
return new Promise((resolve) => {
|
|
1150
|
+
setTimeout(resolve, ms);
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
async function probeOnce(probe) {
|
|
1154
|
+
switch (probe.kind) {
|
|
1155
|
+
case "metro-status":
|
|
1156
|
+
return metroStatusOk(probe.metroUrl);
|
|
1157
|
+
case "hermes-target":
|
|
1158
|
+
return hermesTargetPresent(probe);
|
|
1159
|
+
case "xctest-hello":
|
|
1160
|
+
return xctestHelloOk(probe.port, probe.tokenFile);
|
|
1161
|
+
case "instrumentation-hello":
|
|
1162
|
+
return instrumentationHelloOk(probe.port, probe.tokenFile);
|
|
1163
|
+
default: {
|
|
1164
|
+
const _exhaustive = probe;
|
|
1165
|
+
throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
async function metroStatusOk(metroUrl) {
|
|
1170
|
+
try {
|
|
1171
|
+
const response = await fetch(`${metroUrl}/status`, {
|
|
1172
|
+
signal: AbortSignal.timeout(PROBE_INTERVAL_MS)
|
|
1173
|
+
});
|
|
1174
|
+
const body = await response.text();
|
|
1175
|
+
return body.includes("packager-status:running");
|
|
1176
|
+
} catch {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
async function hermesTargetPresent(probe) {
|
|
1181
|
+
try {
|
|
1182
|
+
const response = await fetch(`${probe.metroUrl}/json`, {
|
|
1183
|
+
signal: AbortSignal.timeout(PROBE_INTERVAL_MS)
|
|
1184
|
+
});
|
|
1185
|
+
if (!response.ok) return false;
|
|
1186
|
+
const targets = await response.json();
|
|
1187
|
+
return targets.some((target) => {
|
|
1188
|
+
const isReactNative = String(target.title ?? "").includes("Hermes") || target.vm === "Hermes" || String(target.description ?? "").includes("React Native");
|
|
1189
|
+
if (!isReactNative || target.appId !== probe.appId) return false;
|
|
1190
|
+
if (!probe.deviceNameMatch) return true;
|
|
1191
|
+
return String(target.deviceName ?? "").includes(probe.deviceNameMatch);
|
|
1192
|
+
});
|
|
1193
|
+
} catch {
|
|
1194
|
+
return false;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
async function xctestHelloOk(port, tokenFile) {
|
|
1198
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
1199
|
+
if (!WebSocketCtor)
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
"global WebSocket is required for the xctest probe (run under bun or Node >= 22)"
|
|
1202
|
+
);
|
|
1203
|
+
const authToken = await readTokenFile(tokenFile);
|
|
1204
|
+
return new Promise((resolve) => {
|
|
1205
|
+
const socket = new WebSocketCtor(`ws://127.0.0.1:${port}`);
|
|
1206
|
+
const timer = setTimeout(() => {
|
|
1207
|
+
close(socket);
|
|
1208
|
+
resolve(false);
|
|
1209
|
+
}, PROBE_INTERVAL_MS);
|
|
1210
|
+
socket.addEventListener("open", () => {
|
|
1211
|
+
socket.send(
|
|
1212
|
+
JSON.stringify({
|
|
1213
|
+
id: 1,
|
|
1214
|
+
type: "hello",
|
|
1215
|
+
protocolVersion: 1,
|
|
1216
|
+
client: "rn-driver-runner",
|
|
1217
|
+
...authToken ? { authToken } : {}
|
|
1218
|
+
})
|
|
1219
|
+
);
|
|
1220
|
+
});
|
|
1221
|
+
socket.addEventListener("message", (event) => {
|
|
1222
|
+
clearTimeout(timer);
|
|
1223
|
+
close(socket);
|
|
1224
|
+
try {
|
|
1225
|
+
const payload = JSON.parse(String(event.data));
|
|
1226
|
+
resolve(payload.id === 1 && payload.ok === true);
|
|
1227
|
+
} catch {
|
|
1228
|
+
resolve(false);
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
socket.addEventListener("error", () => {
|
|
1232
|
+
clearTimeout(timer);
|
|
1233
|
+
resolve(false);
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
async function instrumentationHelloOk(port, tokenFile) {
|
|
1238
|
+
try {
|
|
1239
|
+
const token = await readTokenFile(tokenFile);
|
|
1240
|
+
const controller = new AbortController();
|
|
1241
|
+
const timer = setTimeout(() => controller.abort(), PROBE_INTERVAL_MS);
|
|
1242
|
+
const response = await fetch(`http://127.0.0.1:${port}/command`, {
|
|
1243
|
+
method: "POST",
|
|
1244
|
+
headers: {
|
|
1245
|
+
"content-type": "application/json",
|
|
1246
|
+
...token ? { "x-rn-driver-auth": token } : {}
|
|
1247
|
+
},
|
|
1248
|
+
body: JSON.stringify({ type: "hello" }),
|
|
1249
|
+
signal: controller.signal
|
|
1250
|
+
});
|
|
1251
|
+
clearTimeout(timer);
|
|
1252
|
+
const payload = await response.json().catch(() => void 0);
|
|
1253
|
+
return response.ok && payload?.ok === true;
|
|
1254
|
+
} catch {
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
async function readTokenFile(path4) {
|
|
1259
|
+
try {
|
|
1260
|
+
const token = (await readFile(path4, "utf8")).trim();
|
|
1261
|
+
return token === "" ? void 0 : token;
|
|
1262
|
+
} catch {
|
|
1263
|
+
return void 0;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function close(socket) {
|
|
1267
|
+
try {
|
|
1268
|
+
socket.close();
|
|
1269
|
+
} catch {
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
var run = promisify(execFile);
|
|
1273
|
+
async function resolveIosTarget(ios, metro, opts) {
|
|
1274
|
+
const { udid, name } = await selectSimulator(ios, opts.device);
|
|
1275
|
+
await terminateStaleOnOtherSims(udid, ios.bundleId);
|
|
1276
|
+
const tokenFile = await mintTokenFile();
|
|
1277
|
+
const scheme = uitestScheme(ios);
|
|
1278
|
+
return {
|
|
1279
|
+
simUdid: udid,
|
|
1280
|
+
simName: name,
|
|
1281
|
+
destination: ios.destination ?? `platform=iOS Simulator,id=${udid}`,
|
|
1282
|
+
uitestScheme: scheme,
|
|
1283
|
+
touchPort: ios.companion?.port ?? DEFAULTS.companionPort,
|
|
1284
|
+
companionReadyTimeoutMs: ios.companion?.readyTimeoutMs ?? DEFAULTS.iosCompanionReadyTimeoutMs,
|
|
1285
|
+
hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
|
|
1286
|
+
tokenFile,
|
|
1287
|
+
runtimeConfigFile: path.join("ios", scheme, "RNDriverTouchCompanionRuntimeConfig.json"),
|
|
1288
|
+
initialUrl: ios.launch.initialUrl ?? metro.url
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
async function resolveAndroidTarget(android, _metro, opts) {
|
|
1292
|
+
const serial = await selectSerial(opts.device);
|
|
1293
|
+
await requireBooted(serial);
|
|
1294
|
+
const deviceName = (await capture("adb", ["-s", serial, "shell", "getprop", "ro.product.model"])).trim();
|
|
1295
|
+
const tokenFile = await mintTokenFile();
|
|
1296
|
+
return {
|
|
1297
|
+
resolved: {
|
|
1298
|
+
serial,
|
|
1299
|
+
touchPort: android.companion?.port ?? DEFAULTS.companionPort,
|
|
1300
|
+
companionReadyTimeoutMs: android.companion?.readyTimeoutMs ?? DEFAULTS.androidCompanionReadyTimeoutMs,
|
|
1301
|
+
hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
|
|
1302
|
+
tokenFile,
|
|
1303
|
+
deviceTokenFileName: DEFAULTS.androidTokenFileName,
|
|
1304
|
+
instrumentationTarget: instrumentationTarget(android)
|
|
1305
|
+
},
|
|
1306
|
+
deviceName
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
async function selectSimulator(ios, deviceOverride) {
|
|
1310
|
+
const data = JSON.parse(
|
|
1311
|
+
await capture("xcrun", ["simctl", "list", "devices", "available", "--json"])
|
|
1312
|
+
);
|
|
1313
|
+
const all = Object.entries(data.devices ?? {}).flatMap(([runtime, list]) => list.map((device) => ({ ...device, runtime }))).filter((device) => device.isAvailable !== false);
|
|
1314
|
+
return pickSimulator(all, deviceOverride, ios.destination);
|
|
1315
|
+
}
|
|
1316
|
+
function pickSimulator(devices, deviceOverride, destination) {
|
|
1317
|
+
const explicitUdid = parseUdid(deviceOverride) ?? parseUdid(destination);
|
|
1318
|
+
if (explicitUdid) {
|
|
1319
|
+
const match = devices.find((device) => device.udid === explicitUdid);
|
|
1320
|
+
if (!match) throw new Error(`requested iOS simulator not found: ${explicitUdid}`);
|
|
1321
|
+
return { udid: match.udid, name: match.name };
|
|
1322
|
+
}
|
|
1323
|
+
if (deviceOverride) {
|
|
1324
|
+
const byName = devices.find((device) => device.name === deviceOverride) ?? devices.find((device) => device.name.includes(deviceOverride));
|
|
1325
|
+
if (!byName) throw new Error(`requested iOS simulator not found by name: ${deviceOverride}`);
|
|
1326
|
+
return { udid: byName.udid, name: byName.name };
|
|
1327
|
+
}
|
|
1328
|
+
const byNewest = (a, b) => compareRuntime(runtimeVersion(b.runtime), runtimeVersion(a.runtime));
|
|
1329
|
+
const iphones = devices.filter((device) => device.name.startsWith("iPhone"));
|
|
1330
|
+
const booted = iphones.filter((device) => device.state === "Booted").sort(byNewest);
|
|
1331
|
+
const pick = booted[0] ?? [...iphones].sort(byNewest)[0];
|
|
1332
|
+
if (!pick) throw new Error("no available iPhone simulator found");
|
|
1333
|
+
return { udid: pick.udid, name: pick.name };
|
|
1334
|
+
}
|
|
1335
|
+
async function terminateStaleOnOtherSims(keepUdid, bundleId) {
|
|
1336
|
+
const booted = await capture("xcrun", ["simctl", "list", "devices", "booted"]);
|
|
1337
|
+
const udids = booted.match(/[0-9A-Fa-f-]{36}/g) ?? [];
|
|
1338
|
+
for (const udid of udids) {
|
|
1339
|
+
if (udid === keepUdid) continue;
|
|
1340
|
+
await capture("xcrun", ["simctl", "terminate", udid, bundleId]).catch(() => "");
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
async function selectSerial(deviceOverride) {
|
|
1344
|
+
await capture("adb", ["start-server"]).catch(() => "");
|
|
1345
|
+
if (deviceOverride) {
|
|
1346
|
+
await run("adb", ["-s", deviceOverride, "get-state"]);
|
|
1347
|
+
return deviceOverride;
|
|
1348
|
+
}
|
|
1349
|
+
const devices = await capture("adb", ["devices"]);
|
|
1350
|
+
const serial = devices.split("\n").slice(1).map((line) => line.trim().split(/\s+/)).find((cols) => cols[1] === "device" && cols[0]?.startsWith("emulator-"))?.[0];
|
|
1351
|
+
if (!serial) throw new Error("no booted emulator found in `adb devices`");
|
|
1352
|
+
return serial;
|
|
1353
|
+
}
|
|
1354
|
+
async function requireBooted(serial) {
|
|
1355
|
+
const state = (await capture("adb", ["-s", serial, "get-state"])).trim();
|
|
1356
|
+
if (state !== "device") throw new Error(`adb device ${serial} is not ready (state: ${state})`);
|
|
1357
|
+
const booted = (await capture("adb", ["-s", serial, "shell", "getprop", "sys.boot_completed"])).trim();
|
|
1358
|
+
if (booted !== "1") throw new Error(`adb device ${serial} has not completed boot`);
|
|
1359
|
+
}
|
|
1360
|
+
async function mintTokenFile() {
|
|
1361
|
+
const dir = await mkdtemp(path.join(tmpdir(), "rn-driver-token-"));
|
|
1362
|
+
const file = path.join(dir, "token");
|
|
1363
|
+
await writeFile(file, randomBytes(16).toString("hex"));
|
|
1364
|
+
await chmod(file, 384);
|
|
1365
|
+
return file;
|
|
1366
|
+
}
|
|
1367
|
+
async function capture(command, args) {
|
|
1368
|
+
const { stdout } = await run(command, args, { maxBuffer: 16 * 1024 * 1024 });
|
|
1369
|
+
return stdout.toString();
|
|
1370
|
+
}
|
|
1371
|
+
function parseUdid(value) {
|
|
1372
|
+
if (!value) return void 0;
|
|
1373
|
+
const match = value.match(/id=([0-9A-Fa-f-]{36})/) ?? value.match(/^([0-9A-Fa-f-]{36})$/);
|
|
1374
|
+
return match?.[1];
|
|
1375
|
+
}
|
|
1376
|
+
function runtimeVersion(runtime) {
|
|
1377
|
+
const match = runtime.match(/iOS-([0-9-]+)$/);
|
|
1378
|
+
return match?.[1] ? match[1].split("-").map((part) => Number.parseInt(part, 10)) : [0];
|
|
1379
|
+
}
|
|
1380
|
+
function compareRuntime(a, b) {
|
|
1381
|
+
const length = Math.max(a.length, b.length);
|
|
1382
|
+
for (let index = 0; index < length; index += 1) {
|
|
1383
|
+
const delta = (a[index] ?? 0) - (b[index] ?? 0);
|
|
1384
|
+
if (delta !== 0) return delta;
|
|
1385
|
+
}
|
|
1386
|
+
return 0;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/validate.ts
|
|
1390
|
+
var LAUNCH_MODES = ["launch", "activate", "attach"];
|
|
1391
|
+
var LAUNCH_KINDS = ["plain", "expo-dev-client"];
|
|
1392
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["metro", "ios", "android", "playwright", "timeoutMs"]);
|
|
1393
|
+
var METRO_KEYS = /* @__PURE__ */ new Set(["url", "command", "host", "port", "reuseExisting", "readyTimeoutMs"]);
|
|
1394
|
+
var LAUNCH_KEYS = /* @__PURE__ */ new Set(["mode", "kind", "initialUrl"]);
|
|
1395
|
+
var COMPANION_KEYS = /* @__PURE__ */ new Set(["port", "readyTimeoutMs"]);
|
|
1396
|
+
var IOS_KEYS = /* @__PURE__ */ new Set([
|
|
1397
|
+
"bundleId",
|
|
1398
|
+
"workspace",
|
|
1399
|
+
"appScheme",
|
|
1400
|
+
"uitestScheme",
|
|
1401
|
+
"destination",
|
|
1402
|
+
"launch",
|
|
1403
|
+
"companion",
|
|
1404
|
+
"defaults"
|
|
1405
|
+
]);
|
|
1406
|
+
var ANDROID_KEYS = /* @__PURE__ */ new Set([
|
|
1407
|
+
"packageName",
|
|
1408
|
+
"activity",
|
|
1409
|
+
"gradleTasks",
|
|
1410
|
+
"appApkPath",
|
|
1411
|
+
"testApkPath",
|
|
1412
|
+
"instrumentationTarget",
|
|
1413
|
+
"launch",
|
|
1414
|
+
"companion"
|
|
1415
|
+
]);
|
|
1416
|
+
var PLAYWRIGHT_KEYS = /* @__PURE__ */ new Set(["config", "specs"]);
|
|
1417
|
+
function validateConfig(config, platforms) {
|
|
1418
|
+
const errors = [];
|
|
1419
|
+
if (!isRecord(config)) {
|
|
1420
|
+
return {
|
|
1421
|
+
ok: false,
|
|
1422
|
+
errors: ["config: expected an object exported as default from rn-driver.config"]
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
reportUnknownKeys("config", config, TOP_LEVEL_KEYS, errors);
|
|
1426
|
+
if (config.timeoutMs !== void 0 && !isPositiveNumber(config.timeoutMs)) {
|
|
1427
|
+
errors.push("config.timeoutMs: expected a positive number");
|
|
1428
|
+
}
|
|
1429
|
+
validateMetro(config.metro, errors);
|
|
1430
|
+
validatePlaywright(config.playwright, errors);
|
|
1431
|
+
if (platforms.includes("ios")) validateIos(config.ios, errors);
|
|
1432
|
+
if (platforms.includes("android")) validateAndroid(config.android, errors);
|
|
1433
|
+
return { ok: errors.length === 0, errors };
|
|
1434
|
+
}
|
|
1435
|
+
function validateMetro(metro, errors) {
|
|
1436
|
+
if (metro === void 0) return;
|
|
1437
|
+
if (!isRecord(metro)) {
|
|
1438
|
+
errors.push("config.metro: expected an object");
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
reportUnknownKeys("config.metro", metro, METRO_KEYS, errors);
|
|
1442
|
+
optionalString("config.metro.url", metro.url, errors);
|
|
1443
|
+
optionalString("config.metro.command", metro.command, errors);
|
|
1444
|
+
optionalString("config.metro.host", metro.host, errors);
|
|
1445
|
+
if (metro.port !== void 0 && !isPort(metro.port))
|
|
1446
|
+
errors.push("config.metro.port: expected a port (1-65535)");
|
|
1447
|
+
if (metro.reuseExisting !== void 0 && typeof metro.reuseExisting !== "boolean") {
|
|
1448
|
+
errors.push("config.metro.reuseExisting: expected a boolean");
|
|
1449
|
+
}
|
|
1450
|
+
if (metro.readyTimeoutMs !== void 0 && !isPositiveNumber(metro.readyTimeoutMs)) {
|
|
1451
|
+
errors.push("config.metro.readyTimeoutMs: expected a positive number");
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function validatePlaywright(playwright, errors) {
|
|
1455
|
+
if (playwright === void 0) return;
|
|
1456
|
+
if (!isRecord(playwright)) {
|
|
1457
|
+
errors.push("config.playwright: expected an object");
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
reportUnknownKeys("config.playwright", playwright, PLAYWRIGHT_KEYS, errors);
|
|
1461
|
+
optionalString("config.playwright.config", playwright.config, errors);
|
|
1462
|
+
if (playwright.specs !== void 0 && !isStringArray(playwright.specs)) {
|
|
1463
|
+
errors.push("config.playwright.specs: expected an array of strings");
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function validateIos(ios, errors) {
|
|
1467
|
+
if (!isRecord(ios)) {
|
|
1468
|
+
errors.push('config.ios: required when platform "ios" is selected (expected an object)');
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
reportUnknownKeys("config.ios", ios, IOS_KEYS, errors);
|
|
1472
|
+
requireString("config.ios.bundleId", ios.bundleId, errors);
|
|
1473
|
+
requireString("config.ios.workspace", ios.workspace, errors);
|
|
1474
|
+
requireString("config.ios.appScheme", ios.appScheme, errors);
|
|
1475
|
+
optionalString("config.ios.uitestScheme", ios.uitestScheme, errors);
|
|
1476
|
+
optionalString("config.ios.destination", ios.destination, errors);
|
|
1477
|
+
validateCompanion("config.ios.companion", ios.companion, errors);
|
|
1478
|
+
if (ios.defaults !== void 0) {
|
|
1479
|
+
if (!isRecord(ios.defaults)) {
|
|
1480
|
+
errors.push("config.ios.defaults: expected an object of key -> string|number|boolean");
|
|
1481
|
+
} else {
|
|
1482
|
+
for (const [key, value] of Object.entries(ios.defaults)) {
|
|
1483
|
+
const kind = typeof value;
|
|
1484
|
+
if (kind !== "string" && kind !== "number" && kind !== "boolean") {
|
|
1485
|
+
errors.push(`config.ios.defaults.${key}: expected string|number|boolean`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
const launch = validateLaunch("config.ios.launch", ios.launch, errors);
|
|
1491
|
+
if (launch && launch.kind === "expo-dev-client" && launch.mode !== "attach") {
|
|
1492
|
+
errors.push(
|
|
1493
|
+
'config.ios.launch: kind "expo-dev-client" requires mode "attach" (the host owns the launch)'
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
if (launch && launch.mode === "attach" && launch.kind !== "expo-dev-client") {
|
|
1497
|
+
errors.push(
|
|
1498
|
+
'config.ios.launch: mode "attach" requires kind "expo-dev-client"; a plain app uses mode "launch" or "activate" (the companion launches it)'
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
function validateAndroid(android, errors) {
|
|
1503
|
+
if (!isRecord(android)) {
|
|
1504
|
+
errors.push('config.android: required when platform "android" is selected (expected an object)');
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
reportUnknownKeys("config.android", android, ANDROID_KEYS, errors);
|
|
1508
|
+
requireAndroidPackage("config.android.packageName", android.packageName, errors);
|
|
1509
|
+
requireAndroidActivity("config.android.activity", android.activity, errors);
|
|
1510
|
+
optionalString("config.android.appApkPath", android.appApkPath, errors);
|
|
1511
|
+
optionalString("config.android.testApkPath", android.testApkPath, errors);
|
|
1512
|
+
if (android.instrumentationTarget !== void 0)
|
|
1513
|
+
requireInstrumentationTarget(
|
|
1514
|
+
"config.android.instrumentationTarget",
|
|
1515
|
+
android.instrumentationTarget,
|
|
1516
|
+
errors
|
|
1517
|
+
);
|
|
1518
|
+
if (android.gradleTasks !== void 0 && !isStringArray(android.gradleTasks)) {
|
|
1519
|
+
errors.push("config.android.gradleTasks: expected an array of strings");
|
|
1520
|
+
}
|
|
1521
|
+
validateCompanion("config.android.companion", android.companion, errors);
|
|
1522
|
+
validateLaunch("config.android.launch", android.launch, errors);
|
|
1523
|
+
}
|
|
1524
|
+
function validateCompanion(path4, companion, errors) {
|
|
1525
|
+
if (companion === void 0) return;
|
|
1526
|
+
if (!isRecord(companion)) {
|
|
1527
|
+
errors.push(`${path4}: expected an object`);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
reportUnknownKeys(path4, companion, COMPANION_KEYS, errors);
|
|
1531
|
+
if (companion.port !== void 0 && !isPort(companion.port))
|
|
1532
|
+
errors.push(`${path4}.port: expected a port (1-65535)`);
|
|
1533
|
+
if (companion.readyTimeoutMs !== void 0 && !isPositiveNumber(companion.readyTimeoutMs)) {
|
|
1534
|
+
errors.push(`${path4}.readyTimeoutMs: expected a positive number`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
function validateLaunch(path4, launch, errors) {
|
|
1538
|
+
if (!isRecord(launch)) {
|
|
1539
|
+
errors.push(`${path4}: required (expected an object with mode and kind)`);
|
|
1540
|
+
return void 0;
|
|
1541
|
+
}
|
|
1542
|
+
reportUnknownKeys(path4, launch, LAUNCH_KEYS, errors);
|
|
1543
|
+
optionalString(`${path4}.initialUrl`, launch.initialUrl, errors);
|
|
1544
|
+
const modeOk = typeof launch.mode === "string" && LAUNCH_MODES.includes(launch.mode);
|
|
1545
|
+
const kindOk = typeof launch.kind === "string" && LAUNCH_KINDS.includes(launch.kind);
|
|
1546
|
+
if (!modeOk) errors.push(`${path4}.mode: expected one of ${LAUNCH_MODES.join(", ")}`);
|
|
1547
|
+
if (!kindOk) errors.push(`${path4}.kind: expected one of ${LAUNCH_KINDS.join(", ")}`);
|
|
1548
|
+
return modeOk && kindOk ? { mode: launch.mode, kind: launch.kind } : void 0;
|
|
1549
|
+
}
|
|
1550
|
+
function isRecord(value) {
|
|
1551
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1552
|
+
}
|
|
1553
|
+
function isPositiveNumber(value) {
|
|
1554
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
1555
|
+
}
|
|
1556
|
+
function isPort(value) {
|
|
1557
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
|
|
1558
|
+
}
|
|
1559
|
+
function isStringArray(value) {
|
|
1560
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1561
|
+
}
|
|
1562
|
+
function requireString(path4, value, errors) {
|
|
1563
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
1564
|
+
errors.push(`${path4}: required non-empty string`);
|
|
1565
|
+
}
|
|
1566
|
+
var ANDROID_PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
1567
|
+
var ANDROID_ACTIVITY_RE = /^\.?[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
|
|
1568
|
+
function requireAndroidPackage(path4, value, errors) {
|
|
1569
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1570
|
+
errors.push(`${path4}: required non-empty string`);
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
if (!ANDROID_PACKAGE_RE.test(value))
|
|
1574
|
+
errors.push(`${path4}: expected a valid Android application id (e.g. com.company.app)`);
|
|
1575
|
+
}
|
|
1576
|
+
function requireAndroidActivity(path4, value, errors) {
|
|
1577
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1578
|
+
errors.push(`${path4}: required non-empty string`);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (!ANDROID_ACTIVITY_RE.test(value))
|
|
1582
|
+
errors.push(`${path4}: expected an activity name (e.g. .MainActivity)`);
|
|
1583
|
+
}
|
|
1584
|
+
var ANDROID_INSTRUMENTATION_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*\/[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
|
|
1585
|
+
function requireInstrumentationTarget(path4, value, errors) {
|
|
1586
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1587
|
+
errors.push(`${path4}: expected a non-empty string`);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
if (!ANDROID_INSTRUMENTATION_RE.test(value))
|
|
1591
|
+
errors.push(`${path4}: expected an am instrument target (e.g. com.app.test/com.app.Runner)`);
|
|
1592
|
+
}
|
|
1593
|
+
function optionalString(path4, value, errors) {
|
|
1594
|
+
if (value !== void 0 && typeof value !== "string") errors.push(`${path4}: expected a string`);
|
|
1595
|
+
}
|
|
1596
|
+
function reportUnknownKeys(path4, value, allowed, errors) {
|
|
1597
|
+
for (const key of Object.keys(value)) {
|
|
1598
|
+
if (!allowed.has(key)) errors.push(`${path4}.${key}: unknown key`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
function assertValid(config, platforms) {
|
|
1602
|
+
const result = validateConfig(config, platforms);
|
|
1603
|
+
if (!result.ok) {
|
|
1604
|
+
throw new ConfigValidationError(result.errors);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
var ConfigValidationError = class extends Error {
|
|
1608
|
+
errors;
|
|
1609
|
+
constructor(errors) {
|
|
1610
|
+
super(`Invalid rn-driver config:
|
|
1611
|
+
- ${errors.join("\n - ")}`);
|
|
1612
|
+
this.name = "ConfigValidationError";
|
|
1613
|
+
this.errors = errors;
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
// src/cli.ts
|
|
1618
|
+
var STAGE_EXIT_CODES = {
|
|
1619
|
+
config: 10,
|
|
1620
|
+
metro: 11,
|
|
1621
|
+
device: 12,
|
|
1622
|
+
build: 13,
|
|
1623
|
+
companion: 14,
|
|
1624
|
+
"app-launch": 15,
|
|
1625
|
+
"hermes-target": 16,
|
|
1626
|
+
playwright: 1,
|
|
1627
|
+
cleanup: 17
|
|
1628
|
+
};
|
|
1629
|
+
async function run2(argv) {
|
|
1630
|
+
const { command, specs, passthrough, flags } = parseCliArgs(argv);
|
|
1631
|
+
if (flags.help) {
|
|
1632
|
+
printHelp();
|
|
1633
|
+
return 0;
|
|
1634
|
+
}
|
|
1635
|
+
if (command !== "test") {
|
|
1636
|
+
process.stderr.write(`Unknown command: ${command ?? "(none)"}
|
|
1637
|
+
`);
|
|
1638
|
+
printHelp();
|
|
1639
|
+
return 2;
|
|
1640
|
+
}
|
|
1641
|
+
let platforms;
|
|
1642
|
+
try {
|
|
1643
|
+
platforms = resolvePlatforms(flags.platform);
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
process.stderr.write(`${error.message}
|
|
1646
|
+
`);
|
|
1647
|
+
return 2;
|
|
1648
|
+
}
|
|
1649
|
+
let config;
|
|
1650
|
+
try {
|
|
1651
|
+
const loaded = await loadConfig({
|
|
1652
|
+
cwd: process.cwd(),
|
|
1653
|
+
...flags.config ? { configPath: flags.config } : {}
|
|
1654
|
+
});
|
|
1655
|
+
assertValid(loaded.config, platforms);
|
|
1656
|
+
config = loaded.config;
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
if (error instanceof ConfigValidationError || error instanceof ConfigNotFoundError) {
|
|
1659
|
+
process.stderr.write(`${error.message}
|
|
1660
|
+
`);
|
|
1661
|
+
return 2;
|
|
1662
|
+
}
|
|
1663
|
+
throw error;
|
|
1664
|
+
}
|
|
1665
|
+
if (flags.dryRun) {
|
|
1666
|
+
for (const platform of platforms) {
|
|
1667
|
+
process.stdout.write(
|
|
1668
|
+
`${renderPlan(buildDryRunPlan(config, platform, { specs, passthrough }))}
|
|
1669
|
+
|
|
1670
|
+
`
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
return 0;
|
|
1674
|
+
}
|
|
1675
|
+
const runner = new NodeProcessRunner();
|
|
1676
|
+
const logDir = await mkdtemp(path.join(tmpdir(), "rn-driver-logs-"));
|
|
1677
|
+
const onSignal = (signal) => {
|
|
1678
|
+
process.stderr.write(`
|
|
1679
|
+
Received ${signal} \u2014 terminating runner-owned processes\u2026
|
|
1680
|
+
`);
|
|
1681
|
+
runner.killAll();
|
|
1682
|
+
process.exit(130);
|
|
1683
|
+
};
|
|
1684
|
+
const onSigint = () => onSignal("SIGINT");
|
|
1685
|
+
const onSigterm = () => onSignal("SIGTERM");
|
|
1686
|
+
process.on("SIGINT", onSigint);
|
|
1687
|
+
process.on("SIGTERM", onSigterm);
|
|
1688
|
+
try {
|
|
1689
|
+
let exitCode = 0;
|
|
1690
|
+
for (const platform of platforms) {
|
|
1691
|
+
runner.log(`
|
|
1692
|
+
=== platform: ${platform} ===`);
|
|
1693
|
+
const code = await runPlatform(platform, config, {
|
|
1694
|
+
runner,
|
|
1695
|
+
logDir,
|
|
1696
|
+
flags,
|
|
1697
|
+
specs,
|
|
1698
|
+
passthrough
|
|
1699
|
+
});
|
|
1700
|
+
if (code !== 0) exitCode = code;
|
|
1701
|
+
}
|
|
1702
|
+
return exitCode;
|
|
1703
|
+
} finally {
|
|
1704
|
+
process.removeListener("SIGINT", onSigint);
|
|
1705
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
async function runPlatform(platform, config, ctx) {
|
|
1709
|
+
const metro = resolveMetro(config.metro);
|
|
1710
|
+
const reuseMetro = metro.reuseExisting && await metroRunning(metro.url);
|
|
1711
|
+
if (reuseMetro) ctx.runner.log(`Reusing Metro already running at ${metro.url}`);
|
|
1712
|
+
if (!reuseMetro && await portInUse(metro.host, metro.port)) {
|
|
1713
|
+
process.stderr.write(
|
|
1714
|
+
`
|
|
1715
|
+
FAILED [${platform}] at stage [metro] metro.preflight: ${metro.host}:${metro.port} is already in use. Free it, set metro.reuseExisting, or choose another metro.port.
|
|
1716
|
+
`
|
|
1717
|
+
);
|
|
1718
|
+
return STAGE_EXIT_CODES.metro;
|
|
1719
|
+
}
|
|
1720
|
+
let plan;
|
|
1721
|
+
try {
|
|
1722
|
+
plan = await buildPlatformPlan(platform, config, metro, ctx);
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
process.stderr.write(`
|
|
1725
|
+
FAILED [${platform}] at stage [device]: ${error.message}
|
|
1726
|
+
`);
|
|
1727
|
+
return STAGE_EXIT_CODES.device;
|
|
1728
|
+
}
|
|
1729
|
+
try {
|
|
1730
|
+
const result = await executePlan(plan, ctx.runner, {
|
|
1731
|
+
logDir: ctx.logDir,
|
|
1732
|
+
skipBuild: ctx.flags.skipBuild,
|
|
1733
|
+
verbose: ctx.flags.verbose,
|
|
1734
|
+
skipStep: (step) => reuseMetro && step.id === "metro.start",
|
|
1735
|
+
skipCleanup: (action) => reuseMetro && action.type === "kill-process" && action.processKey === "metro"
|
|
1736
|
+
});
|
|
1737
|
+
if (result.playwrightCode === 0) {
|
|
1738
|
+
ctx.runner.log(`PASS: ${platform} e2e`);
|
|
1739
|
+
} else {
|
|
1740
|
+
ctx.runner.log(`FAIL: ${platform} Playwright exited ${result.playwrightCode}`);
|
|
1741
|
+
}
|
|
1742
|
+
return result.playwrightCode;
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
if (error instanceof StageError) {
|
|
1745
|
+
process.stderr.write(
|
|
1746
|
+
`
|
|
1747
|
+
FAILED [${platform}] at stage [${error.stage}] ${error.stepId}: ${error.message}
|
|
1748
|
+
`
|
|
1749
|
+
);
|
|
1750
|
+
process.stderr.write(`Logs: ${ctx.logDir}
|
|
1751
|
+
`);
|
|
1752
|
+
await printLogTail(ctx.logDir, "metro");
|
|
1753
|
+
await printLogTail(ctx.logDir, "companion");
|
|
1754
|
+
return STAGE_EXIT_CODES[error.stage];
|
|
1755
|
+
}
|
|
1756
|
+
throw error;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
async function buildPlatformPlan(platform, config, metro, ctx) {
|
|
1760
|
+
if (platform === "ios") {
|
|
1761
|
+
if (!config.ios) throw new Error("config.ios is required for the ios platform");
|
|
1762
|
+
const resolved2 = await resolveIosTarget(config.ios, metro, deviceOpt(ctx.flags.device));
|
|
1763
|
+
return planIos({
|
|
1764
|
+
ios: config.ios,
|
|
1765
|
+
metro,
|
|
1766
|
+
resolved: resolved2,
|
|
1767
|
+
playwright: config.playwright,
|
|
1768
|
+
timeoutMs: config.timeoutMs,
|
|
1769
|
+
specs: ctx.specs,
|
|
1770
|
+
passthrough: ctx.passthrough
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
if (!config.android) throw new Error("config.android is required for the android platform");
|
|
1774
|
+
const { resolved, deviceName } = await resolveAndroidTarget(
|
|
1775
|
+
config.android,
|
|
1776
|
+
metro,
|
|
1777
|
+
deviceOpt(ctx.flags.device)
|
|
1778
|
+
);
|
|
1779
|
+
return planAndroid({
|
|
1780
|
+
android: config.android,
|
|
1781
|
+
metro,
|
|
1782
|
+
resolved,
|
|
1783
|
+
playwright: config.playwright,
|
|
1784
|
+
timeoutMs: config.timeoutMs,
|
|
1785
|
+
specs: ctx.specs,
|
|
1786
|
+
passthrough: ctx.passthrough,
|
|
1787
|
+
hermesDeviceName: deviceName
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
function deviceOpt(device) {
|
|
1791
|
+
return device ? { device } : {};
|
|
1792
|
+
}
|
|
1793
|
+
async function metroRunning(metroUrl) {
|
|
1794
|
+
try {
|
|
1795
|
+
const response = await fetch(`${metroUrl}/status`, { signal: AbortSignal.timeout(2e3) });
|
|
1796
|
+
return (await response.text()).includes("packager-status:running");
|
|
1797
|
+
} catch {
|
|
1798
|
+
return false;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
async function printLogTail(logDir, key, lines = 20) {
|
|
1802
|
+
try {
|
|
1803
|
+
const content = await readFile(path.join(logDir, `${key}.log`), "utf8");
|
|
1804
|
+
const tail = content.split("\n").filter((line) => line.length > 0).slice(-lines);
|
|
1805
|
+
if (tail.length > 0)
|
|
1806
|
+
process.stderr.write(`
|
|
1807
|
+
--- ${key} log (last ${tail.length} lines) ---
|
|
1808
|
+
${tail.join("\n")}
|
|
1809
|
+
`);
|
|
1810
|
+
} catch {
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
function portInUse(host, port) {
|
|
1814
|
+
return new Promise((resolve) => {
|
|
1815
|
+
const socket = net.connect({ host, port });
|
|
1816
|
+
const finish = (inUse) => {
|
|
1817
|
+
socket.destroy();
|
|
1818
|
+
resolve(inUse);
|
|
1819
|
+
};
|
|
1820
|
+
socket.setTimeout(1e3);
|
|
1821
|
+
socket.once("connect", () => finish(true));
|
|
1822
|
+
socket.once("timeout", () => finish(false));
|
|
1823
|
+
socket.once("error", () => finish(false));
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
function resolvePlatforms(platform) {
|
|
1827
|
+
if (!platform) throw new Error("--platform <ios|android|all> is required");
|
|
1828
|
+
if (platform === "all") return ["ios", "android"];
|
|
1829
|
+
if (platform === "ios" || platform === "android") return [platform];
|
|
1830
|
+
throw new Error(`--platform must be one of ios, android, all (got: ${platform})`);
|
|
1831
|
+
}
|
|
1832
|
+
function parseCliArgs(argv) {
|
|
1833
|
+
const dashDash = argv.indexOf("--");
|
|
1834
|
+
const before = dashDash === -1 ? argv : argv.slice(0, dashDash);
|
|
1835
|
+
const passthrough = dashDash === -1 ? [] : argv.slice(dashDash + 1);
|
|
1836
|
+
const { values, positionals } = parseArgs({
|
|
1837
|
+
args: before,
|
|
1838
|
+
options: {
|
|
1839
|
+
platform: { type: "string", short: "p" },
|
|
1840
|
+
config: { type: "string", short: "c" },
|
|
1841
|
+
device: { type: "string", short: "d" },
|
|
1842
|
+
"dry-run": { type: "boolean" },
|
|
1843
|
+
"skip-build": { type: "boolean" },
|
|
1844
|
+
verbose: { type: "boolean" },
|
|
1845
|
+
help: { type: "boolean", short: "h" }
|
|
1846
|
+
},
|
|
1847
|
+
allowPositionals: true
|
|
1848
|
+
});
|
|
1849
|
+
const [command = "test", ...specs] = positionals;
|
|
1850
|
+
return {
|
|
1851
|
+
command,
|
|
1852
|
+
specs,
|
|
1853
|
+
passthrough,
|
|
1854
|
+
flags: {
|
|
1855
|
+
platform: values.platform,
|
|
1856
|
+
config: values.config,
|
|
1857
|
+
device: values.device,
|
|
1858
|
+
dryRun: values["dry-run"] ?? false,
|
|
1859
|
+
skipBuild: values["skip-build"] ?? false,
|
|
1860
|
+
verbose: values.verbose ?? false,
|
|
1861
|
+
help: values.help ?? false
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
function printHelp() {
|
|
1866
|
+
process.stdout.write(`
|
|
1867
|
+
rn-driver \u2014 config-backed cross-platform RN Playwright e2e runner
|
|
1868
|
+
|
|
1869
|
+
Usage:
|
|
1870
|
+
rn-driver test --platform <ios|android|all> [options] [specs...] [-- <playwright args>]
|
|
1871
|
+
|
|
1872
|
+
Options:
|
|
1873
|
+
-p, --platform ios | android | all (required)
|
|
1874
|
+
-c, --config Path to rn-driver.config.{ts,mjs,js} (default: searched upward)
|
|
1875
|
+
-d, --device Simulator udid / emulator serial override
|
|
1876
|
+
--dry-run Print the resolved plan and exit (no side effects)
|
|
1877
|
+
--skip-build Reuse an already-built native project
|
|
1878
|
+
--verbose Stream per-step progress
|
|
1879
|
+
-h, --help Show help
|
|
1880
|
+
|
|
1881
|
+
Examples:
|
|
1882
|
+
rn-driver test --platform ios
|
|
1883
|
+
rn-driver test --platform android --skip-build
|
|
1884
|
+
rn-driver test --platform all --dry-run
|
|
1885
|
+
rn-driver test --platform ios e2e/integration/counter.spec.ts
|
|
1886
|
+
`);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
export { run2 as run };
|
|
1890
|
+
//# sourceMappingURL=cli.mjs.map
|
|
1891
|
+
//# sourceMappingURL=cli.mjs.map
|