@unotest/mobile 0.1.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1967 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/runner/install.ts
5
+ import { config as loadDotenv2 } from "dotenv";
6
+ import { existsSync as existsSync4 } from "fs";
7
+ import { resolve as resolve3 } from "path";
8
+
9
+ // src/config/env.ts
10
+ import { config as loadDotenv } from "dotenv";
11
+ import { z } from "zod";
12
+ var ENV_FILE_PATH = "unotest/.env";
13
+ var loaded = false;
14
+ function ensureLoaded() {
15
+ if (loaded) return;
16
+ loadDotenv({ path: ENV_FILE_PATH });
17
+ loaded = true;
18
+ }
19
+ __name(ensureLoaded, "ensureLoaded");
20
+ var EnvSchema = z.object({
21
+ // APP_BUNDLE_ID — required at appLaunch / install time. We let the schema
22
+ // accept it as optional so commands that don't touch the app (`doctor`,
23
+ // `lint`) work without it. Use-site (WdaDriver) validates and errors with
24
+ // a clear message if missing.
25
+ APP_BUNDLE_ID: z.string().min(1).optional(),
26
+ // APP_URL_SCHEME — only consumed by the (currently unimplemented) Expo
27
+ // dev-client recovery flow. Optional; reserved for future use.
28
+ APP_URL_SCHEME: z.string().min(1).optional(),
29
+ // APP_PERMISSIONS — comma-separated `simctl privacy` services that
30
+ // `install --clean` (CLI + MCP) auto-grants before launch. Populated
31
+ // by `install --update-env` from detected NS*UsageDescription keys in
32
+ // the .app's Info.plist (P4 / S4). Optional — apps that don't request
33
+ // privacy services leave this unset.
34
+ // Example: APP_PERMISSIONS=location,motion
35
+ APP_PERMISSIONS: z.string().optional(),
36
+ // API_BASE_URL — only required if scenarios call `apiCall(...)`. Lazy:
37
+ // the ApiClient is constructed at first use, not at startup.
38
+ API_BASE_URL: z.string().url().optional(),
39
+ // PROJECT_ROOT — optional default cwd for the `shell(...)` DSL primitive.
40
+ // When unset, shell commands run from process.cwd(). Set to the absolute
41
+ // path of the project-under-test when its CLI must be invoked from a
42
+ // specific directory (e.g. monorepo root).
43
+ PROJECT_ROOT: z.string().optional(),
44
+ // DATABASE_URL — only required if scenarios call `dbQuery(...)` /
45
+ // `dbExec(...)`. Lazy: the DbClient is constructed at first use, not at
46
+ // startup. Format examples:
47
+ // postgresql://user:pass@host:5432/dbname
48
+ // mysql://user:pass@host:3306/dbname
49
+ // sqlite:./e2e.db
50
+ // sqlite::memory:
51
+ DATABASE_URL: z.string().min(1).optional(),
52
+ // SIM_A_NAME / SIM_B_NAME — schema-optional so non-UI commands work
53
+ // without them. Pool-aware validation in loadEnv() below requires the
54
+ // names for slots actually present in SIM_POOL.
55
+ SIM_A_NAME: z.string().min(1).optional(),
56
+ SIM_B_NAME: z.string().min(1).optional(),
57
+ SIM_POOL: z.string().default("A,B"),
58
+ // METRO_URL — only consumed by the (currently unimplemented) Expo
59
+ // dev-client recovery flow. Optional; reserved for future use.
60
+ METRO_URL: z.string().url().optional(),
61
+ // Expo dev-client builds show a "Development Servers" launcher after a clean
62
+ // launch. When true, the driver auto-opens
63
+ // `exp+<APP_URL_SCHEME>://expo-development-client/?url=<METRO_URL>` after
64
+ // `app_launch clean: true` to bypass the launcher. (Flow not yet wired.)
65
+ EXPO_DEV_CLIENT: z.string().optional().transform((v) => v === "true" || v === "1"),
66
+ SESSION_LOG_PATH: z.string().default("unotest/sessions/current.jsonl"),
67
+ // Explicit kill-switch for session recording. When "1"/"true", or when
68
+ // SESSION_LOG_PATH is empty, buildApp wires a NoopSessionRecorder. Used
69
+ // by evals harness and any consumer that wants the MCP server to make
70
+ // no on-disk session log.
71
+ SESSION_LOG_DISABLE: z.string().optional().transform((v) => v === "1" || v === "true"),
72
+ // When true, recorder writes the FULL tool result alongside the
73
+ // truncated preview. Off by default — snapshots can be megabytes.
74
+ SESSION_LOG_FULL: z.string().optional().transform((v) => v === "1" || v === "true"),
75
+ ARTIFACTS_DIR: z.string().default("unotest/artifacts"),
76
+ // Where ExplorationService persists per-session JSONL recording logs.
77
+ // Default: <ARTIFACTS_DIR>/explorations. Folded into the gitignored
78
+ // `unotest/artifacts/` tree by the init template.
79
+ EXPLORATIONS_DIR: z.string().optional(),
80
+ // WDA per-slot port mapping (D-13 parallel multi-device). Stored as a
81
+ // comma-separated `slot=port` list, e.g. "A=8100,B=8101". Each slot present
82
+ // in SIM_POOL needs a port.
83
+ WDA_PORTS: z.string().default("A=8100,B=8101"),
84
+ // Implicit auto-wait on selector-bearing actions (D-18).
85
+ WDA_DEFAULT_ACTION_WAIT_MS: z.string().default("2000").transform((v) => Number.parseInt(v, 10)),
86
+ WDA_DEFAULT_WAITFOR_TIMEOUT_MS: z.string().default("10000").transform((v) => Number.parseInt(v, 10)),
87
+ // TTL for paused-failed runtimes before auto-abort (D-17). Default 30 min.
88
+ PAUSED_RUNTIME_TTL_MS: z.string().default("1800000").transform((v) => Number.parseInt(v, 10))
89
+ });
90
+ var cached = null;
91
+ function loadEnv() {
92
+ if (cached) return cached;
93
+ ensureLoaded();
94
+ const parsed = EnvSchema.safeParse(process.env);
95
+ if (!parsed.success) {
96
+ const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
97
+ throw new Error(
98
+ `Invalid environment. Copy unotest/.env.example to unotest/.env and fill required values.
99
+ ${issues}`
100
+ );
101
+ }
102
+ const raw = parsed.data;
103
+ const simPool = raw.SIM_POOL.split(",").map((s) => s.trim()).filter(Boolean);
104
+ const simBySlot = {};
105
+ for (const slot of simPool) {
106
+ const key = `SIM_${slot}_NAME`;
107
+ const value = process.env[key];
108
+ if (!value) {
109
+ throw new Error(
110
+ `SIM_POOL lists slot "${slot}" but ${key} is not set in unotest/.env. Either add ${key}=<simulator-name> or shrink SIM_POOL to omit slot "${slot}".`
111
+ );
112
+ }
113
+ simBySlot[slot] = value;
114
+ }
115
+ const wdaPortBySlot = {};
116
+ for (const entry of raw.WDA_PORTS.split(",").map((s) => s.trim()).filter(Boolean)) {
117
+ const [slot, portStr] = entry.split("=").map((s) => s.trim());
118
+ if (!slot || !portStr) {
119
+ throw new Error(`Invalid WDA_PORTS entry "${entry}". Expected "slot=port"`);
120
+ }
121
+ const port = Number.parseInt(portStr, 10);
122
+ if (!Number.isFinite(port) || port <= 0) {
123
+ throw new Error(`Invalid WDA_PORTS port "${portStr}" for slot "${slot}"`);
124
+ }
125
+ wdaPortBySlot[slot] = port;
126
+ }
127
+ for (const slot of simPool) {
128
+ if (wdaPortBySlot[slot] == null) {
129
+ throw new Error(
130
+ `SIM_POOL includes slot "${slot}" but WDA_PORTS has no entry for it. Add "${slot}=<port>" to WDA_PORTS in unotest/.env.`
131
+ );
132
+ }
133
+ }
134
+ cached = {
135
+ ...raw,
136
+ simPool,
137
+ simBySlot,
138
+ wdaPortBySlot,
139
+ defaultActionWaitMs: raw.WDA_DEFAULT_ACTION_WAIT_MS,
140
+ defaultWaitForTimeoutMs: raw.WDA_DEFAULT_WAITFOR_TIMEOUT_MS,
141
+ pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS,
142
+ explorationsDir: raw.EXPLORATIONS_DIR ?? `${raw.ARTIFACTS_DIR}/explorations`
143
+ };
144
+ return cached;
145
+ }
146
+ __name(loadEnv, "loadEnv");
147
+
148
+ // src/driver/ios-utils.ts
149
+ import { execFile } from "child_process";
150
+ import { promisify } from "util";
151
+ var exec = promisify(execFile);
152
+ async function listSimulators() {
153
+ const { stdout } = await exec("xcrun", ["simctl", "list", "-j", "devices"]);
154
+ const parsed = JSON.parse(stdout);
155
+ const out = [];
156
+ for (const [runtime, list] of Object.entries(parsed.devices)) {
157
+ for (const d of list) {
158
+ if (d.isAvailable === false) continue;
159
+ out.push({ name: d.name, udid: d.udid, state: d.state, runtime });
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+ __name(listSimulators, "listSimulators");
165
+ function friendlyRuntime(runtime) {
166
+ const m = runtime.match(/SimRuntime\.([A-Za-z]+)-(\d+)-(\d+)$/);
167
+ if (!m) return runtime;
168
+ return `${m[1]} ${m[2]}.${m[3]}`;
169
+ }
170
+ __name(friendlyRuntime, "friendlyRuntime");
171
+ async function resolveSimByName(spec) {
172
+ const { name, runtimeHint } = parseSimSpec(spec);
173
+ const all = await listSimulators();
174
+ let matches = all.filter((s) => s.name === name);
175
+ if (runtimeHint) {
176
+ matches = matches.filter((s) => friendlyRuntime(s.runtime).toLowerCase().includes(runtimeHint.toLowerCase()));
177
+ }
178
+ if (matches.length === 0) {
179
+ const available = all.map((s) => `${s.name} @ ${friendlyRuntime(s.runtime)}`).join(", ") || "(none)";
180
+ throw new Error(
181
+ `Sim "${spec}" not found. Available: ${available}. If you have multiple sims with the same name, disambiguate via "<name> @ <runtime>" (e.g. "iPhone 16 @ iOS 17.5").`
182
+ );
183
+ }
184
+ const booted = matches.find((s) => s.state === "Booted");
185
+ return booted ?? matches[0];
186
+ }
187
+ __name(resolveSimByName, "resolveSimByName");
188
+ function parseSimSpec(spec) {
189
+ const idx = spec.lastIndexOf("@");
190
+ if (idx === -1) return { name: spec.trim() };
191
+ return {
192
+ name: spec.slice(0, idx).trim(),
193
+ runtimeHint: spec.slice(idx + 1).trim()
194
+ };
195
+ }
196
+ __name(parseSimSpec, "parseSimSpec");
197
+ async function bootSim(udid) {
198
+ try {
199
+ await exec("xcrun", ["simctl", "boot", udid]);
200
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
201
+ return;
202
+ } catch (e) {
203
+ const msg = e.stderr ?? String(e);
204
+ if (msg.includes("Booted") || msg.includes("current state: Booted")) {
205
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
206
+ return;
207
+ }
208
+ const state = await currentSimState(udid).catch(() => null);
209
+ if (state === "Booted") {
210
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
211
+ return;
212
+ }
213
+ if (state && state !== "Shutdown") {
214
+ throw new Error(
215
+ `simctl boot ${udid} failed and sim is in transitional state "${state}". Wait a few seconds and retry, or force-shutdown: \`xcrun simctl shutdown ${udid}\`.
216
+ Original error: ${msg.trim().split("\n").slice(0, 4).join(" | ")}`
217
+ );
218
+ }
219
+ throw new Error(
220
+ `simctl boot ${udid} failed. Sim is "${state ?? "unknown"}".
221
+ Common fixes:
222
+ \u2022 Open Simulator.app, pick this device manually, ensure it boots.
223
+ \u2022 Erase: \`xcrun simctl erase ${udid}\` (wipes content & settings).
224
+ \u2022 Restart CoreSimulator: \`sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService\`.
225
+ Original error: ${msg.trim().split("\n").slice(0, 4).join(" | ")}`
226
+ );
227
+ }
228
+ }
229
+ __name(bootSim, "bootSim");
230
+ async function currentSimState(udid) {
231
+ const sims = await listSimulators();
232
+ const sim = sims.find((s) => s.udid === udid);
233
+ return sim ? sim.state : null;
234
+ }
235
+ __name(currentSimState, "currentSimState");
236
+ async function openSimulatorApp() {
237
+ try {
238
+ await exec("open", ["-a", "Simulator"]);
239
+ } catch {
240
+ }
241
+ }
242
+ __name(openSimulatorApp, "openSimulatorApp");
243
+ async function shutdownSim(udid) {
244
+ try {
245
+ await exec("xcrun", ["simctl", "shutdown", udid]);
246
+ } catch {
247
+ }
248
+ }
249
+ __name(shutdownSim, "shutdownSim");
250
+ async function openUrl(udid, url) {
251
+ await exec("xcrun", ["simctl", "openurl", udid, url]);
252
+ }
253
+ __name(openUrl, "openUrl");
254
+ async function screenshotPng(udid) {
255
+ const { stdout } = await exec("xcrun", ["simctl", "io", udid, "screenshot", "--type=png", "-"], {
256
+ encoding: "buffer",
257
+ maxBuffer: 32 * 1024 * 1024
258
+ });
259
+ return stdout;
260
+ }
261
+ __name(screenshotPng, "screenshotPng");
262
+ async function terminateApp(udid, bundleId) {
263
+ try {
264
+ await exec("xcrun", ["simctl", "terminate", udid, bundleId]);
265
+ } catch {
266
+ }
267
+ }
268
+ __name(terminateApp, "terminateApp");
269
+ async function launchApp(udid, bundleId, args = []) {
270
+ const { stdout } = await exec("xcrun", ["simctl", "launch", udid, bundleId, ...args]);
271
+ const m = stdout.match(/:\s*(\d+)/);
272
+ const pid = m ? Number.parseInt(m[1], 10) : 0;
273
+ return { pid };
274
+ }
275
+ __name(launchApp, "launchApp");
276
+ async function isPidAlive(pid) {
277
+ if (pid <= 0) return false;
278
+ try {
279
+ process.kill(pid, 0);
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+ __name(isPidAlive, "isPidAlive");
286
+ async function assertLaunchedAndStable(udid, bundleId, pid, options = {}) {
287
+ const settleMs = options.settleMs ?? 1500;
288
+ await new Promise((r) => setTimeout(r, settleMs));
289
+ const alive = await isPidAlive(pid);
290
+ if (alive) return;
291
+ const procName = bundleId.split(".").pop() ?? bundleId;
292
+ throw new Error(
293
+ `App "${bundleId}" started (PID ${pid}) but exited within ${settleMs}ms \u2014 looks like a crash on launch.
294
+ See the crash reason:
295
+ xcrun simctl spawn ${udid} log show --predicate 'process == "${procName}" OR senderImagePath CONTAINS "${bundleId}"' --last 30s --info
296
+ Or open Console.app, filter by your app name.
297
+ Common causes: missing native module (rebuild after changing native deps), JS bundle baked against wrong workspace/env, signing issues.`
298
+ );
299
+ }
300
+ __name(assertLaunchedAndStable, "assertLaunchedAndStable");
301
+ async function uninstallApp(udid, bundleId) {
302
+ try {
303
+ await exec("xcrun", ["simctl", "uninstall", udid, bundleId]);
304
+ } catch {
305
+ }
306
+ }
307
+ __name(uninstallApp, "uninstallApp");
308
+ async function installApp(udid, appPath) {
309
+ await exec("xcrun", ["simctl", "install", udid, appPath]);
310
+ }
311
+ __name(installApp, "installApp");
312
+ async function isAppInstalled(udid, bundleId) {
313
+ try {
314
+ await exec("xcrun", ["simctl", "get_app_container", udid, bundleId]);
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+ __name(isAppInstalled, "isAppInstalled");
321
+ async function eraseSim(udid) {
322
+ await exec("xcrun", ["simctl", "erase", udid]);
323
+ }
324
+ __name(eraseSim, "eraseSim");
325
+ async function keychainResetSim(udid) {
326
+ await exec("xcrun", ["simctl", "keychain", udid, "reset"]);
327
+ }
328
+ __name(keychainResetSim, "keychainResetSim");
329
+ async function privacyGrantSim(udid, service, bundleId) {
330
+ await exec("xcrun", ["simctl", "privacy", udid, "grant", service, bundleId]);
331
+ }
332
+ __name(privacyGrantSim, "privacyGrantSim");
333
+ async function pinEnglishKeyboardSim(udid) {
334
+ await exec("xcrun", [
335
+ "simctl",
336
+ "spawn",
337
+ udid,
338
+ "defaults",
339
+ "write",
340
+ "-g",
341
+ "AppleKeyboards",
342
+ "-array",
343
+ "en_US@hw=US;sw=QWERTY"
344
+ ]);
345
+ await exec("xcrun", [
346
+ "simctl",
347
+ "spawn",
348
+ udid,
349
+ "defaults",
350
+ "write",
351
+ "-g",
352
+ "AppleLanguages",
353
+ "-array",
354
+ "en"
355
+ ]);
356
+ await exec("xcrun", [
357
+ "simctl",
358
+ "spawn",
359
+ udid,
360
+ "defaults",
361
+ "write",
362
+ "-g",
363
+ "AppleLocale",
364
+ "-string",
365
+ "en_US"
366
+ ]);
367
+ }
368
+ __name(pinEnglishKeyboardSim, "pinEnglishKeyboardSim");
369
+
370
+ // src/driver/simctl/adapter.ts
371
+ var SimctlAdapter = class {
372
+ static {
373
+ __name(this, "SimctlAdapter");
374
+ }
375
+ async resolveByName(name) {
376
+ return resolveSimByName(name);
377
+ }
378
+ async boot(udid) {
379
+ return bootSim(udid);
380
+ }
381
+ async shutdown(udid) {
382
+ return shutdownSim(udid);
383
+ }
384
+ async install(udid, appPath) {
385
+ return installApp(udid, appPath);
386
+ }
387
+ async uninstall(udid, bundleId) {
388
+ return uninstallApp(udid, bundleId);
389
+ }
390
+ async launch(udid, bundleId, args) {
391
+ return launchApp(udid, bundleId, args);
392
+ }
393
+ async assertLaunchedAndStable(udid, bundleId, pid, settleMs) {
394
+ return assertLaunchedAndStable(udid, bundleId, pid, settleMs !== void 0 ? { settleMs } : {});
395
+ }
396
+ async terminate(udid, bundleId) {
397
+ return terminateApp(udid, bundleId);
398
+ }
399
+ async openUrl(udid, url) {
400
+ return openUrl(udid, url);
401
+ }
402
+ async screenshot(udid) {
403
+ return screenshotPng(udid);
404
+ }
405
+ async isInstalled(udid, bundleId) {
406
+ return isAppInstalled(udid, bundleId);
407
+ }
408
+ async erase(udid) {
409
+ return eraseSim(udid);
410
+ }
411
+ /** B5 — wipes simulator keychain so auth tokens don't survive `clean`
412
+ * launches. Used by `installApp({clean})` and `WdaDriver.appLaunch({clean})`. */
413
+ async keychainReset(udid) {
414
+ return keychainResetSim(udid);
415
+ }
416
+ /** S4 — pre-grant an iOS privacy service to a bundle so the app skips
417
+ * the SpringBoard permission dialog on first launch. */
418
+ async privacyGrant(udid, service, bundleId) {
419
+ return privacyGrantSim(udid, service, bundleId);
420
+ }
421
+ /** S8 — pin the sim's keyboard to en_US@QWERTY so WDA's typeText
422
+ * doesn't drop Latin chars when the sim was last on a Cyrillic layout. */
423
+ async pinEnglishKeyboard(udid) {
424
+ return pinEnglishKeyboardSim(udid);
425
+ }
426
+ async openSimulatorApp() {
427
+ return openSimulatorApp();
428
+ }
429
+ };
430
+
431
+ // src/util/cli-entry.ts
432
+ function runMain(main2, errorExitCode) {
433
+ main2().then(
434
+ (code) => process.exit(code),
435
+ (e) => {
436
+ const msg = e instanceof Error ? e.message : String(e);
437
+ process.stderr.write(`\u2717 ${msg}
438
+ `);
439
+ if (process.env.UNOTEST_DEBUG === "1" && e instanceof Error && e.stack) {
440
+ process.stderr.write(`${e.stack}
441
+ `);
442
+ }
443
+ process.exit(errorExitCode);
444
+ }
445
+ );
446
+ }
447
+ __name(runMain, "runMain");
448
+
449
+ // src/util/interactive-select.ts
450
+ import { emitKeypressEvents } from "readline";
451
+ function reduceSelect(state, key) {
452
+ if (key.ctrl && key.name === "c") return { kind: "abort", reason: "ctrl-c" };
453
+ if (key.name === "escape") return { kind: "abort", reason: "esc" };
454
+ if (key.name === "return" || key.name === "enter") {
455
+ const choice = state.choices[state.index];
456
+ if (!choice || choice.disabled) return { kind: "noop" };
457
+ return { kind: "done", value: choice.value };
458
+ }
459
+ if (key.name === "up" || key.name === "k") {
460
+ return { kind: "redraw", state: { ...state, index: stepIndex(state, -1) } };
461
+ }
462
+ if (key.name === "down" || key.name === "j") {
463
+ return { kind: "redraw", state: { ...state, index: stepIndex(state, 1) } };
464
+ }
465
+ if (key.name === "home" || key.name === "g" && !key.ctrl) {
466
+ return { kind: "redraw", state: { ...state, index: firstEnabled(state.choices, 0, 1) } };
467
+ }
468
+ if (key.name === "end" || key.name === "G" && !key.ctrl) {
469
+ return {
470
+ kind: "redraw",
471
+ state: { ...state, index: firstEnabled(state.choices, state.choices.length - 1, -1) }
472
+ };
473
+ }
474
+ return { kind: "noop" };
475
+ }
476
+ __name(reduceSelect, "reduceSelect");
477
+ function stepIndex(state, dir) {
478
+ const n = state.choices.length;
479
+ if (n === 0) return 0;
480
+ let i = state.index;
481
+ for (let attempts = 0; attempts < n; attempts++) {
482
+ i = (i + dir + n) % n;
483
+ if (!state.choices[i]?.disabled) return i;
484
+ }
485
+ return state.index;
486
+ }
487
+ __name(stepIndex, "stepIndex");
488
+ function firstEnabled(choices, start, dir) {
489
+ const n = choices.length;
490
+ let i = start;
491
+ for (let attempts = 0; attempts < n; attempts++) {
492
+ if (!choices[i]?.disabled) return i;
493
+ i += dir;
494
+ if (i < 0 || i >= n) break;
495
+ }
496
+ return start;
497
+ }
498
+ __name(firstEnabled, "firstEnabled");
499
+ function initialCursorIndex(choices, requested) {
500
+ if (choices.length === 0) return 0;
501
+ const clamped = Math.max(0, Math.min(requested, choices.length - 1));
502
+ if (!choices[clamped]?.disabled) return clamped;
503
+ for (let i = clamped + 1; i < choices.length; i++) if (!choices[i]?.disabled) return i;
504
+ for (let i = clamped - 1; i >= 0; i--) if (!choices[i]?.disabled) return i;
505
+ return clamped;
506
+ }
507
+ __name(initialCursorIndex, "initialCursorIndex");
508
+ var SelectAbortedError = class extends Error {
509
+ constructor(reason) {
510
+ super(`select aborted (${reason})`);
511
+ this.reason = reason;
512
+ this.name = "SelectAbortedError";
513
+ }
514
+ reason;
515
+ static {
516
+ __name(this, "SelectAbortedError");
517
+ }
518
+ };
519
+ async function interactiveSelect(opts, io = {}) {
520
+ const input = io.input ?? process.stdin;
521
+ const output = io.output ?? process.stderr;
522
+ if (!input.isTTY) {
523
+ throw new Error(
524
+ `interactiveSelect: input stream is not a TTY. Caller must check isTTY upstream and use a non-interactive code path.`
525
+ );
526
+ }
527
+ if (opts.choices.length === 0) {
528
+ throw new Error(`interactiveSelect: choices is empty`);
529
+ }
530
+ const initialIndex = initialCursorIndex(opts.choices, opts.initialIndex ?? 0);
531
+ let state = { choices: opts.choices, index: initialIndex };
532
+ output.write("\x1B[?25l");
533
+ emitKeypressEvents(input);
534
+ const wasRaw = input.isRaw ?? false;
535
+ input.setRawMode(true);
536
+ input.resume();
537
+ const renderedLines = render(output, opts.message, state);
538
+ let linesOnScreen = renderedLines;
539
+ const cleanup = /* @__PURE__ */ __name(() => {
540
+ input.setRawMode(wasRaw);
541
+ input.pause();
542
+ input.removeListener("keypress", onKey);
543
+ output.write("\x1B[?25h");
544
+ }, "cleanup");
545
+ let onKey;
546
+ return new Promise((resolve4, reject) => {
547
+ onKey = /* @__PURE__ */ __name((_str, key) => {
548
+ const action = reduceSelect(state, key ?? {});
549
+ if (action.kind === "noop") return;
550
+ if (action.kind === "abort") {
551
+ cleanup();
552
+ reject(new SelectAbortedError(action.reason));
553
+ return;
554
+ }
555
+ if (action.kind === "done") {
556
+ cleanup();
557
+ eraseLines(output, linesOnScreen);
558
+ const chosenLabel = state.choices[state.index]?.label ?? String(action.value);
559
+ output.write(`${opts.message}
560
+ \u2713 ${chosenLabel}
561
+ `);
562
+ resolve4(action.value);
563
+ return;
564
+ }
565
+ state = action.state;
566
+ eraseLines(output, linesOnScreen);
567
+ linesOnScreen = render(output, opts.message, state);
568
+ }, "onKey");
569
+ input.on("keypress", onKey);
570
+ });
571
+ }
572
+ __name(interactiveSelect, "interactiveSelect");
573
+ function render(output, message, state) {
574
+ const messageLines = message.split("\n");
575
+ for (const line of messageLines) output.write(`${line}
576
+ `);
577
+ for (let i = 0; i < state.choices.length; i++) {
578
+ const choice = state.choices[i];
579
+ const marker = i === state.index ? "\u276F" : " ";
580
+ const label = choice.disabled ? dim(choice.label) : choice.label;
581
+ const line = i === state.index ? highlight(`${marker} ${label}`) : `${marker} ${label}`;
582
+ output.write(`${line}
583
+ `);
584
+ }
585
+ output.write(dim("(\u2191/\u2193 to move, enter to select, ctrl-c to abort)\n"));
586
+ return messageLines.length + state.choices.length + 1;
587
+ }
588
+ __name(render, "render");
589
+ function eraseLines(output, n) {
590
+ for (let i = 0; i < n; i++) {
591
+ output.write("\x1B[1A");
592
+ output.write("\x1B[2K");
593
+ }
594
+ }
595
+ __name(eraseLines, "eraseLines");
596
+ function highlight(s) {
597
+ return `\x1B[36m${s}\x1B[0m`;
598
+ }
599
+ __name(highlight, "highlight");
600
+ function dim(s) {
601
+ return `\x1B[2m${s}\x1B[0m`;
602
+ }
603
+ __name(dim, "dim");
604
+
605
+ // src/util/log.ts
606
+ var SGR = {
607
+ reset: "\x1B[0m",
608
+ dim: "\x1B[2m",
609
+ bold: "\x1B[1m",
610
+ red: "\x1B[31m",
611
+ green: "\x1B[32m",
612
+ yellow: "\x1B[33m",
613
+ blue: "\x1B[34m",
614
+ magenta: "\x1B[35m",
615
+ cyan: "\x1B[36m",
616
+ boldCyan: "\x1B[1;36m"
617
+ };
618
+ function colorsEnabled() {
619
+ if (process.env.NO_COLOR) return false;
620
+ if (process.env.FORCE_COLOR) return true;
621
+ return Boolean(process.stdout.isTTY);
622
+ }
623
+ __name(colorsEnabled, "colorsEnabled");
624
+ function paint(color, s) {
625
+ if (!colorsEnabled()) return s;
626
+ return `${SGR[color]}${s}${SGR.reset}`;
627
+ }
628
+ __name(paint, "paint");
629
+ function createLogger(prefix = "") {
630
+ const p = prefix ? `${paint("dim", `[${prefix}]`)} ` : "";
631
+ return {
632
+ info: /* @__PURE__ */ __name((m, ...r) => console.log(`${p}${m}`, ...r), "info"),
633
+ warn: /* @__PURE__ */ __name((m, ...r) => console.warn(`${p}${paint("yellow", "warn")} ${m}`, ...r), "warn"),
634
+ error: /* @__PURE__ */ __name((m, ...r) => console.error(`${p}${paint("red", "error")} ${m}`, ...r), "error"),
635
+ debug: /* @__PURE__ */ __name((m, ...r) => {
636
+ if (process.env.E2E_DEBUG) console.log(`${p}${paint("dim", "debug")} ${m}`, ...r);
637
+ }, "debug"),
638
+ step: /* @__PURE__ */ __name((m, ...r) => console.log(`${p}${paint("cyan", "\u2192")} ${m}`, ...r), "step"),
639
+ child: /* @__PURE__ */ __name((sub) => createLogger(prefix ? `${prefix}/${sub}` : sub), "child")
640
+ };
641
+ }
642
+ __name(createLogger, "createLogger");
643
+
644
+ // src/runner/init/run.ts
645
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
646
+ import { dirname, join, relative, resolve } from "path";
647
+ import { fileURLToPath } from "url";
648
+
649
+ // src/runner/init/environment-check.ts
650
+ import { execFileSync } from "child_process";
651
+ var ExecError = class extends Error {
652
+ constructor(cmd, args) {
653
+ super(`${cmd} ${args.join(" ")} failed`);
654
+ this.cmd = cmd;
655
+ this.args = args;
656
+ }
657
+ cmd;
658
+ args;
659
+ static {
660
+ __name(this, "ExecError");
661
+ }
662
+ };
663
+ function defaultExec(cmd, args) {
664
+ try {
665
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
666
+ } catch {
667
+ throw new ExecError(cmd, args);
668
+ }
669
+ }
670
+ __name(defaultExec, "defaultExec");
671
+ function runEnvironmentChecks(opts = {}) {
672
+ const results = [];
673
+ const platform = opts.platform ?? process.platform;
674
+ const nodeVersion = opts.nodeVersion ?? process.versions.node;
675
+ const exec4 = opts.exec ?? defaultExec;
676
+ if (platform !== "darwin") {
677
+ results.push({
678
+ name: "platform",
679
+ severity: opts.allowNonMacos ? "warning" : "error",
680
+ message: `macOS required (got "${platform}"). iOS simulators only run on macOS (Apple licensing).`,
681
+ detail: opts.allowNonMacos ? "Continuing with --allow-non-macos. Tests cannot run without a macOS host." : "Re-run with --allow-non-macos if you only need to scaffold files (e.g. preparing a macOS CI runner)."
682
+ });
683
+ if (!opts.allowNonMacos) return results;
684
+ } else {
685
+ results.push({ name: "platform", severity: "ok", message: "macOS detected" });
686
+ }
687
+ const major = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10);
688
+ if (major < 20) {
689
+ results.push({
690
+ name: "node",
691
+ severity: "error",
692
+ message: `Node 20+ required (got ${nodeVersion}).`,
693
+ detail: "Upgrade via nvm/fnm/volta."
694
+ });
695
+ return results;
696
+ }
697
+ results.push({ name: "node", severity: "ok", message: `Node ${nodeVersion}` });
698
+ if (platform !== "darwin") return results;
699
+ try {
700
+ const xcodePath = exec4("xcode-select", ["-p"]).trim();
701
+ results.push({ name: "xcode-cli", severity: "ok", message: `Xcode CLI tools at ${xcodePath}` });
702
+ } catch {
703
+ results.push({
704
+ name: "xcode-cli",
705
+ severity: "error",
706
+ message: "Xcode Command Line Tools not found.",
707
+ detail: "Install: `xcode-select --install`"
708
+ });
709
+ return results;
710
+ }
711
+ try {
712
+ exec4("xcrun", ["simctl", "help"]);
713
+ results.push({ name: "simctl", severity: "ok", message: "xcrun simctl available" });
714
+ } catch {
715
+ results.push({
716
+ name: "simctl",
717
+ severity: "error",
718
+ message: "xcrun simctl not available.",
719
+ detail: "Install full Xcode (from Mac App Store), not just Command Line Tools."
720
+ });
721
+ return results;
722
+ }
723
+ try {
724
+ const json = exec4("xcrun", ["simctl", "list", "devices", "available", "--json"]);
725
+ const parsed = JSON.parse(json);
726
+ const total = Object.values(parsed.devices).reduce((sum, list) => sum + list.length, 0);
727
+ if (total === 0) {
728
+ results.push({
729
+ name: "simulators",
730
+ severity: "warning",
731
+ message: "No iOS simulators found.",
732
+ detail: "Create one in Xcode \u2192 Window \u2192 Devices and Simulators \u2192 '+'."
733
+ });
734
+ } else {
735
+ results.push({ name: "simulators", severity: "ok", message: `${total} simulators available` });
736
+ }
737
+ } catch {
738
+ results.push({
739
+ name: "simulators",
740
+ severity: "warning",
741
+ message: "Could not enumerate simulators."
742
+ });
743
+ }
744
+ try {
745
+ const pkgRaw = exec4("cat", ["package.json"]);
746
+ const pkg = JSON.parse(pkgRaw);
747
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
748
+ const isRn = "react-native" in deps || "expo" in deps;
749
+ if (isRn) {
750
+ results.push({ name: "project-type", severity: "ok", message: "React Native / Expo detected" });
751
+ } else {
752
+ results.push({
753
+ name: "project-type",
754
+ severity: "ok",
755
+ message: "no React Native / Expo dependency in package.json",
756
+ detail: "Assuming a native iOS app (Swift/SwiftUI/Obj-C) or a non-Node project. Selectors resolve via the iOS accessibility tree \u2014 set accessibilityIdentifier on the views you want to target."
757
+ });
758
+ }
759
+ } catch {
760
+ results.push({
761
+ name: "project-type",
762
+ severity: "ok",
763
+ message: "no package.json in current directory",
764
+ detail: "That's fine for native iOS projects. If this is a Node-based project, run from its root so unotest-mobile can detect React Native / Expo."
765
+ });
766
+ }
767
+ return results;
768
+ }
769
+ __name(runEnvironmentChecks, "runEnvironmentChecks");
770
+
771
+ // src/runner/init/file-templates.ts
772
+ var templates = {
773
+ smokeWelcome: `// id-smoke-welcome
774
+ // First-run sanity: harness reaches sim, WDA, and app launch handshake
775
+ // #00aa00
776
+ function test_smoke_welcome() {
777
+ setDevice("A");
778
+ appLaunch(true);
779
+ }
780
+ `,
781
+ // Canonical syntax reference for AI agents writing new scenarios. The
782
+ // sentinel banner up top is the strongest single-line signal against the
783
+ // default "this is modern JS" assumption that drives agents to write
784
+ // import/export/async/await. Skill references this file by path; eval
785
+ // harness seeds the same content into temp workdirs.
786
+ e2eTemplateExample: `// unotest-mobile JS-DSL \u2014 NOT Node.js.
787
+ //
788
+ // Bare top-level functions only. NO \`import\`/\`export\`/\`async\`/\`await\`/
789
+ // \`const\`/\`let\`/\`var\`/arrow-functions. Assignments are bare-name: \`x = expr;\`.
790
+ // Member access (\`obj.field\`) and object literals (\`{key: value}\`) are
791
+ // forbidden \u2014 payloads are passed as JSON strings.
792
+ //
793
+ // After editing any scenario in this directory:
794
+ // npx unotest-mobile lint
795
+ //
796
+ // Below: minimal valid example. Copy its shape when writing new scenarios.
797
+
798
+ // id-example-001
799
+ // Example sign-in flow
800
+ // #4a90e2
801
+ function test_example() {
802
+ setDevice("A");
803
+ appLaunch(true);
804
+
805
+ type(getByTestId("email-input"), "user@example.com");
806
+ type(getByTestId("password-input"), "secret123");
807
+ tap(getByTestId("signin-button"));
808
+
809
+ waitFor(getByTestId("home-screen"), 15000);
810
+ }
811
+ `,
812
+ scenarioTemplate: `// id-<your-scenario-id>
813
+ // <one-line description>
814
+ // #888888
815
+ function test_<your_name>() {
816
+ // 1. SETUP \u2014 DB / API fixtures (helpers from _helpers/)
817
+ // wipe_e2e_users();
818
+ // user_id = seed_user("e2e@example.com", "e2e-pass");
819
+
820
+ // 2. ENTER \u2014 bring app to initial UI state
821
+ setDevice("A");
822
+ appLaunch(true);
823
+ waitFor(getByTestId("screen-welcome"), 15000);
824
+
825
+ // 3. ACT \u2014 actions you're testing
826
+ // signin("e2e@example.com", "e2e-pass");
827
+ // tap(getByTestId("btn-something"));
828
+
829
+ // 4. ASSERT \u2014 UI + DB / API checks
830
+ // assertVisible(getByTestId("screen-target"));
831
+ // count = dbQuery("SELECT count(*)::text FROM x WHERE ...");
832
+ // assertEqual(count, "1");
833
+ }
834
+ `,
835
+ agentsMd: `# AI Agents: how to write e2e tests for this project
836
+
837
+ This project uses [\`@unotest/mobile\`](https://www.npmjs.com/package/@unotest/mobile)
838
+ for end-to-end testing of iOS React Native flows.
839
+
840
+ ## Layout
841
+
842
+ - \`unotest/e2e/\` \u2014 scenarios (one \`test_*\` entry function per file)
843
+ - \`unotest/e2e/_helpers/\` \u2014 project-specific helpers, visible to all scenarios
844
+ - \`unotest/e2e/<feature>/\` \u2014 feature-scoped subfolders (recommended)
845
+
846
+ ## How to write a test
847
+
848
+ The full guide \u2014 DSL functions, scenario shape, helper rules, linter codes,
849
+ debugging \u2014 is the \`write-e2e-test\` Claude Code skill at
850
+ \`.claude/skills/write-e2e-test.md\`. It is the single source of truth for
851
+ both Claude Code and other AI agents. Read it before writing tests.
852
+
853
+ ## Running
854
+
855
+ - \`npx @unotest/mobile e2e <name>\` \u2014 run \`unotest/e2e/<name>.js\`
856
+ - \`npx @unotest/mobile lint\` \u2014 static check of all scenarios
857
+ - \`npx @unotest/mobile doctor\` \u2014 re-check environment
858
+
859
+ Inside Claude Code, prefer the MCP \`run_test\` tool with
860
+ \`pauseOnFailure: true\` \u2014 it pauses on the failed step so you can
861
+ \`inspect_runtime\`, fix the scenario, and \`resume\`.
862
+ `,
863
+ envExample: `# unotest-mobile \u2014 copied to unotest/.env by \`init\`. Fill in only what
864
+ # your scenarios actually use. Most variables are optional.
865
+
866
+ # --- Simulators (required for any UI scenario) -----------------------------
867
+ # Names must match \`xcrun simctl list devices\`. SIM_POOL controls which
868
+ # slots are active; each slot in the pool needs the matching SIM_<slot>_NAME.
869
+ #
870
+ # If you have multiple sims with the same name across iOS versions,
871
+ # disambiguate via "<name> @ <runtime>" \u2014 runtime is a substring match,
872
+ # so "iOS 17" or "iOS 17.5" both work.
873
+ # SIM_A_NAME= # pick interactively on first \`install\`
874
+ # SIM_A_NAME=iPhone 16 @ iOS 17.5 # pin to a specific runtime
875
+ # SIM_B_NAME= # only needed if SIM_POOL includes "B"
876
+ # SIM_POOL=A,B # default \u2014 drop "B" if you only need one sim
877
+
878
+ # --- App under test (required if scenarios call appLaunch / openDeeplink) --
879
+ APP_BUNDLE_ID=com.example.myapp
880
+ # APP_URL_SCHEME=myapp # only used by Expo dev-client recovery flow
881
+ # Optional. Path to a built .app bundle. When set, \`unotest-mobile install\`
882
+ # (and the \`app_install\` MCP tool) can be invoked without an explicit path.
883
+ # Useful for repeated installs after each app rebuild.
884
+ # APP_PATH=./build-sim/Build/Products/Release-iphonesimulator/MyApp.app
885
+ # Comma-separated \`simctl privacy\` services to auto-grant on \`install --clean\`.
886
+ # Populated by \`unotest-mobile install --update-env\` from your app's
887
+ # Info.plist NS*UsageDescription keys. Pre-empts the iOS permission dialog
888
+ # on first launch \u2014 those dialogs live in SpringBoard, not in the app's
889
+ # a11y tree, and would otherwise block your scenarios.
890
+ # APP_PERMISSIONS=location,motion
891
+
892
+ # --- Backend (optional \u2014 only required if scenarios use apiCall / db) ------
893
+ # API_BASE_URL=http://localhost:3000/api
894
+ # DATABASE_URL=postgresql://postgres:postgres@localhost:5432/myapp_test
895
+ # Drivers (install peer-deps as needed):
896
+ # npm i -D pg @types/pg # for postgresql://
897
+ # npm i -D mysql2 # for mysql://
898
+ # SQLite via better-sqlite3 is bundled \u2014 no separate install.
899
+ # Docker-compose Postgres/MySQL: ensure host port is exposed
900
+ # (\`ports: ["5432:5432"]\`). Native clients connect from the host, not
901
+ # from inside the compose network.
902
+
903
+ # --- Optional: shell() default cwd -----------------------------------------
904
+ # Absolute path used as default cwd for the \`shell(...)\` DSL primitive.
905
+ # When unset, shell commands run from process.cwd(). Set to your project
906
+ # root if your scenarios shell out to project-local CLIs that must run
907
+ # from there.
908
+ # PROJECT_ROOT=
909
+
910
+ # --- WebDriverAgent --------------------------------------------------------
911
+ # Per-slot WDA ports. Each slot in SIM_POOL needs one. Format: "slot=port".
912
+ WDA_PORTS=A=8100,B=8101
913
+ # WDA_DEFAULT_ACTION_WAIT_MS=2000
914
+ # WDA_DEFAULT_WAITFOR_TIMEOUT_MS=10000
915
+
916
+ # --- Artifacts / sessions / paused-runtime TTL -----------------------------
917
+ # Defaults are sensible \u2014 uncomment only to override.
918
+ # SESSION_LOG_PATH=unotest/sessions/current.jsonl
919
+ # ARTIFACTS_DIR=unotest/artifacts
920
+ # PAUSED_RUNTIME_TTL_MS=1800000 # 30 min before paused-on-failure auto-abort
921
+
922
+ # --- Expo dev-client (reserved for future use) -----------------------------
923
+ # METRO_URL=http://localhost:8081
924
+ # EXPO_DEV_CLIENT=false
925
+ `,
926
+ gitignoreLines: [
927
+ "",
928
+ "# unotest-mobile",
929
+ "unotest/.env",
930
+ "unotest/artifacts/",
931
+ "unotest/sessions/"
932
+ ],
933
+ mcpServerEntry: {
934
+ command: "npx",
935
+ args: ["-y", "@unotest/mobile"]
936
+ }
937
+ };
938
+
939
+ // src/runner/init/mcp-config-merger.ts
940
+ var McpJsonParseError = class extends Error {
941
+ static {
942
+ __name(this, "McpJsonParseError");
943
+ }
944
+ constructor(message) {
945
+ super(message);
946
+ this.name = "McpJsonParseError";
947
+ }
948
+ };
949
+ function mergeMcpConfig(existingSource, serverName, serverConfig, options = {}) {
950
+ if (existingSource === null) {
951
+ const content2 = JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2) + "\n";
952
+ return { action: "created", newContent: content2 };
953
+ }
954
+ let parsed;
955
+ try {
956
+ parsed = JSON.parse(existingSource);
957
+ } catch (e) {
958
+ throw new McpJsonParseError(
959
+ `Failed to parse .mcp.json as JSON: ${e.message}. Fix or remove the file and re-run init.`
960
+ );
961
+ }
962
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
963
+ parsed.mcpServers = {};
964
+ }
965
+ const alreadyPresent = serverName in parsed.mcpServers;
966
+ if (alreadyPresent && !options.force) {
967
+ return { action: "already-present", newContent: existingSource };
968
+ }
969
+ parsed.mcpServers[serverName] = serverConfig;
970
+ const content = JSON.stringify(parsed, null, 2) + "\n";
971
+ return {
972
+ action: alreadyPresent ? "force-overwrote" : "added",
973
+ newContent: content
974
+ };
975
+ }
976
+ __name(mergeMcpConfig, "mergeMcpConfig");
977
+
978
+ // src/runner/init/gitignore-updater.ts
979
+ function appendUniqueLines(existingContent, linesToAdd) {
980
+ const existing = existingContent ?? "";
981
+ const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
982
+ const added = [];
983
+ const alreadyPresent = [];
984
+ for (const line of linesToAdd) {
985
+ const trimmed = line.trim();
986
+ if (trimmed === "" || existingLines.has(trimmed)) {
987
+ if (trimmed !== "") alreadyPresent.push(line);
988
+ continue;
989
+ }
990
+ added.push(line);
991
+ existingLines.add(trimmed);
992
+ }
993
+ if (added.length === 0) {
994
+ return { added, alreadyPresent, newContent: existing };
995
+ }
996
+ const sep = existing === "" ? "" : existing.endsWith("\n") ? "" : "\n";
997
+ const newContent = existing + sep + added.join("\n") + "\n";
998
+ return { added, alreadyPresent, newContent };
999
+ }
1000
+ __name(appendUniqueLines, "appendUniqueLines");
1001
+
1002
+ // src/runner/init/run.ts
1003
+ var here = dirname(fileURLToPath(import.meta.url));
1004
+ var packageRoot = resolve(here, "..", "..");
1005
+ function parseInitArgs(argv) {
1006
+ return {
1007
+ force: argv.includes("--force"),
1008
+ allowNonMacos: argv.includes("--allow-non-macos")
1009
+ };
1010
+ }
1011
+ __name(parseInitArgs, "parseInitArgs");
1012
+ function symbol(severity) {
1013
+ return severity === "ok" ? "\u2713" : severity === "warning" ? "\u26A0" : "\u2717";
1014
+ }
1015
+ __name(symbol, "symbol");
1016
+ function ensureDir(p) {
1017
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
1018
+ }
1019
+ __name(ensureDir, "ensureDir");
1020
+ function writeIfNeeded(filePath, content, force) {
1021
+ const exists = existsSync(filePath);
1022
+ if (exists && !force) return "skipped";
1023
+ ensureDir(dirname(filePath));
1024
+ writeFileSync(filePath, content);
1025
+ return exists ? "overwrote" : "created";
1026
+ }
1027
+ __name(writeIfNeeded, "writeIfNeeded");
1028
+ function readPackageFile(relativePath) {
1029
+ const abs = resolve(packageRoot, relativePath);
1030
+ if (!existsSync(abs)) return null;
1031
+ return readFileSync(abs, "utf8");
1032
+ }
1033
+ __name(readPackageFile, "readPackageFile");
1034
+ function runInit(argv = process.argv.slice(2)) {
1035
+ const opts = parseInitArgs(argv);
1036
+ const target = process.cwd();
1037
+ console.log("unotest-mobile init \u2014 bootstrapping project\n");
1038
+ console.log("Environment:");
1039
+ const checks = runEnvironmentChecks({ allowNonMacos: opts.allowNonMacos });
1040
+ let hardFailed = false;
1041
+ for (const c of checks) {
1042
+ console.log(` ${symbol(c.severity)} ${c.name}: ${c.message}`);
1043
+ if (c.detail) console.log(` ${c.detail}`);
1044
+ if (c.severity === "error") hardFailed = true;
1045
+ }
1046
+ if (hardFailed) {
1047
+ console.error("\nFix the errors above and re-run.");
1048
+ return 1;
1049
+ }
1050
+ console.log("\nFiles:");
1051
+ const summary = [];
1052
+ ensureDir(join(target, "unotest/e2e/_helpers"));
1053
+ ensureDir(join(target, "unotest/e2e/_template"));
1054
+ summary.push({
1055
+ path: "unotest/e2e/smoke-welcome.js",
1056
+ status: writeIfNeeded(join(target, "unotest/e2e/smoke-welcome.js"), templates.smokeWelcome, opts.force)
1057
+ });
1058
+ summary.push({
1059
+ path: "unotest/e2e/_template.js",
1060
+ status: writeIfNeeded(join(target, "unotest/e2e/_template.js"), templates.scenarioTemplate, opts.force)
1061
+ });
1062
+ summary.push({
1063
+ path: "unotest/e2e/_template/example.js",
1064
+ status: writeIfNeeded(
1065
+ join(target, "unotest/e2e/_template/example.js"),
1066
+ templates.e2eTemplateExample,
1067
+ opts.force
1068
+ )
1069
+ });
1070
+ summary.push({
1071
+ path: "unotest/AGENTS.md",
1072
+ status: writeIfNeeded(join(target, "unotest/AGENTS.md"), templates.agentsMd, opts.force)
1073
+ });
1074
+ const skillSrc = readPackageFile(".claude/skills/write-e2e-test.md");
1075
+ if (skillSrc !== null) {
1076
+ summary.push({
1077
+ path: ".claude/skills/write-e2e-test.md",
1078
+ status: writeIfNeeded(join(target, ".claude/skills/write-e2e-test.md"), skillSrc, opts.force)
1079
+ });
1080
+ } else {
1081
+ summary.push({ path: ".claude/skills/write-e2e-test.md", status: "missing-in-package" });
1082
+ }
1083
+ const mcpPath = join(target, ".mcp.json");
1084
+ try {
1085
+ const existing = existsSync(mcpPath) ? readFileSync(mcpPath, "utf8") : null;
1086
+ const merge = mergeMcpConfig(
1087
+ existing,
1088
+ "unotest-mobile",
1089
+ {
1090
+ command: templates.mcpServerEntry.command,
1091
+ args: [...templates.mcpServerEntry.args]
1092
+ },
1093
+ { force: opts.force }
1094
+ );
1095
+ if (merge.action !== "already-present") {
1096
+ writeFileSync(mcpPath, merge.newContent);
1097
+ }
1098
+ summary.push({ path: ".mcp.json", status: merge.action });
1099
+ } catch (e) {
1100
+ if (e instanceof McpJsonParseError) {
1101
+ console.error(`
1102
+ \u2717 .mcp.json: ${e.message}`);
1103
+ return 1;
1104
+ }
1105
+ throw e;
1106
+ }
1107
+ const envExamplePath = join(target, "unotest/.env.example");
1108
+ summary.push({
1109
+ path: "unotest/.env.example",
1110
+ status: writeIfNeeded(envExamplePath, templates.envExample, true)
1111
+ });
1112
+ const envDst = join(target, "unotest/.env");
1113
+ if (!existsSync(envDst)) {
1114
+ writeFileSync(envDst, templates.envExample);
1115
+ summary.push({ path: "unotest/.env", status: "created" });
1116
+ } else {
1117
+ summary.push({ path: "unotest/.env", status: "skipped (exists \u2014 fill in manually)" });
1118
+ }
1119
+ const gitignorePath = join(target, ".gitignore");
1120
+ const gitignoreExisting = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : null;
1121
+ const giUpdate = appendUniqueLines(gitignoreExisting, [...templates.gitignoreLines]);
1122
+ if (giUpdate.added.length > 0) {
1123
+ writeFileSync(gitignorePath, giUpdate.newContent);
1124
+ summary.push({ path: ".gitignore", status: `added ${giUpdate.added.length} line(s)` });
1125
+ } else {
1126
+ summary.push({ path: ".gitignore", status: "already up-to-date" });
1127
+ }
1128
+ for (const item of summary) {
1129
+ const symbolForStatus = item.status === "created" || item.status.startsWith("added") ? "\u2713" : item.status === "skipped" || item.status.startsWith("already") ? "\xB7" : item.status === "overwrote" || item.status === "force-overwrote" ? "\u21BB" : "?";
1130
+ console.log(` ${symbolForStatus} ${item.path} \u2014 ${item.status}`);
1131
+ }
1132
+ console.log(`
1133
+ Next:
1134
+ 1. Edit ${relative(target, envDst) || "unotest/.env"} with your project's values
1135
+ (DATABASE_URL, SIM_A_NAME, APP_BUNDLE_ID, ...)
1136
+ 2. Boot iOS simulators matching SIM_A_NAME / SIM_B_NAME (Xcode \u2192 Devices)
1137
+ 3. Open Claude Code in this directory \u2014 MCP server auto-registers via
1138
+ .mcp.json. Ask the agent to write a test.
1139
+ 4. CLI: \`npx @unotest/mobile e2e smoke-welcome\` to run the starter.
1140
+
1141
+ Re-check environment anytime: \`npx @unotest/mobile doctor\`
1142
+ `);
1143
+ return 0;
1144
+ }
1145
+ __name(runInit, "runInit");
1146
+
1147
+ // src/runner/install/install-app.ts
1148
+ import { execFile as execFile3 } from "child_process";
1149
+ import { existsSync as existsSync2, statSync } from "fs";
1150
+ import { resolve as resolve2 } from "path";
1151
+ import { promisify as promisify3 } from "util";
1152
+
1153
+ // src/runner/install/info-plist.ts
1154
+ import { execFile as execFile2 } from "child_process";
1155
+ import { join as join2 } from "path";
1156
+ import { promisify as promisify2 } from "util";
1157
+ var exec2 = promisify2(execFile2);
1158
+ async function readInfoPlist(appPath) {
1159
+ const plistPath = join2(appPath, "Info.plist");
1160
+ const { stdout } = await exec2("plutil", ["-convert", "json", "-o", "-", plistPath]);
1161
+ const raw = JSON.parse(stdout);
1162
+ const bundleId = typeof raw.CFBundleIdentifier === "string" ? raw.CFBundleIdentifier : void 0;
1163
+ const urlSchemes = [];
1164
+ if (Array.isArray(raw.CFBundleURLTypes)) {
1165
+ for (const type of raw.CFBundleURLTypes) {
1166
+ if (type && typeof type === "object" && Array.isArray(type.CFBundleURLSchemes)) {
1167
+ for (const scheme of type.CFBundleURLSchemes) {
1168
+ if (typeof scheme === "string") urlSchemes.push(scheme);
1169
+ }
1170
+ }
1171
+ }
1172
+ }
1173
+ const usageDescriptions = [];
1174
+ for (const [key, value] of Object.entries(raw)) {
1175
+ if (key.startsWith("NS") && key.endsWith("UsageDescription") && typeof value === "string") {
1176
+ usageDescriptions.push({ key, description: value });
1177
+ }
1178
+ }
1179
+ return { ...bundleId ? { bundleId } : {}, urlSchemes, usageDescriptions };
1180
+ }
1181
+ __name(readInfoPlist, "readInfoPlist");
1182
+
1183
+ // src/runner/install/permission-mapper.ts
1184
+ var NS_TO_SIMCTL_SERVICE = Object.freeze({
1185
+ // Location
1186
+ NSLocationWhenInUseUsageDescription: "location",
1187
+ NSLocationAlwaysAndWhenInUseUsageDescription: "location-always",
1188
+ NSLocationAlwaysUsageDescription: "location-always",
1189
+ // Media
1190
+ NSPhotoLibraryUsageDescription: "photos",
1191
+ NSPhotoLibraryAddUsageDescription: "photos-add",
1192
+ NSMicrophoneUsageDescription: "microphone",
1193
+ NSMediaLibraryUsageDescription: "media-library",
1194
+ // Personal data
1195
+ NSContactsUsageDescription: "contacts",
1196
+ NSCalendarsUsageDescription: "calendar",
1197
+ // legacy (pre-iOS 17)
1198
+ NSCalendarsFullAccessUsageDescription: "calendar",
1199
+ // iOS 17+ preferred
1200
+ NSCalendarsWriteOnlyAccessUsageDescription: "calendar",
1201
+ // iOS 17+ write-only
1202
+ NSRemindersUsageDescription: "reminders",
1203
+ // Sensors / device
1204
+ NSMotionUsageDescription: "motion",
1205
+ // Siri
1206
+ NSSiriUsageDescription: "siri",
1207
+ // Camera + push notifications are not exposed via `simctl privacy` —
1208
+ // camera needs the alert-dismiss path (B1), push needs UNUserNotificationCenter.
1209
+ NSCameraUsageDescription: null
1210
+ });
1211
+ var KNOWN_SIMCTL_SERVICES = new Set(
1212
+ Object.values(NS_TO_SIMCTL_SERVICE).filter((v) => v !== null)
1213
+ );
1214
+ function inferSimctlServices(usageDescriptions) {
1215
+ const seen = /* @__PURE__ */ new Set();
1216
+ const out = [];
1217
+ for (const u of usageDescriptions) {
1218
+ const svc = NS_TO_SIMCTL_SERVICE[u.key];
1219
+ if (svc !== void 0 && svc !== null && !seen.has(svc)) {
1220
+ seen.add(svc);
1221
+ out.push(svc);
1222
+ }
1223
+ }
1224
+ return out;
1225
+ }
1226
+ __name(inferSimctlServices, "inferSimctlServices");
1227
+
1228
+ // src/runner/install/install-app.ts
1229
+ var exec3 = promisify3(execFile3);
1230
+ async function installApp2(opts, deps) {
1231
+ const appPathAbsolute = resolve2(opts.appPath);
1232
+ if (!existsSync2(appPathAbsolute)) {
1233
+ throw new Error(`App path does not exist: ${appPathAbsolute}`);
1234
+ }
1235
+ if (!appPathAbsolute.endsWith(".app")) {
1236
+ throw new Error(
1237
+ `App path must point to a .app bundle (directory ending in .app), got: ${appPathAbsolute}. If you have an .ipa or .zip \u2014 extract it first.`
1238
+ );
1239
+ }
1240
+ if (!statSync(appPathAbsolute).isDirectory()) {
1241
+ throw new Error(`App path must be a directory (.app bundle), got file: ${appPathAbsolute}`);
1242
+ }
1243
+ const infoPlist = `${appPathAbsolute}/Info.plist`;
1244
+ if (!existsSync2(infoPlist)) {
1245
+ throw new Error(`Info.plist not found inside .app: ${infoPlist}`);
1246
+ }
1247
+ const readBundleId = deps.readBundleId ?? readBundleIdViaPlistBuddy;
1248
+ const appBundleId = (await readBundleId(appPathAbsolute)).trim();
1249
+ if (!appBundleId) {
1250
+ throw new Error(`Could not read CFBundleIdentifier from ${infoPlist}`);
1251
+ }
1252
+ const readUrlScheme = deps.readUrlScheme ?? readUrlSchemeViaPlistBuddy;
1253
+ const appUrlScheme = await readUrlScheme(appPathAbsolute);
1254
+ const readUsageDescriptions = deps.readUsageDescriptions ?? (async (p) => (await readInfoPlist(p)).usageDescriptions);
1255
+ const usageDescriptions = await readUsageDescriptions(appPathAbsolute);
1256
+ const detectedPermissions = inferSimctlServices(usageDescriptions);
1257
+ if (opts.slots.length === 0) {
1258
+ throw new Error(`No slots to install on. Pass --slot or check SIM_POOL in unotest/.env.`);
1259
+ }
1260
+ for (const slot of opts.slots) {
1261
+ if (!opts.simBySlot[slot]) {
1262
+ throw new Error(
1263
+ `Slot "${slot}" requested but no SIM_${slot}_NAME in unotest/.env (or not in SIM_POOL).`
1264
+ );
1265
+ }
1266
+ }
1267
+ const slotResults = [];
1268
+ for (const slot of opts.slots) {
1269
+ const simName = opts.simBySlot[slot];
1270
+ const sim = await deps.simctl.resolveByName(simName);
1271
+ let erased = false;
1272
+ if (opts.erase) {
1273
+ deps.logger.info(`[${slot}] erasing ${simName} (${sim.udid})`);
1274
+ try {
1275
+ await deps.simctl.shutdown(sim.udid);
1276
+ } catch {
1277
+ }
1278
+ await deps.simctl.erase(sim.udid);
1279
+ erased = true;
1280
+ }
1281
+ deps.logger.info(`[${slot}] booting ${simName} (${sim.udid})`);
1282
+ await deps.simctl.boot(sim.udid);
1283
+ if (process.env.SIMCTL_HEADLESS !== "1") {
1284
+ await deps.simctl.openSimulatorApp();
1285
+ }
1286
+ let uninstalled = false;
1287
+ if (opts.clean) {
1288
+ deps.logger.info(`[${slot}] uninstalling existing ${appBundleId}`);
1289
+ try {
1290
+ await deps.simctl.uninstall(sim.udid, appBundleId);
1291
+ uninstalled = true;
1292
+ } catch {
1293
+ }
1294
+ deps.logger.info(`[${slot}] resetting keychain on ${sim.udid}`);
1295
+ await deps.simctl.keychainReset(sim.udid);
1296
+ }
1297
+ deps.logger.info(`[${slot}] installing ${appPathAbsolute}`);
1298
+ await deps.simctl.install(sim.udid, appPathAbsolute);
1299
+ if (opts.permissions && opts.permissions.length > 0) {
1300
+ for (const service of opts.permissions) {
1301
+ deps.logger.info(`[${slot}] granting ${service} to ${appBundleId}`);
1302
+ await deps.simctl.privacyGrant(sim.udid, service, appBundleId);
1303
+ }
1304
+ }
1305
+ if (opts.pinKeyboard !== false) {
1306
+ deps.logger.info(`[${slot}] pinning keyboard to en_US@QWERTY`);
1307
+ await deps.simctl.pinEnglishKeyboard(sim.udid);
1308
+ }
1309
+ let launched = false;
1310
+ if (opts.launch) {
1311
+ deps.logger.info(`[${slot}] launching ${appBundleId}`);
1312
+ const { pid } = await deps.simctl.launch(sim.udid, appBundleId);
1313
+ await deps.simctl.assertLaunchedAndStable(sim.udid, appBundleId, pid);
1314
+ launched = true;
1315
+ }
1316
+ slotResults.push({ slot, simName, udid: sim.udid, erased, uninstalled, launched });
1317
+ }
1318
+ return {
1319
+ appBundleId,
1320
+ appUrlScheme,
1321
+ appPathAbsolute,
1322
+ bundleIdMismatch: opts.envBundleId !== void 0 && opts.envBundleId !== appBundleId,
1323
+ detectedPermissions,
1324
+ slots: slotResults
1325
+ };
1326
+ }
1327
+ __name(installApp2, "installApp");
1328
+ async function readBundleIdViaPlistBuddy(appPath) {
1329
+ const { stdout } = await exec3("/usr/libexec/PlistBuddy", [
1330
+ "-c",
1331
+ "Print :CFBundleIdentifier",
1332
+ `${appPath}/Info.plist`
1333
+ ]);
1334
+ return stdout;
1335
+ }
1336
+ __name(readBundleIdViaPlistBuddy, "readBundleIdViaPlistBuddy");
1337
+ async function readUrlSchemeViaPlistBuddy(appPath) {
1338
+ try {
1339
+ const { stdout } = await exec3("/usr/libexec/PlistBuddy", [
1340
+ "-c",
1341
+ "Print :CFBundleURLTypes:0:CFBundleURLSchemes:0",
1342
+ `${appPath}/Info.plist`
1343
+ ]);
1344
+ const scheme = stdout.trim();
1345
+ return scheme.length > 0 ? scheme : void 0;
1346
+ } catch {
1347
+ return void 0;
1348
+ }
1349
+ }
1350
+ __name(readUrlSchemeViaPlistBuddy, "readUrlSchemeViaPlistBuddy");
1351
+
1352
+ // src/runner/install/readline-prompt.ts
1353
+ function createReadlinePromptIO() {
1354
+ return {
1355
+ async promptChoice(message, options) {
1356
+ try {
1357
+ return await interactiveSelect(
1358
+ {
1359
+ message,
1360
+ choices: options.map((o) => ({ label: o.label, value: o.value }))
1361
+ },
1362
+ {}
1363
+ );
1364
+ } catch (e) {
1365
+ if (e instanceof SelectAbortedError) {
1366
+ throw new Error(
1367
+ `cancelled \u2014 re-run when ready, or pass --sim-* flags / --yes for non-interactive setup.`
1368
+ );
1369
+ }
1370
+ throw e;
1371
+ }
1372
+ },
1373
+ print(line) {
1374
+ process.stderr.write(`${line}
1375
+ `);
1376
+ },
1377
+ close() {
1378
+ }
1379
+ };
1380
+ }
1381
+ __name(createReadlinePromptIO, "createReadlinePromptIO");
1382
+
1383
+ // src/runner/install/sim-resolver.ts
1384
+ var SKIP_SLOT = "__skip__";
1385
+ var MANUAL_EDIT = "__manual__";
1386
+ var ManualEditRequested = class extends Error {
1387
+ static {
1388
+ __name(this, "ManualEditRequested");
1389
+ }
1390
+ constructor(slot) {
1391
+ super(
1392
+ `Slot "${slot}" left unresolved \u2014 open unotest/.env, set SIM_${slot}_NAME to one of the available simulator names, and re-run.`
1393
+ );
1394
+ this.name = "ManualEditRequested";
1395
+ }
1396
+ };
1397
+ var NonInteractiveResolveError = class extends Error {
1398
+ static {
1399
+ __name(this, "NonInteractiveResolveError");
1400
+ }
1401
+ constructor(slot, reason, currentValue) {
1402
+ const detail = reason === "missing" ? `SIM_${slot}_NAME is not set in unotest/.env` : `SIM_${slot}_NAME="${currentValue}" does not match any installed simulator`;
1403
+ const skipHint = slot === "A" ? "" : ` (or --no-sim-${slot.toLowerCase()} to drop slot ${slot} from SIM_POOL)`;
1404
+ super(
1405
+ `${detail}. Running non-interactively \u2014 pass --sim-${slot.toLowerCase()}=<name>${skipHint}, or re-run in a terminal for an interactive picker.`
1406
+ );
1407
+ this.name = "NonInteractiveResolveError";
1408
+ }
1409
+ };
1410
+ var NoSimulatorsAvailableError = class extends Error {
1411
+ static {
1412
+ __name(this, "NoSimulatorsAvailableError");
1413
+ }
1414
+ constructor() {
1415
+ super(
1416
+ `No iOS simulators found on this machine. Install Xcode and create a simulator via Xcode \u2192 Window \u2192 Devices and Simulators, then re-run.`
1417
+ );
1418
+ this.name = "NoSimulatorsAvailableError";
1419
+ }
1420
+ };
1421
+ async function resolveSims(input, io) {
1422
+ const availableNames = new Set(input.availableSims.map((s) => s.name));
1423
+ const finalPool = [];
1424
+ const finalMapping = {};
1425
+ const envUpdates = [];
1426
+ let prompted = false;
1427
+ for (const slot of input.pool) {
1428
+ const override = input.overrides[slot];
1429
+ const current = input.simBySlot[slot];
1430
+ if (override === "skip") {
1431
+ envUpdates.push({ key: `SIM_POOL`, value: "__placeholder__" });
1432
+ io.print(`\xB7 slot ${slot} dropped (--no-sim-${slot.toLowerCase()})`);
1433
+ continue;
1434
+ }
1435
+ if (typeof override === "string") {
1436
+ if (!availableNames.has(override)) {
1437
+ throw new Error(
1438
+ `--sim-${slot.toLowerCase()}="${override}" does not match any installed simulator. Available: ${formatSimList(input.availableSims)}`
1439
+ );
1440
+ }
1441
+ finalPool.push(slot);
1442
+ finalMapping[slot] = override;
1443
+ if (override !== current) {
1444
+ envUpdates.push({ key: `SIM_${slot}_NAME`, value: override });
1445
+ }
1446
+ continue;
1447
+ }
1448
+ if (current && availableNames.has(current)) {
1449
+ finalPool.push(slot);
1450
+ finalMapping[slot] = current;
1451
+ continue;
1452
+ }
1453
+ if (input.nonInteractive) {
1454
+ throw new NonInteractiveResolveError(
1455
+ slot,
1456
+ current ? "not-found" : "missing",
1457
+ current
1458
+ );
1459
+ }
1460
+ if (input.availableSims.length === 0) {
1461
+ throw new NoSimulatorsAvailableError();
1462
+ }
1463
+ prompted = true;
1464
+ const reason = current ? `SIM_${slot}_NAME="${current}" doesn't match any installed simulator.` : `SIM_${slot}_NAME is not set in unotest/.env.`;
1465
+ const simChoices = input.availableSims.map((s) => ({
1466
+ label: `${s.name} @ ${s.runtime}${s.state === "Booted" ? " (booted)" : ""}`,
1467
+ value: s.name
1468
+ }));
1469
+ const options = [];
1470
+ if (slot !== "A") {
1471
+ options.push({
1472
+ label: `do not configure slot ${slot} \u2014 only one simulator needed`,
1473
+ value: SKIP_SLOT
1474
+ });
1475
+ }
1476
+ options.push(...simChoices);
1477
+ options.push({
1478
+ label: `edit unotest/.env manually and re-run`,
1479
+ value: MANUAL_EDIT
1480
+ });
1481
+ const picked = await io.promptChoice(
1482
+ `${reason}
1483
+ Pick a simulator for slot ${slot}:`,
1484
+ options
1485
+ );
1486
+ if (picked === MANUAL_EDIT) {
1487
+ throw new ManualEditRequested(slot);
1488
+ }
1489
+ if (picked === SKIP_SLOT) {
1490
+ io.print(`\xB7 slot ${slot} dropped from SIM_POOL`);
1491
+ continue;
1492
+ }
1493
+ finalPool.push(slot);
1494
+ finalMapping[slot] = picked;
1495
+ envUpdates.push({ key: `SIM_${slot}_NAME`, value: picked });
1496
+ io.print(`\u2713 SIM_${slot}_NAME=${picked}`);
1497
+ }
1498
+ if (finalPool.length === 0) {
1499
+ throw new Error(
1500
+ `All slots were skipped \u2014 install needs at least slot A. Re-run without --no-sim-a or pick a simulator for slot A.`
1501
+ );
1502
+ }
1503
+ const finalPoolStr = finalPool.join(",");
1504
+ const currentPoolStr = input.pool.join(",");
1505
+ const cleanedUpdates = envUpdates.filter((u) => u.value !== "__placeholder__");
1506
+ if (finalPoolStr !== currentPoolStr) {
1507
+ cleanedUpdates.push({ key: "SIM_POOL", value: finalPoolStr });
1508
+ io.print(`\u2713 SIM_POOL=${finalPoolStr}`);
1509
+ }
1510
+ return {
1511
+ pool: finalPool,
1512
+ simBySlot: finalMapping,
1513
+ envUpdates: cleanedUpdates,
1514
+ prompted
1515
+ };
1516
+ }
1517
+ __name(resolveSims, "resolveSims");
1518
+ function formatSimList(sims) {
1519
+ if (sims.length === 0) return "(none \u2014 install Xcode and create a simulator)";
1520
+ return sims.map((s) => `${s.name} @ ${s.runtime}`).join(", ");
1521
+ }
1522
+ __name(formatSimList, "formatSimList");
1523
+
1524
+ // src/runner/install/update-env-file.ts
1525
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1526
+ function updateEnvFile(path, updates) {
1527
+ const original = existsSync3(path) ? readFileSync2(path, "utf8") : "";
1528
+ const lines = original.split(/\r?\n/);
1529
+ const added = [];
1530
+ const changed = [];
1531
+ const unchanged = [];
1532
+ for (const { key, value } of updates) {
1533
+ const newLine = `${key}=${value}`;
1534
+ const re = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
1535
+ let foundAt = -1;
1536
+ for (let i = 0; i < lines.length; i++) {
1537
+ if (re.test(lines[i] ?? "")) {
1538
+ foundAt = i;
1539
+ break;
1540
+ }
1541
+ }
1542
+ if (foundAt === -1) {
1543
+ if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
1544
+ lines.push(newLine);
1545
+ added.push(key);
1546
+ } else if (lines[foundAt] === newLine) {
1547
+ unchanged.push(key);
1548
+ } else {
1549
+ lines[foundAt] = newLine;
1550
+ changed.push(key);
1551
+ }
1552
+ }
1553
+ if (added.length > 0 || changed.length > 0) {
1554
+ writeFileSync2(path, lines.join("\n"));
1555
+ }
1556
+ return { added, changed, unchanged };
1557
+ }
1558
+ __name(updateEnvFile, "updateEnvFile");
1559
+ function escapeRegex(s) {
1560
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1561
+ }
1562
+ __name(escapeRegex, "escapeRegex");
1563
+
1564
+ // src/runner/install.ts
1565
+ function parseArgs(argv) {
1566
+ const opts = {
1567
+ slot: "A",
1568
+ clean: false,
1569
+ erase: false,
1570
+ launch: false,
1571
+ updateEnv: false,
1572
+ noSimB: false,
1573
+ yes: false,
1574
+ noPermissions: false
1575
+ };
1576
+ for (let i = 0; i < argv.length; i++) {
1577
+ const a = argv[i];
1578
+ if (a === "--slot") {
1579
+ const v = argv[++i];
1580
+ if (v !== "A" && v !== "B" && v !== "all") {
1581
+ throw new Error(`--slot must be A, B, or all (got "${v}")`);
1582
+ }
1583
+ opts.slot = v;
1584
+ } else if (a === "--clean") {
1585
+ opts.clean = true;
1586
+ } else if (a === "--erase") {
1587
+ opts.erase = true;
1588
+ } else if (a === "--launch") {
1589
+ opts.launch = true;
1590
+ } else if (a === "--update-env") {
1591
+ opts.updateEnv = true;
1592
+ } else if (a === "--no-sim-b") {
1593
+ opts.noSimB = true;
1594
+ } else if (a === "--yes" || a === "-y") {
1595
+ opts.yes = true;
1596
+ } else if (a.startsWith("--sim-a=")) {
1597
+ opts.simA = a.slice("--sim-a=".length);
1598
+ } else if (a.startsWith("--sim-b=")) {
1599
+ opts.simB = a.slice("--sim-b=".length);
1600
+ } else if (a.startsWith("--permissions=")) {
1601
+ const raw = a.slice("--permissions=".length);
1602
+ opts.permissionsFlag = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1603
+ } else if (a === "--no-permissions") {
1604
+ opts.noPermissions = true;
1605
+ } else if (a === "-h" || a === "--help") {
1606
+ printHelp();
1607
+ process.exit(0);
1608
+ } else if (a.startsWith("--")) {
1609
+ throw new Error(`unknown flag: ${a}`);
1610
+ } else {
1611
+ if (opts.appPath !== void 0) {
1612
+ throw new Error(`only one path argument allowed (got "${opts.appPath}" and "${a}")`);
1613
+ }
1614
+ opts.appPath = a;
1615
+ }
1616
+ }
1617
+ if (opts.permissionsFlag !== void 0 && opts.noPermissions) {
1618
+ throw new Error(`--permissions=<list> and --no-permissions are mutually exclusive.`);
1619
+ }
1620
+ return opts;
1621
+ }
1622
+ __name(parseArgs, "parseArgs");
1623
+ function resolveCliPermissions(opts, env) {
1624
+ if (opts.permissionsFlag !== void 0) return opts.permissionsFlag;
1625
+ if (opts.noPermissions) return [];
1626
+ const envValue = env.APP_PERMISSIONS;
1627
+ if (envValue && envValue.trim().length > 0) {
1628
+ return envValue.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1629
+ }
1630
+ return void 0;
1631
+ }
1632
+ __name(resolveCliPermissions, "resolveCliPermissions");
1633
+ function shouldPersistPermissions(resolved, currentEnvValue) {
1634
+ if (resolved === void 0 || resolved.length === 0) return false;
1635
+ return resolved.join(",") !== (currentEnvValue ?? "");
1636
+ }
1637
+ __name(shouldPersistPermissions, "shouldPersistPermissions");
1638
+ function printHelp() {
1639
+ process.stdout.write(
1640
+ `Usage: unotest-mobile install [path-to-.app] [flags]
1641
+
1642
+ If [path] is omitted, reads APP_PATH from unotest/.env.
1643
+
1644
+ Flags:
1645
+ --slot A|B|all Which slot(s) to install on. Default: A.
1646
+ --clean Uninstall existing app at the same bundle id first.
1647
+ --erase Erase simulator content & settings before install (destructive).
1648
+ --launch Launch the app after install (quick sanity check).
1649
+ --update-env Persist what we discover (APP_PATH, APP_BUNDLE_ID,
1650
+ APP_URL_SCHEME, APP_PERMISSIONS) into unotest/.env.
1651
+ --permissions=<list> Override APP_PERMISSIONS for this run. Comma-separated
1652
+ simctl-privacy services (e.g. "location,motion").
1653
+ Empty value ("--permissions=") = grant nothing.
1654
+ --no-permissions Skip auto-grant even if APP_PERMISSIONS is set in env.
1655
+ Conflicts with --permissions=<list>.
1656
+ --sim-a=<name> Non-interactive: set SIM_A_NAME for this run + .env.
1657
+ --sim-b=<name> Non-interactive: set SIM_B_NAME for this run + .env.
1658
+ --no-sim-b Non-interactive: drop slot B from SIM_POOL.
1659
+ -y, --yes Non-interactive: accept current state; error if any
1660
+ slot is unresolved (use with the --sim-* flags).
1661
+ -h, --help This help.
1662
+
1663
+ Examples:
1664
+ unotest-mobile install ./build/MyApp.app
1665
+ unotest-mobile install ./MyApp.app --slot all --clean
1666
+ unotest-mobile install ./MyApp.app --update-env --launch
1667
+ unotest-mobile install ./MyApp.app -y --sim-a=UnoTest-A --no-sim-b
1668
+ `
1669
+ );
1670
+ }
1671
+ __name(printHelp, "printHelp");
1672
+ async function ensureSimsResolved(opts) {
1673
+ loadDotenv2({ path: ENV_FILE_PATH });
1674
+ const currentPoolStr = process.env.SIM_POOL ?? "A,B";
1675
+ const currentPool = currentPoolStr.split(",").map((s) => s.trim()).filter(Boolean);
1676
+ const simBySlot = {};
1677
+ for (const slot of currentPool) {
1678
+ simBySlot[slot] = process.env[`SIM_${slot}_NAME`];
1679
+ }
1680
+ const overrides = {};
1681
+ if (opts.simA !== void 0) overrides.A = opts.simA;
1682
+ if (opts.simB !== void 0) overrides.B = opts.simB;
1683
+ if (opts.noSimB) overrides.B = "skip";
1684
+ const availableSims = (await listSimulators()).map((s) => ({
1685
+ name: s.name,
1686
+ runtime: friendlyRuntime(s.runtime),
1687
+ state: s.state
1688
+ }));
1689
+ const nonInteractive = opts.yes || !process.stdin.isTTY;
1690
+ let io;
1691
+ if (nonInteractive) {
1692
+ io = {
1693
+ promptChoice: /* @__PURE__ */ __name(async () => {
1694
+ throw new Error("promptChoice invoked in non-interactive mode (bug)");
1695
+ }, "promptChoice"),
1696
+ print: /* @__PURE__ */ __name((line) => process.stderr.write(`${line}
1697
+ `), "print")
1698
+ };
1699
+ } else {
1700
+ io = createReadlinePromptIO();
1701
+ }
1702
+ try {
1703
+ const resolved = await resolveSims(
1704
+ { pool: currentPool, simBySlot, availableSims, overrides, nonInteractive },
1705
+ io
1706
+ );
1707
+ if (resolved.envUpdates.length > 0) {
1708
+ const envPath = resolve3(ENV_FILE_PATH);
1709
+ updateEnvFile(envPath, resolved.envUpdates);
1710
+ process.stderr.write(`\u2713 updated ${envPath}
1711
+ `);
1712
+ }
1713
+ for (const u of resolved.envUpdates) {
1714
+ process.env[u.key] = u.value;
1715
+ }
1716
+ process.env.SIM_POOL = resolved.pool.join(",");
1717
+ } finally {
1718
+ io.close?.();
1719
+ }
1720
+ }
1721
+ __name(ensureSimsResolved, "ensureSimsResolved");
1722
+ async function ensureProjectInitialized(opts) {
1723
+ if (existsSync4(ENV_FILE_PATH)) return true;
1724
+ const nonInteractive = opts.yes || !process.stdin.isTTY;
1725
+ if (nonInteractive) {
1726
+ process.stderr.write(
1727
+ `\u2717 ${ENV_FILE_PATH} not found \u2014 project hasn't been initialized.
1728
+ Run \`unotest-mobile init\` first, then re-run install.
1729
+ `
1730
+ );
1731
+ return false;
1732
+ }
1733
+ process.stderr.write(
1734
+ `${ENV_FILE_PATH} not found \u2014 looks like this project hasn't been initialized yet.
1735
+ `
1736
+ );
1737
+ let choice;
1738
+ try {
1739
+ choice = await interactiveSelect(
1740
+ {
1741
+ message: `What would you like to do?`,
1742
+ choices: [
1743
+ {
1744
+ label: `run \`unotest-mobile init\` now \u2014 creates unotest/ scaffold, .mcp.json, .env (recommended)`,
1745
+ value: "init"
1746
+ },
1747
+ { label: `cancel \u2014 I'll run init myself`, value: "abort" }
1748
+ ]
1749
+ },
1750
+ {}
1751
+ );
1752
+ } catch (e) {
1753
+ if (e instanceof SelectAbortedError) {
1754
+ process.stderr.write(`cancelled.
1755
+ `);
1756
+ return false;
1757
+ }
1758
+ throw e;
1759
+ }
1760
+ if (choice === "abort") {
1761
+ process.stderr.write(`Run \`unotest-mobile init\`, then re-run install.
1762
+ `);
1763
+ return false;
1764
+ }
1765
+ process.stderr.write(`
1766
+ `);
1767
+ const initExitCode = runInit([]);
1768
+ if (initExitCode !== 0) {
1769
+ process.stderr.write(`\u2717 init failed (exit ${initExitCode}) \u2014 aborting install.
1770
+ `);
1771
+ return false;
1772
+ }
1773
+ process.stderr.write(`
1774
+ `);
1775
+ return true;
1776
+ }
1777
+ __name(ensureProjectInitialized, "ensureProjectInitialized");
1778
+ async function confirmDetectedPermissions(detected) {
1779
+ try {
1780
+ return await interactiveSelect(
1781
+ {
1782
+ message: `Detected iOS permissions in Info.plist: ${detected.join(", ")}.
1783
+ Pre-grant on test sims (writes APP_PERMISSIONS to unotest/.env)?`,
1784
+ choices: [
1785
+ { label: `yes \u2014 grant ${detected.join(", ")} (recommended)`, value: true },
1786
+ { label: `skip \u2014 don't auto-grant, leave APP_PERMISSIONS unset`, value: false }
1787
+ ]
1788
+ },
1789
+ {}
1790
+ );
1791
+ } catch (e) {
1792
+ if (e instanceof SelectAbortedError) return false;
1793
+ throw e;
1794
+ }
1795
+ }
1796
+ __name(confirmDetectedPermissions, "confirmDetectedPermissions");
1797
+ async function main() {
1798
+ let opts;
1799
+ try {
1800
+ opts = parseArgs(process.argv.slice(2));
1801
+ } catch (e) {
1802
+ process.stderr.write(`\u2717 ${e.message}
1803
+ `);
1804
+ process.stderr.write(`Run \`unotest-mobile install --help\` for usage.
1805
+ `);
1806
+ return 2;
1807
+ }
1808
+ try {
1809
+ process.cwd();
1810
+ } catch (e) {
1811
+ if (e.code === "ENOENT") {
1812
+ process.stderr.write(
1813
+ `\u2717 Current directory is invalid (was it deleted while your shell was in it?).
1814
+ Run \`cd "$(pwd -P 2>/dev/null || echo ~)"\` or simply \`cd .\` in another fresh shell, then re-run.
1815
+ `
1816
+ );
1817
+ return 1;
1818
+ }
1819
+ throw e;
1820
+ }
1821
+ if (!await ensureProjectInitialized(opts)) {
1822
+ return 1;
1823
+ }
1824
+ try {
1825
+ await ensureSimsResolved(opts);
1826
+ } catch (e) {
1827
+ if (e instanceof ManualEditRequested || e instanceof NonInteractiveResolveError || e instanceof NoSimulatorsAvailableError) {
1828
+ process.stderr.write(`\u2717 ${e.message}
1829
+ `);
1830
+ return 1;
1831
+ }
1832
+ throw e;
1833
+ }
1834
+ const env = loadEnv();
1835
+ const appPath = opts.appPath ?? process.env.APP_PATH;
1836
+ if (!appPath) {
1837
+ process.stderr.write(
1838
+ `\u2717 No app path provided.
1839
+ Pass it as an argument: \`unotest-mobile install <path-to-.app>\`
1840
+ Or set APP_PATH in unotest/.env to skip the argument.
1841
+ `
1842
+ );
1843
+ return 2;
1844
+ }
1845
+ const slots = opts.slot === "all" ? env.simPool : env.simPool.includes(opts.slot) ? [opts.slot] : (() => {
1846
+ throw new Error(
1847
+ `--slot ${opts.slot} requested but slot "${opts.slot}" is not in SIM_POOL (${env.simPool.join(",")}).`
1848
+ );
1849
+ })();
1850
+ const logger = createLogger().child("install");
1851
+ const simctl = new SimctlAdapter();
1852
+ let permissions = resolveCliPermissions(opts, process.env);
1853
+ const nonInteractiveTop = opts.yes || !process.stdin.isTTY;
1854
+ if (permissions === void 0 && opts.updateEnv) {
1855
+ try {
1856
+ const plist = await readInfoPlist(resolve3(appPath));
1857
+ const detected = inferSimctlServices(plist.usageDescriptions);
1858
+ if (detected.length > 0) {
1859
+ const confirmed = nonInteractiveTop ? true : await confirmDetectedPermissions(detected);
1860
+ if (confirmed) permissions = detected;
1861
+ }
1862
+ } catch (e) {
1863
+ process.stderr.write(
1864
+ `[install] WARN: could not pre-read Info.plist for permissions detection: ${e.message}
1865
+ `
1866
+ );
1867
+ }
1868
+ }
1869
+ let result;
1870
+ try {
1871
+ result = await installApp2(
1872
+ {
1873
+ appPath,
1874
+ slots,
1875
+ simBySlot: env.simBySlot,
1876
+ envBundleId: env.APP_BUNDLE_ID,
1877
+ clean: opts.clean,
1878
+ erase: opts.erase,
1879
+ launch: opts.launch,
1880
+ ...permissions !== void 0 ? { permissions } : {}
1881
+ // S8 — keyboard pin is always on in v1; reserve opts.pinKeyboard
1882
+ // for future per-call override (no CLI flag yet by design).
1883
+ },
1884
+ { simctl, logger }
1885
+ );
1886
+ } catch (e) {
1887
+ process.stderr.write(`\u2717 install failed: ${e.message}
1888
+ `);
1889
+ return 1;
1890
+ }
1891
+ if (!opts.updateEnv && result.detectedPermissions.length > 0) {
1892
+ const envSet = new Set(
1893
+ (process.env.APP_PERMISSIONS ?? "").split(",").map((s) => s.trim()).filter(Boolean)
1894
+ );
1895
+ const newOnes = result.detectedPermissions.filter((s) => !envSet.has(s));
1896
+ if (newOnes.length > 0) {
1897
+ process.stdout.write(
1898
+ `
1899
+ \u2139 Detected permissions in Info.plist: ${result.detectedPermissions.join(", ")}.
1900
+ Re-run with --update-env to persist APP_PERMISSIONS and auto-grant next time.
1901
+ `
1902
+ );
1903
+ }
1904
+ }
1905
+ process.stdout.write(`
1906
+ \u2713 Installed ${result.appBundleId}
1907
+ `);
1908
+ process.stdout.write(` from: ${result.appPathAbsolute}
1909
+ `);
1910
+ if (result.appUrlScheme) {
1911
+ process.stdout.write(` url scheme: ${result.appUrlScheme}
1912
+ `);
1913
+ }
1914
+ for (const s of result.slots) {
1915
+ const flags = [
1916
+ s.erased ? "erased" : null,
1917
+ s.uninstalled ? "uninstalled-old" : null,
1918
+ s.launched ? "launched" : null
1919
+ ].filter(Boolean).join(", ");
1920
+ process.stdout.write(
1921
+ ` on slot ${s.slot} \u2192 ${s.simName} (${s.udid})${flags ? ` [${flags}]` : ""}
1922
+ `
1923
+ );
1924
+ }
1925
+ if (result.bundleIdMismatch) {
1926
+ process.stdout.write(
1927
+ `
1928
+ \u26A0 Bundle id mismatch:
1929
+ App's CFBundleIdentifier: ${result.appBundleId}
1930
+ APP_BUNDLE_ID in .env: ${env.APP_BUNDLE_ID}
1931
+ Tests will fail at appLaunch / WDA session start.
1932
+ ` + (opts.updateEnv ? ` --update-env was passed \u2192 syncing .env now.
1933
+ ` : ` Re-run with --update-env to sync, or fix unotest/.env manually.
1934
+ `)
1935
+ );
1936
+ }
1937
+ if (opts.updateEnv) {
1938
+ const updates = [{ key: "APP_PATH", value: result.appPathAbsolute }];
1939
+ if (result.bundleIdMismatch) {
1940
+ updates.push({ key: "APP_BUNDLE_ID", value: result.appBundleId });
1941
+ }
1942
+ if (result.appUrlScheme && !process.env.APP_URL_SCHEME) {
1943
+ updates.push({ key: "APP_URL_SCHEME", value: result.appUrlScheme });
1944
+ }
1945
+ if (shouldPersistPermissions(permissions, process.env.APP_PERMISSIONS)) {
1946
+ updates.push({ key: "APP_PERMISSIONS", value: permissions.join(",") });
1947
+ }
1948
+ const envPath = resolve3(ENV_FILE_PATH);
1949
+ const upd = updateEnvFile(envPath, updates);
1950
+ const summary = [
1951
+ upd.added.length > 0 ? `added ${upd.added.join(",")}` : null,
1952
+ upd.changed.length > 0 ? `changed ${upd.changed.join(",")}` : null,
1953
+ upd.unchanged.length > 0 ? `unchanged ${upd.unchanged.join(",")}` : null
1954
+ ].filter(Boolean).join("; ");
1955
+ process.stdout.write(`
1956
+ \u2713 ${envPath}: ${summary}
1957
+ `);
1958
+ }
1959
+ return 0;
1960
+ }
1961
+ __name(main, "main");
1962
+ runMain(main, 1);
1963
+ export {
1964
+ parseArgs,
1965
+ resolveCliPermissions,
1966
+ shouldPersistPermissions
1967
+ };