@unotest/mobile 0.1.1 → 0.8.1
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/.claude/skills/write-e2e-test.md +373 -261
- package/CHANGELOG.md +488 -0
- package/README.md +67 -93
- package/bin/mcp.js +66 -8
- package/dist/mcp/server.js +2262 -408
- package/dist/runner/cli.js +1237 -181
- package/dist/runner/doctor.js +0 -1
- package/dist/runner/init.js +131 -55
- package/dist/runner/install.js +1988 -0
- package/package.json +10 -3
- package/dist/mcp/server.js.map +0 -1
- package/dist/runner/cli.js.map +0 -1
- package/dist/runner/doctor.js.map +0 -1
- package/dist/runner/init.js.map +0 -1
|
@@ -0,0 +1,1988 @@
|
|
|
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
|
+
/**
|
|
934
|
+
* Build the `.mcp.json` entry for this package, pinned to the supplied
|
|
935
|
+
* version. Caller (`runInit`) reads its own `package.json:version` and
|
|
936
|
+
* passes it in, so the entry written into the consumer's project
|
|
937
|
+
* always matches the `init`-running copy. Pinning side-steps the
|
|
938
|
+
* `npx` stale-global / stale-cache ambush: a bare `@unotest/mobile`
|
|
939
|
+
* arg lets `npx` resolve to whatever globally-installed (often very
|
|
940
|
+
* old) copy a developer happens to have, which then crashes at
|
|
941
|
+
* startup against the current env schema.
|
|
942
|
+
*/
|
|
943
|
+
mcpServerEntry: /* @__PURE__ */ __name((version) => ({
|
|
944
|
+
command: "npx",
|
|
945
|
+
args: ["-y", `@unotest/mobile@${version}`]
|
|
946
|
+
}), "mcpServerEntry")
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// src/runner/init/mcp-config-merger.ts
|
|
950
|
+
var McpJsonParseError = class extends Error {
|
|
951
|
+
static {
|
|
952
|
+
__name(this, "McpJsonParseError");
|
|
953
|
+
}
|
|
954
|
+
constructor(message) {
|
|
955
|
+
super(message);
|
|
956
|
+
this.name = "McpJsonParseError";
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
function mergeMcpConfig(existingSource, serverName, serverConfig, options = {}) {
|
|
960
|
+
if (existingSource === null) {
|
|
961
|
+
const content2 = JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2) + "\n";
|
|
962
|
+
return { action: "created", newContent: content2 };
|
|
963
|
+
}
|
|
964
|
+
let parsed;
|
|
965
|
+
try {
|
|
966
|
+
parsed = JSON.parse(existingSource);
|
|
967
|
+
} catch (e) {
|
|
968
|
+
throw new McpJsonParseError(
|
|
969
|
+
`Failed to parse .mcp.json as JSON: ${e.message}. Fix or remove the file and re-run init.`
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
|
|
973
|
+
parsed.mcpServers = {};
|
|
974
|
+
}
|
|
975
|
+
const alreadyPresent = serverName in parsed.mcpServers;
|
|
976
|
+
if (alreadyPresent && !options.force) {
|
|
977
|
+
return { action: "already-present", newContent: existingSource };
|
|
978
|
+
}
|
|
979
|
+
parsed.mcpServers[serverName] = serverConfig;
|
|
980
|
+
const content = JSON.stringify(parsed, null, 2) + "\n";
|
|
981
|
+
return {
|
|
982
|
+
action: alreadyPresent ? "force-overwrote" : "added",
|
|
983
|
+
newContent: content
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
__name(mergeMcpConfig, "mergeMcpConfig");
|
|
987
|
+
|
|
988
|
+
// src/runner/init/gitignore-updater.ts
|
|
989
|
+
function appendUniqueLines(existingContent, linesToAdd) {
|
|
990
|
+
const existing = existingContent ?? "";
|
|
991
|
+
const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
992
|
+
const added = [];
|
|
993
|
+
const alreadyPresent = [];
|
|
994
|
+
for (const line of linesToAdd) {
|
|
995
|
+
const trimmed = line.trim();
|
|
996
|
+
if (trimmed === "" || existingLines.has(trimmed)) {
|
|
997
|
+
if (trimmed !== "") alreadyPresent.push(line);
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
added.push(line);
|
|
1001
|
+
existingLines.add(trimmed);
|
|
1002
|
+
}
|
|
1003
|
+
if (added.length === 0) {
|
|
1004
|
+
return { added, alreadyPresent, newContent: existing };
|
|
1005
|
+
}
|
|
1006
|
+
const sep = existing === "" ? "" : existing.endsWith("\n") ? "" : "\n";
|
|
1007
|
+
const newContent = existing + sep + added.join("\n") + "\n";
|
|
1008
|
+
return { added, alreadyPresent, newContent };
|
|
1009
|
+
}
|
|
1010
|
+
__name(appendUniqueLines, "appendUniqueLines");
|
|
1011
|
+
|
|
1012
|
+
// src/runner/init/run.ts
|
|
1013
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
1014
|
+
var packageRoot = resolve(here, "..", "..");
|
|
1015
|
+
function parseInitArgs(argv) {
|
|
1016
|
+
return {
|
|
1017
|
+
force: argv.includes("--force"),
|
|
1018
|
+
allowNonMacos: argv.includes("--allow-non-macos")
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
__name(parseInitArgs, "parseInitArgs");
|
|
1022
|
+
function symbol(severity) {
|
|
1023
|
+
return severity === "ok" ? "\u2713" : severity === "warning" ? "\u26A0" : "\u2717";
|
|
1024
|
+
}
|
|
1025
|
+
__name(symbol, "symbol");
|
|
1026
|
+
function ensureDir(p) {
|
|
1027
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true });
|
|
1028
|
+
}
|
|
1029
|
+
__name(ensureDir, "ensureDir");
|
|
1030
|
+
function writeIfNeeded(filePath, content, force) {
|
|
1031
|
+
const exists = existsSync(filePath);
|
|
1032
|
+
if (exists && !force) return "skipped";
|
|
1033
|
+
ensureDir(dirname(filePath));
|
|
1034
|
+
writeFileSync(filePath, content);
|
|
1035
|
+
return exists ? "overwrote" : "created";
|
|
1036
|
+
}
|
|
1037
|
+
__name(writeIfNeeded, "writeIfNeeded");
|
|
1038
|
+
function readPackageFile(relativePath) {
|
|
1039
|
+
const abs = resolve(packageRoot, relativePath);
|
|
1040
|
+
if (!existsSync(abs)) return null;
|
|
1041
|
+
return readFileSync(abs, "utf8");
|
|
1042
|
+
}
|
|
1043
|
+
__name(readPackageFile, "readPackageFile");
|
|
1044
|
+
function readOwnVersion() {
|
|
1045
|
+
const pkgRaw = readPackageFile("package.json");
|
|
1046
|
+
if (pkgRaw === null) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
`package.json missing at ${packageRoot}. This is a packaging bug \u2014 reinstall \`@unotest/mobile\` or report the issue.`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const parsed = JSON.parse(pkgRaw);
|
|
1052
|
+
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
1053
|
+
throw new Error(`package.json:version is not a non-empty string`);
|
|
1054
|
+
}
|
|
1055
|
+
return parsed.version;
|
|
1056
|
+
}
|
|
1057
|
+
__name(readOwnVersion, "readOwnVersion");
|
|
1058
|
+
function runInit(argv = process.argv.slice(2)) {
|
|
1059
|
+
const opts = parseInitArgs(argv);
|
|
1060
|
+
const target = process.cwd();
|
|
1061
|
+
console.log("unotest-mobile init \u2014 bootstrapping project\n");
|
|
1062
|
+
console.log("Environment:");
|
|
1063
|
+
const checks = runEnvironmentChecks({ allowNonMacos: opts.allowNonMacos });
|
|
1064
|
+
let hardFailed = false;
|
|
1065
|
+
for (const c of checks) {
|
|
1066
|
+
console.log(` ${symbol(c.severity)} ${c.name}: ${c.message}`);
|
|
1067
|
+
if (c.detail) console.log(` ${c.detail}`);
|
|
1068
|
+
if (c.severity === "error") hardFailed = true;
|
|
1069
|
+
}
|
|
1070
|
+
if (hardFailed) {
|
|
1071
|
+
console.error("\nFix the errors above and re-run.");
|
|
1072
|
+
return 1;
|
|
1073
|
+
}
|
|
1074
|
+
console.log("\nFiles:");
|
|
1075
|
+
const summary = [];
|
|
1076
|
+
ensureDir(join(target, "unotest/e2e/_helpers"));
|
|
1077
|
+
ensureDir(join(target, "unotest/e2e/_template"));
|
|
1078
|
+
summary.push({
|
|
1079
|
+
path: "unotest/e2e/smoke-welcome.js",
|
|
1080
|
+
status: writeIfNeeded(join(target, "unotest/e2e/smoke-welcome.js"), templates.smokeWelcome, opts.force)
|
|
1081
|
+
});
|
|
1082
|
+
summary.push({
|
|
1083
|
+
path: "unotest/e2e/_template.js",
|
|
1084
|
+
status: writeIfNeeded(join(target, "unotest/e2e/_template.js"), templates.scenarioTemplate, opts.force)
|
|
1085
|
+
});
|
|
1086
|
+
summary.push({
|
|
1087
|
+
path: "unotest/e2e/_template/example.js",
|
|
1088
|
+
status: writeIfNeeded(
|
|
1089
|
+
join(target, "unotest/e2e/_template/example.js"),
|
|
1090
|
+
templates.e2eTemplateExample,
|
|
1091
|
+
opts.force
|
|
1092
|
+
)
|
|
1093
|
+
});
|
|
1094
|
+
summary.push({
|
|
1095
|
+
path: "unotest/AGENTS.md",
|
|
1096
|
+
status: writeIfNeeded(join(target, "unotest/AGENTS.md"), templates.agentsMd, opts.force)
|
|
1097
|
+
});
|
|
1098
|
+
const skillSrc = readPackageFile(".claude/skills/write-e2e-test.md");
|
|
1099
|
+
if (skillSrc !== null) {
|
|
1100
|
+
summary.push({
|
|
1101
|
+
path: ".claude/skills/write-e2e-test.md",
|
|
1102
|
+
status: writeIfNeeded(join(target, ".claude/skills/write-e2e-test.md"), skillSrc, opts.force)
|
|
1103
|
+
});
|
|
1104
|
+
} else {
|
|
1105
|
+
summary.push({ path: ".claude/skills/write-e2e-test.md", status: "missing-in-package" });
|
|
1106
|
+
}
|
|
1107
|
+
const mcpPath = join(target, ".mcp.json");
|
|
1108
|
+
try {
|
|
1109
|
+
const existing = existsSync(mcpPath) ? readFileSync(mcpPath, "utf8") : null;
|
|
1110
|
+
const merge = mergeMcpConfig(
|
|
1111
|
+
existing,
|
|
1112
|
+
"unotest-mobile",
|
|
1113
|
+
templates.mcpServerEntry(readOwnVersion()),
|
|
1114
|
+
{ force: opts.force }
|
|
1115
|
+
);
|
|
1116
|
+
if (merge.action !== "already-present") {
|
|
1117
|
+
writeFileSync(mcpPath, merge.newContent);
|
|
1118
|
+
}
|
|
1119
|
+
summary.push({ path: ".mcp.json", status: merge.action });
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
if (e instanceof McpJsonParseError) {
|
|
1122
|
+
console.error(`
|
|
1123
|
+
\u2717 .mcp.json: ${e.message}`);
|
|
1124
|
+
return 1;
|
|
1125
|
+
}
|
|
1126
|
+
throw e;
|
|
1127
|
+
}
|
|
1128
|
+
const envExamplePath = join(target, "unotest/.env.example");
|
|
1129
|
+
summary.push({
|
|
1130
|
+
path: "unotest/.env.example",
|
|
1131
|
+
status: writeIfNeeded(envExamplePath, templates.envExample, true)
|
|
1132
|
+
});
|
|
1133
|
+
const envDst = join(target, "unotest/.env");
|
|
1134
|
+
if (!existsSync(envDst)) {
|
|
1135
|
+
writeFileSync(envDst, templates.envExample);
|
|
1136
|
+
summary.push({ path: "unotest/.env", status: "created" });
|
|
1137
|
+
} else {
|
|
1138
|
+
summary.push({ path: "unotest/.env", status: "skipped (exists \u2014 fill in manually)" });
|
|
1139
|
+
}
|
|
1140
|
+
const gitignorePath = join(target, ".gitignore");
|
|
1141
|
+
const gitignoreExisting = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : null;
|
|
1142
|
+
const giUpdate = appendUniqueLines(gitignoreExisting, [...templates.gitignoreLines]);
|
|
1143
|
+
if (giUpdate.added.length > 0) {
|
|
1144
|
+
writeFileSync(gitignorePath, giUpdate.newContent);
|
|
1145
|
+
summary.push({ path: ".gitignore", status: `added ${giUpdate.added.length} line(s)` });
|
|
1146
|
+
} else {
|
|
1147
|
+
summary.push({ path: ".gitignore", status: "already up-to-date" });
|
|
1148
|
+
}
|
|
1149
|
+
for (const item of summary) {
|
|
1150
|
+
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" : "?";
|
|
1151
|
+
console.log(` ${symbolForStatus} ${item.path} \u2014 ${item.status}`);
|
|
1152
|
+
}
|
|
1153
|
+
console.log(`
|
|
1154
|
+
Next:
|
|
1155
|
+
1. Edit ${relative(target, envDst) || "unotest/.env"} with your project's values
|
|
1156
|
+
(DATABASE_URL, SIM_A_NAME, APP_BUNDLE_ID, ...)
|
|
1157
|
+
2. Boot iOS simulators matching SIM_A_NAME / SIM_B_NAME (Xcode \u2192 Devices)
|
|
1158
|
+
3. Open Claude Code in this directory \u2014 MCP server auto-registers via
|
|
1159
|
+
.mcp.json. Ask the agent to write a test.
|
|
1160
|
+
4. CLI: \`npx @unotest/mobile e2e smoke-welcome\` to run the starter.
|
|
1161
|
+
|
|
1162
|
+
Re-check environment anytime: \`npx @unotest/mobile doctor\`
|
|
1163
|
+
`);
|
|
1164
|
+
return 0;
|
|
1165
|
+
}
|
|
1166
|
+
__name(runInit, "runInit");
|
|
1167
|
+
|
|
1168
|
+
// src/runner/install/install-app.ts
|
|
1169
|
+
import { execFile as execFile3 } from "child_process";
|
|
1170
|
+
import { existsSync as existsSync2, statSync } from "fs";
|
|
1171
|
+
import { resolve as resolve2 } from "path";
|
|
1172
|
+
import { promisify as promisify3 } from "util";
|
|
1173
|
+
|
|
1174
|
+
// src/runner/install/info-plist.ts
|
|
1175
|
+
import { execFile as execFile2 } from "child_process";
|
|
1176
|
+
import { join as join2 } from "path";
|
|
1177
|
+
import { promisify as promisify2 } from "util";
|
|
1178
|
+
var exec2 = promisify2(execFile2);
|
|
1179
|
+
async function readInfoPlist(appPath) {
|
|
1180
|
+
const plistPath = join2(appPath, "Info.plist");
|
|
1181
|
+
const { stdout } = await exec2("plutil", ["-convert", "json", "-o", "-", plistPath]);
|
|
1182
|
+
const raw = JSON.parse(stdout);
|
|
1183
|
+
const bundleId = typeof raw.CFBundleIdentifier === "string" ? raw.CFBundleIdentifier : void 0;
|
|
1184
|
+
const urlSchemes = [];
|
|
1185
|
+
if (Array.isArray(raw.CFBundleURLTypes)) {
|
|
1186
|
+
for (const type of raw.CFBundleURLTypes) {
|
|
1187
|
+
if (type && typeof type === "object" && Array.isArray(type.CFBundleURLSchemes)) {
|
|
1188
|
+
for (const scheme of type.CFBundleURLSchemes) {
|
|
1189
|
+
if (typeof scheme === "string") urlSchemes.push(scheme);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
const usageDescriptions = [];
|
|
1195
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1196
|
+
if (key.startsWith("NS") && key.endsWith("UsageDescription") && typeof value === "string") {
|
|
1197
|
+
usageDescriptions.push({ key, description: value });
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return { ...bundleId ? { bundleId } : {}, urlSchemes, usageDescriptions };
|
|
1201
|
+
}
|
|
1202
|
+
__name(readInfoPlist, "readInfoPlist");
|
|
1203
|
+
|
|
1204
|
+
// src/runner/install/permission-mapper.ts
|
|
1205
|
+
var NS_TO_SIMCTL_SERVICE = Object.freeze({
|
|
1206
|
+
// Location
|
|
1207
|
+
NSLocationWhenInUseUsageDescription: "location",
|
|
1208
|
+
NSLocationAlwaysAndWhenInUseUsageDescription: "location-always",
|
|
1209
|
+
NSLocationAlwaysUsageDescription: "location-always",
|
|
1210
|
+
// Media
|
|
1211
|
+
NSPhotoLibraryUsageDescription: "photos",
|
|
1212
|
+
NSPhotoLibraryAddUsageDescription: "photos-add",
|
|
1213
|
+
NSMicrophoneUsageDescription: "microphone",
|
|
1214
|
+
NSMediaLibraryUsageDescription: "media-library",
|
|
1215
|
+
// Personal data
|
|
1216
|
+
NSContactsUsageDescription: "contacts",
|
|
1217
|
+
NSCalendarsUsageDescription: "calendar",
|
|
1218
|
+
// legacy (pre-iOS 17)
|
|
1219
|
+
NSCalendarsFullAccessUsageDescription: "calendar",
|
|
1220
|
+
// iOS 17+ preferred
|
|
1221
|
+
NSCalendarsWriteOnlyAccessUsageDescription: "calendar",
|
|
1222
|
+
// iOS 17+ write-only
|
|
1223
|
+
NSRemindersUsageDescription: "reminders",
|
|
1224
|
+
// Sensors / device
|
|
1225
|
+
NSMotionUsageDescription: "motion",
|
|
1226
|
+
// Siri
|
|
1227
|
+
NSSiriUsageDescription: "siri",
|
|
1228
|
+
// Camera + push notifications are not exposed via `simctl privacy` —
|
|
1229
|
+
// camera needs the alert-dismiss path (B1), push needs UNUserNotificationCenter.
|
|
1230
|
+
NSCameraUsageDescription: null
|
|
1231
|
+
});
|
|
1232
|
+
var KNOWN_SIMCTL_SERVICES = new Set(
|
|
1233
|
+
Object.values(NS_TO_SIMCTL_SERVICE).filter((v) => v !== null)
|
|
1234
|
+
);
|
|
1235
|
+
function inferSimctlServices(usageDescriptions) {
|
|
1236
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1237
|
+
const out = [];
|
|
1238
|
+
for (const u of usageDescriptions) {
|
|
1239
|
+
const svc = NS_TO_SIMCTL_SERVICE[u.key];
|
|
1240
|
+
if (svc !== void 0 && svc !== null && !seen.has(svc)) {
|
|
1241
|
+
seen.add(svc);
|
|
1242
|
+
out.push(svc);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return out;
|
|
1246
|
+
}
|
|
1247
|
+
__name(inferSimctlServices, "inferSimctlServices");
|
|
1248
|
+
|
|
1249
|
+
// src/runner/install/install-app.ts
|
|
1250
|
+
var exec3 = promisify3(execFile3);
|
|
1251
|
+
async function installApp2(opts, deps) {
|
|
1252
|
+
const appPathAbsolute = resolve2(opts.appPath);
|
|
1253
|
+
if (!existsSync2(appPathAbsolute)) {
|
|
1254
|
+
throw new Error(`App path does not exist: ${appPathAbsolute}`);
|
|
1255
|
+
}
|
|
1256
|
+
if (!appPathAbsolute.endsWith(".app")) {
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
`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.`
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
if (!statSync(appPathAbsolute).isDirectory()) {
|
|
1262
|
+
throw new Error(`App path must be a directory (.app bundle), got file: ${appPathAbsolute}`);
|
|
1263
|
+
}
|
|
1264
|
+
const infoPlist = `${appPathAbsolute}/Info.plist`;
|
|
1265
|
+
if (!existsSync2(infoPlist)) {
|
|
1266
|
+
throw new Error(`Info.plist not found inside .app: ${infoPlist}`);
|
|
1267
|
+
}
|
|
1268
|
+
const readBundleId = deps.readBundleId ?? readBundleIdViaPlistBuddy;
|
|
1269
|
+
const appBundleId = (await readBundleId(appPathAbsolute)).trim();
|
|
1270
|
+
if (!appBundleId) {
|
|
1271
|
+
throw new Error(`Could not read CFBundleIdentifier from ${infoPlist}`);
|
|
1272
|
+
}
|
|
1273
|
+
const readUrlScheme = deps.readUrlScheme ?? readUrlSchemeViaPlistBuddy;
|
|
1274
|
+
const appUrlScheme = await readUrlScheme(appPathAbsolute);
|
|
1275
|
+
const readUsageDescriptions = deps.readUsageDescriptions ?? (async (p) => (await readInfoPlist(p)).usageDescriptions);
|
|
1276
|
+
const usageDescriptions = await readUsageDescriptions(appPathAbsolute);
|
|
1277
|
+
const detectedPermissions = inferSimctlServices(usageDescriptions);
|
|
1278
|
+
if (opts.slots.length === 0) {
|
|
1279
|
+
throw new Error(`No slots to install on. Pass --slot or check SIM_POOL in unotest/.env.`);
|
|
1280
|
+
}
|
|
1281
|
+
for (const slot of opts.slots) {
|
|
1282
|
+
if (!opts.simBySlot[slot]) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
`Slot "${slot}" requested but no SIM_${slot}_NAME in unotest/.env (or not in SIM_POOL).`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const slotResults = [];
|
|
1289
|
+
for (const slot of opts.slots) {
|
|
1290
|
+
const simName = opts.simBySlot[slot];
|
|
1291
|
+
const sim = await deps.simctl.resolveByName(simName);
|
|
1292
|
+
let erased = false;
|
|
1293
|
+
if (opts.erase) {
|
|
1294
|
+
deps.logger.info(`[${slot}] erasing ${simName} (${sim.udid})`);
|
|
1295
|
+
try {
|
|
1296
|
+
await deps.simctl.shutdown(sim.udid);
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1299
|
+
await deps.simctl.erase(sim.udid);
|
|
1300
|
+
erased = true;
|
|
1301
|
+
}
|
|
1302
|
+
deps.logger.info(`[${slot}] booting ${simName} (${sim.udid})`);
|
|
1303
|
+
await deps.simctl.boot(sim.udid);
|
|
1304
|
+
if (process.env.SIMCTL_HEADLESS !== "1") {
|
|
1305
|
+
await deps.simctl.openSimulatorApp();
|
|
1306
|
+
}
|
|
1307
|
+
let uninstalled = false;
|
|
1308
|
+
if (opts.clean) {
|
|
1309
|
+
deps.logger.info(`[${slot}] uninstalling existing ${appBundleId}`);
|
|
1310
|
+
try {
|
|
1311
|
+
await deps.simctl.uninstall(sim.udid, appBundleId);
|
|
1312
|
+
uninstalled = true;
|
|
1313
|
+
} catch {
|
|
1314
|
+
}
|
|
1315
|
+
deps.logger.info(`[${slot}] resetting keychain on ${sim.udid}`);
|
|
1316
|
+
await deps.simctl.keychainReset(sim.udid);
|
|
1317
|
+
}
|
|
1318
|
+
deps.logger.info(`[${slot}] installing ${appPathAbsolute}`);
|
|
1319
|
+
await deps.simctl.install(sim.udid, appPathAbsolute);
|
|
1320
|
+
if (opts.permissions && opts.permissions.length > 0) {
|
|
1321
|
+
for (const service of opts.permissions) {
|
|
1322
|
+
deps.logger.info(`[${slot}] granting ${service} to ${appBundleId}`);
|
|
1323
|
+
await deps.simctl.privacyGrant(sim.udid, service, appBundleId);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (opts.pinKeyboard !== false) {
|
|
1327
|
+
deps.logger.info(`[${slot}] pinning keyboard to en_US@QWERTY`);
|
|
1328
|
+
await deps.simctl.pinEnglishKeyboard(sim.udid);
|
|
1329
|
+
}
|
|
1330
|
+
let launched = false;
|
|
1331
|
+
if (opts.launch) {
|
|
1332
|
+
deps.logger.info(`[${slot}] launching ${appBundleId}`);
|
|
1333
|
+
const { pid } = await deps.simctl.launch(sim.udid, appBundleId);
|
|
1334
|
+
await deps.simctl.assertLaunchedAndStable(sim.udid, appBundleId, pid);
|
|
1335
|
+
launched = true;
|
|
1336
|
+
}
|
|
1337
|
+
slotResults.push({ slot, simName, udid: sim.udid, erased, uninstalled, launched });
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
appBundleId,
|
|
1341
|
+
appUrlScheme,
|
|
1342
|
+
appPathAbsolute,
|
|
1343
|
+
bundleIdMismatch: opts.envBundleId !== void 0 && opts.envBundleId !== appBundleId,
|
|
1344
|
+
detectedPermissions,
|
|
1345
|
+
slots: slotResults
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
__name(installApp2, "installApp");
|
|
1349
|
+
async function readBundleIdViaPlistBuddy(appPath) {
|
|
1350
|
+
const { stdout } = await exec3("/usr/libexec/PlistBuddy", [
|
|
1351
|
+
"-c",
|
|
1352
|
+
"Print :CFBundleIdentifier",
|
|
1353
|
+
`${appPath}/Info.plist`
|
|
1354
|
+
]);
|
|
1355
|
+
return stdout;
|
|
1356
|
+
}
|
|
1357
|
+
__name(readBundleIdViaPlistBuddy, "readBundleIdViaPlistBuddy");
|
|
1358
|
+
async function readUrlSchemeViaPlistBuddy(appPath) {
|
|
1359
|
+
try {
|
|
1360
|
+
const { stdout } = await exec3("/usr/libexec/PlistBuddy", [
|
|
1361
|
+
"-c",
|
|
1362
|
+
"Print :CFBundleURLTypes:0:CFBundleURLSchemes:0",
|
|
1363
|
+
`${appPath}/Info.plist`
|
|
1364
|
+
]);
|
|
1365
|
+
const scheme = stdout.trim();
|
|
1366
|
+
return scheme.length > 0 ? scheme : void 0;
|
|
1367
|
+
} catch {
|
|
1368
|
+
return void 0;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
__name(readUrlSchemeViaPlistBuddy, "readUrlSchemeViaPlistBuddy");
|
|
1372
|
+
|
|
1373
|
+
// src/runner/install/readline-prompt.ts
|
|
1374
|
+
function createReadlinePromptIO() {
|
|
1375
|
+
return {
|
|
1376
|
+
async promptChoice(message, options) {
|
|
1377
|
+
try {
|
|
1378
|
+
return await interactiveSelect(
|
|
1379
|
+
{
|
|
1380
|
+
message,
|
|
1381
|
+
choices: options.map((o) => ({ label: o.label, value: o.value }))
|
|
1382
|
+
},
|
|
1383
|
+
{}
|
|
1384
|
+
);
|
|
1385
|
+
} catch (e) {
|
|
1386
|
+
if (e instanceof SelectAbortedError) {
|
|
1387
|
+
throw new Error(
|
|
1388
|
+
`cancelled \u2014 re-run when ready, or pass --sim-* flags / --yes for non-interactive setup.`
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
throw e;
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
print(line) {
|
|
1395
|
+
process.stderr.write(`${line}
|
|
1396
|
+
`);
|
|
1397
|
+
},
|
|
1398
|
+
close() {
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
__name(createReadlinePromptIO, "createReadlinePromptIO");
|
|
1403
|
+
|
|
1404
|
+
// src/runner/install/sim-resolver.ts
|
|
1405
|
+
var SKIP_SLOT = "__skip__";
|
|
1406
|
+
var MANUAL_EDIT = "__manual__";
|
|
1407
|
+
var ManualEditRequested = class extends Error {
|
|
1408
|
+
static {
|
|
1409
|
+
__name(this, "ManualEditRequested");
|
|
1410
|
+
}
|
|
1411
|
+
constructor(slot) {
|
|
1412
|
+
super(
|
|
1413
|
+
`Slot "${slot}" left unresolved \u2014 open unotest/.env, set SIM_${slot}_NAME to one of the available simulator names, and re-run.`
|
|
1414
|
+
);
|
|
1415
|
+
this.name = "ManualEditRequested";
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
var NonInteractiveResolveError = class extends Error {
|
|
1419
|
+
static {
|
|
1420
|
+
__name(this, "NonInteractiveResolveError");
|
|
1421
|
+
}
|
|
1422
|
+
constructor(slot, reason, currentValue) {
|
|
1423
|
+
const detail = reason === "missing" ? `SIM_${slot}_NAME is not set in unotest/.env` : `SIM_${slot}_NAME="${currentValue}" does not match any installed simulator`;
|
|
1424
|
+
const skipHint = slot === "A" ? "" : ` (or --no-sim-${slot.toLowerCase()} to drop slot ${slot} from SIM_POOL)`;
|
|
1425
|
+
super(
|
|
1426
|
+
`${detail}. Running non-interactively \u2014 pass --sim-${slot.toLowerCase()}=<name>${skipHint}, or re-run in a terminal for an interactive picker.`
|
|
1427
|
+
);
|
|
1428
|
+
this.name = "NonInteractiveResolveError";
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
var NoSimulatorsAvailableError = class extends Error {
|
|
1432
|
+
static {
|
|
1433
|
+
__name(this, "NoSimulatorsAvailableError");
|
|
1434
|
+
}
|
|
1435
|
+
constructor() {
|
|
1436
|
+
super(
|
|
1437
|
+
`No iOS simulators found on this machine. Install Xcode and create a simulator via Xcode \u2192 Window \u2192 Devices and Simulators, then re-run.`
|
|
1438
|
+
);
|
|
1439
|
+
this.name = "NoSimulatorsAvailableError";
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
async function resolveSims(input, io) {
|
|
1443
|
+
const availableNames = new Set(input.availableSims.map((s) => s.name));
|
|
1444
|
+
const finalPool = [];
|
|
1445
|
+
const finalMapping = {};
|
|
1446
|
+
const envUpdates = [];
|
|
1447
|
+
let prompted = false;
|
|
1448
|
+
for (const slot of input.pool) {
|
|
1449
|
+
const override = input.overrides[slot];
|
|
1450
|
+
const current = input.simBySlot[slot];
|
|
1451
|
+
if (override === "skip") {
|
|
1452
|
+
envUpdates.push({ key: `SIM_POOL`, value: "__placeholder__" });
|
|
1453
|
+
io.print(`\xB7 slot ${slot} dropped (--no-sim-${slot.toLowerCase()})`);
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof override === "string") {
|
|
1457
|
+
if (!availableNames.has(override)) {
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`--sim-${slot.toLowerCase()}="${override}" does not match any installed simulator. Available: ${formatSimList(input.availableSims)}`
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
finalPool.push(slot);
|
|
1463
|
+
finalMapping[slot] = override;
|
|
1464
|
+
if (override !== current) {
|
|
1465
|
+
envUpdates.push({ key: `SIM_${slot}_NAME`, value: override });
|
|
1466
|
+
}
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
if (current && availableNames.has(current)) {
|
|
1470
|
+
finalPool.push(slot);
|
|
1471
|
+
finalMapping[slot] = current;
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
if (input.nonInteractive) {
|
|
1475
|
+
throw new NonInteractiveResolveError(
|
|
1476
|
+
slot,
|
|
1477
|
+
current ? "not-found" : "missing",
|
|
1478
|
+
current
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
if (input.availableSims.length === 0) {
|
|
1482
|
+
throw new NoSimulatorsAvailableError();
|
|
1483
|
+
}
|
|
1484
|
+
prompted = true;
|
|
1485
|
+
const reason = current ? `SIM_${slot}_NAME="${current}" doesn't match any installed simulator.` : `SIM_${slot}_NAME is not set in unotest/.env.`;
|
|
1486
|
+
const simChoices = input.availableSims.map((s) => ({
|
|
1487
|
+
label: `${s.name} @ ${s.runtime}${s.state === "Booted" ? " (booted)" : ""}`,
|
|
1488
|
+
value: s.name
|
|
1489
|
+
}));
|
|
1490
|
+
const options = [];
|
|
1491
|
+
if (slot !== "A") {
|
|
1492
|
+
options.push({
|
|
1493
|
+
label: `do not configure slot ${slot} \u2014 only one simulator needed`,
|
|
1494
|
+
value: SKIP_SLOT
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
options.push(...simChoices);
|
|
1498
|
+
options.push({
|
|
1499
|
+
label: `edit unotest/.env manually and re-run`,
|
|
1500
|
+
value: MANUAL_EDIT
|
|
1501
|
+
});
|
|
1502
|
+
const picked = await io.promptChoice(
|
|
1503
|
+
`${reason}
|
|
1504
|
+
Pick a simulator for slot ${slot}:`,
|
|
1505
|
+
options
|
|
1506
|
+
);
|
|
1507
|
+
if (picked === MANUAL_EDIT) {
|
|
1508
|
+
throw new ManualEditRequested(slot);
|
|
1509
|
+
}
|
|
1510
|
+
if (picked === SKIP_SLOT) {
|
|
1511
|
+
io.print(`\xB7 slot ${slot} dropped from SIM_POOL`);
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
finalPool.push(slot);
|
|
1515
|
+
finalMapping[slot] = picked;
|
|
1516
|
+
envUpdates.push({ key: `SIM_${slot}_NAME`, value: picked });
|
|
1517
|
+
io.print(`\u2713 SIM_${slot}_NAME=${picked}`);
|
|
1518
|
+
}
|
|
1519
|
+
if (finalPool.length === 0) {
|
|
1520
|
+
throw new Error(
|
|
1521
|
+
`All slots were skipped \u2014 install needs at least slot A. Re-run without --no-sim-a or pick a simulator for slot A.`
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
const finalPoolStr = finalPool.join(",");
|
|
1525
|
+
const currentPoolStr = input.pool.join(",");
|
|
1526
|
+
const cleanedUpdates = envUpdates.filter((u) => u.value !== "__placeholder__");
|
|
1527
|
+
if (finalPoolStr !== currentPoolStr) {
|
|
1528
|
+
cleanedUpdates.push({ key: "SIM_POOL", value: finalPoolStr });
|
|
1529
|
+
io.print(`\u2713 SIM_POOL=${finalPoolStr}`);
|
|
1530
|
+
}
|
|
1531
|
+
return {
|
|
1532
|
+
pool: finalPool,
|
|
1533
|
+
simBySlot: finalMapping,
|
|
1534
|
+
envUpdates: cleanedUpdates,
|
|
1535
|
+
prompted
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
__name(resolveSims, "resolveSims");
|
|
1539
|
+
function formatSimList(sims) {
|
|
1540
|
+
if (sims.length === 0) return "(none \u2014 install Xcode and create a simulator)";
|
|
1541
|
+
return sims.map((s) => `${s.name} @ ${s.runtime}`).join(", ");
|
|
1542
|
+
}
|
|
1543
|
+
__name(formatSimList, "formatSimList");
|
|
1544
|
+
|
|
1545
|
+
// src/runner/install/update-env-file.ts
|
|
1546
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1547
|
+
function updateEnvFile(path, updates) {
|
|
1548
|
+
const original = existsSync3(path) ? readFileSync2(path, "utf8") : "";
|
|
1549
|
+
const lines = original.split(/\r?\n/);
|
|
1550
|
+
const added = [];
|
|
1551
|
+
const changed = [];
|
|
1552
|
+
const unchanged = [];
|
|
1553
|
+
for (const { key, value } of updates) {
|
|
1554
|
+
const newLine = `${key}=${value}`;
|
|
1555
|
+
const re = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
|
|
1556
|
+
let foundAt = -1;
|
|
1557
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1558
|
+
if (re.test(lines[i] ?? "")) {
|
|
1559
|
+
foundAt = i;
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (foundAt === -1) {
|
|
1564
|
+
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
|
|
1565
|
+
lines.push(newLine);
|
|
1566
|
+
added.push(key);
|
|
1567
|
+
} else if (lines[foundAt] === newLine) {
|
|
1568
|
+
unchanged.push(key);
|
|
1569
|
+
} else {
|
|
1570
|
+
lines[foundAt] = newLine;
|
|
1571
|
+
changed.push(key);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (added.length > 0 || changed.length > 0) {
|
|
1575
|
+
writeFileSync2(path, lines.join("\n"));
|
|
1576
|
+
}
|
|
1577
|
+
return { added, changed, unchanged };
|
|
1578
|
+
}
|
|
1579
|
+
__name(updateEnvFile, "updateEnvFile");
|
|
1580
|
+
function escapeRegex(s) {
|
|
1581
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1582
|
+
}
|
|
1583
|
+
__name(escapeRegex, "escapeRegex");
|
|
1584
|
+
|
|
1585
|
+
// src/runner/install.ts
|
|
1586
|
+
function parseArgs(argv) {
|
|
1587
|
+
const opts = {
|
|
1588
|
+
slot: "A",
|
|
1589
|
+
clean: false,
|
|
1590
|
+
erase: false,
|
|
1591
|
+
launch: false,
|
|
1592
|
+
updateEnv: false,
|
|
1593
|
+
noSimB: false,
|
|
1594
|
+
yes: false,
|
|
1595
|
+
noPermissions: false
|
|
1596
|
+
};
|
|
1597
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1598
|
+
const a = argv[i];
|
|
1599
|
+
if (a === "--slot") {
|
|
1600
|
+
const v = argv[++i];
|
|
1601
|
+
if (v !== "A" && v !== "B" && v !== "all") {
|
|
1602
|
+
throw new Error(`--slot must be A, B, or all (got "${v}")`);
|
|
1603
|
+
}
|
|
1604
|
+
opts.slot = v;
|
|
1605
|
+
} else if (a === "--clean") {
|
|
1606
|
+
opts.clean = true;
|
|
1607
|
+
} else if (a === "--erase") {
|
|
1608
|
+
opts.erase = true;
|
|
1609
|
+
} else if (a === "--launch") {
|
|
1610
|
+
opts.launch = true;
|
|
1611
|
+
} else if (a === "--update-env") {
|
|
1612
|
+
opts.updateEnv = true;
|
|
1613
|
+
} else if (a === "--no-sim-b") {
|
|
1614
|
+
opts.noSimB = true;
|
|
1615
|
+
} else if (a === "--yes" || a === "-y") {
|
|
1616
|
+
opts.yes = true;
|
|
1617
|
+
} else if (a.startsWith("--sim-a=")) {
|
|
1618
|
+
opts.simA = a.slice("--sim-a=".length);
|
|
1619
|
+
} else if (a.startsWith("--sim-b=")) {
|
|
1620
|
+
opts.simB = a.slice("--sim-b=".length);
|
|
1621
|
+
} else if (a.startsWith("--permissions=")) {
|
|
1622
|
+
const raw = a.slice("--permissions=".length);
|
|
1623
|
+
opts.permissionsFlag = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1624
|
+
} else if (a === "--no-permissions") {
|
|
1625
|
+
opts.noPermissions = true;
|
|
1626
|
+
} else if (a === "-h" || a === "--help") {
|
|
1627
|
+
printHelp();
|
|
1628
|
+
process.exit(0);
|
|
1629
|
+
} else if (a.startsWith("--")) {
|
|
1630
|
+
throw new Error(`unknown flag: ${a}`);
|
|
1631
|
+
} else {
|
|
1632
|
+
if (opts.appPath !== void 0) {
|
|
1633
|
+
throw new Error(`only one path argument allowed (got "${opts.appPath}" and "${a}")`);
|
|
1634
|
+
}
|
|
1635
|
+
opts.appPath = a;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
if (opts.permissionsFlag !== void 0 && opts.noPermissions) {
|
|
1639
|
+
throw new Error(`--permissions=<list> and --no-permissions are mutually exclusive.`);
|
|
1640
|
+
}
|
|
1641
|
+
return opts;
|
|
1642
|
+
}
|
|
1643
|
+
__name(parseArgs, "parseArgs");
|
|
1644
|
+
function resolveCliPermissions(opts, env) {
|
|
1645
|
+
if (opts.permissionsFlag !== void 0) return opts.permissionsFlag;
|
|
1646
|
+
if (opts.noPermissions) return [];
|
|
1647
|
+
const envValue = env.APP_PERMISSIONS;
|
|
1648
|
+
if (envValue && envValue.trim().length > 0) {
|
|
1649
|
+
return envValue.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1650
|
+
}
|
|
1651
|
+
return void 0;
|
|
1652
|
+
}
|
|
1653
|
+
__name(resolveCliPermissions, "resolveCliPermissions");
|
|
1654
|
+
function shouldPersistPermissions(resolved, currentEnvValue) {
|
|
1655
|
+
if (resolved === void 0 || resolved.length === 0) return false;
|
|
1656
|
+
return resolved.join(",") !== (currentEnvValue ?? "");
|
|
1657
|
+
}
|
|
1658
|
+
__name(shouldPersistPermissions, "shouldPersistPermissions");
|
|
1659
|
+
function printHelp() {
|
|
1660
|
+
process.stdout.write(
|
|
1661
|
+
`Usage: unotest-mobile install [path-to-.app] [flags]
|
|
1662
|
+
|
|
1663
|
+
If [path] is omitted, reads APP_PATH from unotest/.env.
|
|
1664
|
+
|
|
1665
|
+
Flags:
|
|
1666
|
+
--slot A|B|all Which slot(s) to install on. Default: A.
|
|
1667
|
+
--clean Uninstall existing app at the same bundle id first.
|
|
1668
|
+
--erase Erase simulator content & settings before install (destructive).
|
|
1669
|
+
--launch Launch the app after install (quick sanity check).
|
|
1670
|
+
--update-env Persist what we discover (APP_PATH, APP_BUNDLE_ID,
|
|
1671
|
+
APP_URL_SCHEME, APP_PERMISSIONS) into unotest/.env.
|
|
1672
|
+
--permissions=<list> Override APP_PERMISSIONS for this run. Comma-separated
|
|
1673
|
+
simctl-privacy services (e.g. "location,motion").
|
|
1674
|
+
Empty value ("--permissions=") = grant nothing.
|
|
1675
|
+
--no-permissions Skip auto-grant even if APP_PERMISSIONS is set in env.
|
|
1676
|
+
Conflicts with --permissions=<list>.
|
|
1677
|
+
--sim-a=<name> Non-interactive: set SIM_A_NAME for this run + .env.
|
|
1678
|
+
--sim-b=<name> Non-interactive: set SIM_B_NAME for this run + .env.
|
|
1679
|
+
--no-sim-b Non-interactive: drop slot B from SIM_POOL.
|
|
1680
|
+
-y, --yes Non-interactive: accept current state; error if any
|
|
1681
|
+
slot is unresolved (use with the --sim-* flags).
|
|
1682
|
+
-h, --help This help.
|
|
1683
|
+
|
|
1684
|
+
Examples:
|
|
1685
|
+
unotest-mobile install ./build/MyApp.app
|
|
1686
|
+
unotest-mobile install ./MyApp.app --slot all --clean
|
|
1687
|
+
unotest-mobile install ./MyApp.app --update-env --launch
|
|
1688
|
+
unotest-mobile install ./MyApp.app -y --sim-a=UnoTest-A --no-sim-b
|
|
1689
|
+
`
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
__name(printHelp, "printHelp");
|
|
1693
|
+
async function ensureSimsResolved(opts) {
|
|
1694
|
+
loadDotenv2({ path: ENV_FILE_PATH });
|
|
1695
|
+
const currentPoolStr = process.env.SIM_POOL ?? "A,B";
|
|
1696
|
+
const currentPool = currentPoolStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1697
|
+
const simBySlot = {};
|
|
1698
|
+
for (const slot of currentPool) {
|
|
1699
|
+
simBySlot[slot] = process.env[`SIM_${slot}_NAME`];
|
|
1700
|
+
}
|
|
1701
|
+
const overrides = {};
|
|
1702
|
+
if (opts.simA !== void 0) overrides.A = opts.simA;
|
|
1703
|
+
if (opts.simB !== void 0) overrides.B = opts.simB;
|
|
1704
|
+
if (opts.noSimB) overrides.B = "skip";
|
|
1705
|
+
const availableSims = (await listSimulators()).map((s) => ({
|
|
1706
|
+
name: s.name,
|
|
1707
|
+
runtime: friendlyRuntime(s.runtime),
|
|
1708
|
+
state: s.state
|
|
1709
|
+
}));
|
|
1710
|
+
const nonInteractive = opts.yes || !process.stdin.isTTY;
|
|
1711
|
+
let io;
|
|
1712
|
+
if (nonInteractive) {
|
|
1713
|
+
io = {
|
|
1714
|
+
promptChoice: /* @__PURE__ */ __name(async () => {
|
|
1715
|
+
throw new Error("promptChoice invoked in non-interactive mode (bug)");
|
|
1716
|
+
}, "promptChoice"),
|
|
1717
|
+
print: /* @__PURE__ */ __name((line) => process.stderr.write(`${line}
|
|
1718
|
+
`), "print")
|
|
1719
|
+
};
|
|
1720
|
+
} else {
|
|
1721
|
+
io = createReadlinePromptIO();
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const resolved = await resolveSims(
|
|
1725
|
+
{ pool: currentPool, simBySlot, availableSims, overrides, nonInteractive },
|
|
1726
|
+
io
|
|
1727
|
+
);
|
|
1728
|
+
if (resolved.envUpdates.length > 0) {
|
|
1729
|
+
const envPath = resolve3(ENV_FILE_PATH);
|
|
1730
|
+
updateEnvFile(envPath, resolved.envUpdates);
|
|
1731
|
+
process.stderr.write(`\u2713 updated ${envPath}
|
|
1732
|
+
`);
|
|
1733
|
+
}
|
|
1734
|
+
for (const u of resolved.envUpdates) {
|
|
1735
|
+
process.env[u.key] = u.value;
|
|
1736
|
+
}
|
|
1737
|
+
process.env.SIM_POOL = resolved.pool.join(",");
|
|
1738
|
+
} finally {
|
|
1739
|
+
io.close?.();
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
__name(ensureSimsResolved, "ensureSimsResolved");
|
|
1743
|
+
async function ensureProjectInitialized(opts) {
|
|
1744
|
+
if (existsSync4(ENV_FILE_PATH)) return true;
|
|
1745
|
+
const nonInteractive = opts.yes || !process.stdin.isTTY;
|
|
1746
|
+
if (nonInteractive) {
|
|
1747
|
+
process.stderr.write(
|
|
1748
|
+
`\u2717 ${ENV_FILE_PATH} not found \u2014 project hasn't been initialized.
|
|
1749
|
+
Run \`unotest-mobile init\` first, then re-run install.
|
|
1750
|
+
`
|
|
1751
|
+
);
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
process.stderr.write(
|
|
1755
|
+
`${ENV_FILE_PATH} not found \u2014 looks like this project hasn't been initialized yet.
|
|
1756
|
+
`
|
|
1757
|
+
);
|
|
1758
|
+
let choice;
|
|
1759
|
+
try {
|
|
1760
|
+
choice = await interactiveSelect(
|
|
1761
|
+
{
|
|
1762
|
+
message: `What would you like to do?`,
|
|
1763
|
+
choices: [
|
|
1764
|
+
{
|
|
1765
|
+
label: `run \`unotest-mobile init\` now \u2014 creates unotest/ scaffold, .mcp.json, .env (recommended)`,
|
|
1766
|
+
value: "init"
|
|
1767
|
+
},
|
|
1768
|
+
{ label: `cancel \u2014 I'll run init myself`, value: "abort" }
|
|
1769
|
+
]
|
|
1770
|
+
},
|
|
1771
|
+
{}
|
|
1772
|
+
);
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
if (e instanceof SelectAbortedError) {
|
|
1775
|
+
process.stderr.write(`cancelled.
|
|
1776
|
+
`);
|
|
1777
|
+
return false;
|
|
1778
|
+
}
|
|
1779
|
+
throw e;
|
|
1780
|
+
}
|
|
1781
|
+
if (choice === "abort") {
|
|
1782
|
+
process.stderr.write(`Run \`unotest-mobile init\`, then re-run install.
|
|
1783
|
+
`);
|
|
1784
|
+
return false;
|
|
1785
|
+
}
|
|
1786
|
+
process.stderr.write(`
|
|
1787
|
+
`);
|
|
1788
|
+
const initExitCode = runInit([]);
|
|
1789
|
+
if (initExitCode !== 0) {
|
|
1790
|
+
process.stderr.write(`\u2717 init failed (exit ${initExitCode}) \u2014 aborting install.
|
|
1791
|
+
`);
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
process.stderr.write(`
|
|
1795
|
+
`);
|
|
1796
|
+
return true;
|
|
1797
|
+
}
|
|
1798
|
+
__name(ensureProjectInitialized, "ensureProjectInitialized");
|
|
1799
|
+
async function confirmDetectedPermissions(detected) {
|
|
1800
|
+
try {
|
|
1801
|
+
return await interactiveSelect(
|
|
1802
|
+
{
|
|
1803
|
+
message: `Detected iOS permissions in Info.plist: ${detected.join(", ")}.
|
|
1804
|
+
Pre-grant on test sims (writes APP_PERMISSIONS to unotest/.env)?`,
|
|
1805
|
+
choices: [
|
|
1806
|
+
{ label: `yes \u2014 grant ${detected.join(", ")} (recommended)`, value: true },
|
|
1807
|
+
{ label: `skip \u2014 don't auto-grant, leave APP_PERMISSIONS unset`, value: false }
|
|
1808
|
+
]
|
|
1809
|
+
},
|
|
1810
|
+
{}
|
|
1811
|
+
);
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
if (e instanceof SelectAbortedError) return false;
|
|
1814
|
+
throw e;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
__name(confirmDetectedPermissions, "confirmDetectedPermissions");
|
|
1818
|
+
async function main() {
|
|
1819
|
+
let opts;
|
|
1820
|
+
try {
|
|
1821
|
+
opts = parseArgs(process.argv.slice(2));
|
|
1822
|
+
} catch (e) {
|
|
1823
|
+
process.stderr.write(`\u2717 ${e.message}
|
|
1824
|
+
`);
|
|
1825
|
+
process.stderr.write(`Run \`unotest-mobile install --help\` for usage.
|
|
1826
|
+
`);
|
|
1827
|
+
return 2;
|
|
1828
|
+
}
|
|
1829
|
+
try {
|
|
1830
|
+
process.cwd();
|
|
1831
|
+
} catch (e) {
|
|
1832
|
+
if (e.code === "ENOENT") {
|
|
1833
|
+
process.stderr.write(
|
|
1834
|
+
`\u2717 Current directory is invalid (was it deleted while your shell was in it?).
|
|
1835
|
+
Run \`cd "$(pwd -P 2>/dev/null || echo ~)"\` or simply \`cd .\` in another fresh shell, then re-run.
|
|
1836
|
+
`
|
|
1837
|
+
);
|
|
1838
|
+
return 1;
|
|
1839
|
+
}
|
|
1840
|
+
throw e;
|
|
1841
|
+
}
|
|
1842
|
+
if (!await ensureProjectInitialized(opts)) {
|
|
1843
|
+
return 1;
|
|
1844
|
+
}
|
|
1845
|
+
try {
|
|
1846
|
+
await ensureSimsResolved(opts);
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
if (e instanceof ManualEditRequested || e instanceof NonInteractiveResolveError || e instanceof NoSimulatorsAvailableError) {
|
|
1849
|
+
process.stderr.write(`\u2717 ${e.message}
|
|
1850
|
+
`);
|
|
1851
|
+
return 1;
|
|
1852
|
+
}
|
|
1853
|
+
throw e;
|
|
1854
|
+
}
|
|
1855
|
+
const env = loadEnv();
|
|
1856
|
+
const appPath = opts.appPath ?? process.env.APP_PATH;
|
|
1857
|
+
if (!appPath) {
|
|
1858
|
+
process.stderr.write(
|
|
1859
|
+
`\u2717 No app path provided.
|
|
1860
|
+
Pass it as an argument: \`unotest-mobile install <path-to-.app>\`
|
|
1861
|
+
Or set APP_PATH in unotest/.env to skip the argument.
|
|
1862
|
+
`
|
|
1863
|
+
);
|
|
1864
|
+
return 2;
|
|
1865
|
+
}
|
|
1866
|
+
const slots = opts.slot === "all" ? env.simPool : env.simPool.includes(opts.slot) ? [opts.slot] : (() => {
|
|
1867
|
+
throw new Error(
|
|
1868
|
+
`--slot ${opts.slot} requested but slot "${opts.slot}" is not in SIM_POOL (${env.simPool.join(",")}).`
|
|
1869
|
+
);
|
|
1870
|
+
})();
|
|
1871
|
+
const logger = createLogger().child("install");
|
|
1872
|
+
const simctl = new SimctlAdapter();
|
|
1873
|
+
let permissions = resolveCliPermissions(opts, process.env);
|
|
1874
|
+
const nonInteractiveTop = opts.yes || !process.stdin.isTTY;
|
|
1875
|
+
if (permissions === void 0 && opts.updateEnv) {
|
|
1876
|
+
try {
|
|
1877
|
+
const plist = await readInfoPlist(resolve3(appPath));
|
|
1878
|
+
const detected = inferSimctlServices(plist.usageDescriptions);
|
|
1879
|
+
if (detected.length > 0) {
|
|
1880
|
+
const confirmed = nonInteractiveTop ? true : await confirmDetectedPermissions(detected);
|
|
1881
|
+
if (confirmed) permissions = detected;
|
|
1882
|
+
}
|
|
1883
|
+
} catch (e) {
|
|
1884
|
+
process.stderr.write(
|
|
1885
|
+
`[install] WARN: could not pre-read Info.plist for permissions detection: ${e.message}
|
|
1886
|
+
`
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
let result;
|
|
1891
|
+
try {
|
|
1892
|
+
result = await installApp2(
|
|
1893
|
+
{
|
|
1894
|
+
appPath,
|
|
1895
|
+
slots,
|
|
1896
|
+
simBySlot: env.simBySlot,
|
|
1897
|
+
envBundleId: env.APP_BUNDLE_ID,
|
|
1898
|
+
clean: opts.clean,
|
|
1899
|
+
erase: opts.erase,
|
|
1900
|
+
launch: opts.launch,
|
|
1901
|
+
...permissions !== void 0 ? { permissions } : {}
|
|
1902
|
+
// S8 — keyboard pin is always on in v1; reserve opts.pinKeyboard
|
|
1903
|
+
// for future per-call override (no CLI flag yet by design).
|
|
1904
|
+
},
|
|
1905
|
+
{ simctl, logger }
|
|
1906
|
+
);
|
|
1907
|
+
} catch (e) {
|
|
1908
|
+
process.stderr.write(`\u2717 install failed: ${e.message}
|
|
1909
|
+
`);
|
|
1910
|
+
return 1;
|
|
1911
|
+
}
|
|
1912
|
+
if (!opts.updateEnv && result.detectedPermissions.length > 0) {
|
|
1913
|
+
const envSet = new Set(
|
|
1914
|
+
(process.env.APP_PERMISSIONS ?? "").split(",").map((s) => s.trim()).filter(Boolean)
|
|
1915
|
+
);
|
|
1916
|
+
const newOnes = result.detectedPermissions.filter((s) => !envSet.has(s));
|
|
1917
|
+
if (newOnes.length > 0) {
|
|
1918
|
+
process.stdout.write(
|
|
1919
|
+
`
|
|
1920
|
+
\u2139 Detected permissions in Info.plist: ${result.detectedPermissions.join(", ")}.
|
|
1921
|
+
Re-run with --update-env to persist APP_PERMISSIONS and auto-grant next time.
|
|
1922
|
+
`
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
process.stdout.write(`
|
|
1927
|
+
\u2713 Installed ${result.appBundleId}
|
|
1928
|
+
`);
|
|
1929
|
+
process.stdout.write(` from: ${result.appPathAbsolute}
|
|
1930
|
+
`);
|
|
1931
|
+
if (result.appUrlScheme) {
|
|
1932
|
+
process.stdout.write(` url scheme: ${result.appUrlScheme}
|
|
1933
|
+
`);
|
|
1934
|
+
}
|
|
1935
|
+
for (const s of result.slots) {
|
|
1936
|
+
const flags = [
|
|
1937
|
+
s.erased ? "erased" : null,
|
|
1938
|
+
s.uninstalled ? "uninstalled-old" : null,
|
|
1939
|
+
s.launched ? "launched" : null
|
|
1940
|
+
].filter(Boolean).join(", ");
|
|
1941
|
+
process.stdout.write(
|
|
1942
|
+
` on slot ${s.slot} \u2192 ${s.simName} (${s.udid})${flags ? ` [${flags}]` : ""}
|
|
1943
|
+
`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
if (result.bundleIdMismatch) {
|
|
1947
|
+
process.stdout.write(
|
|
1948
|
+
`
|
|
1949
|
+
\u26A0 Bundle id mismatch:
|
|
1950
|
+
App's CFBundleIdentifier: ${result.appBundleId}
|
|
1951
|
+
APP_BUNDLE_ID in .env: ${env.APP_BUNDLE_ID}
|
|
1952
|
+
Tests will fail at appLaunch / WDA session start.
|
|
1953
|
+
` + (opts.updateEnv ? ` --update-env was passed \u2192 syncing .env now.
|
|
1954
|
+
` : ` Re-run with --update-env to sync, or fix unotest/.env manually.
|
|
1955
|
+
`)
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
if (opts.updateEnv) {
|
|
1959
|
+
const updates = [{ key: "APP_PATH", value: result.appPathAbsolute }];
|
|
1960
|
+
if (result.bundleIdMismatch) {
|
|
1961
|
+
updates.push({ key: "APP_BUNDLE_ID", value: result.appBundleId });
|
|
1962
|
+
}
|
|
1963
|
+
if (result.appUrlScheme && !process.env.APP_URL_SCHEME) {
|
|
1964
|
+
updates.push({ key: "APP_URL_SCHEME", value: result.appUrlScheme });
|
|
1965
|
+
}
|
|
1966
|
+
if (shouldPersistPermissions(permissions, process.env.APP_PERMISSIONS)) {
|
|
1967
|
+
updates.push({ key: "APP_PERMISSIONS", value: permissions.join(",") });
|
|
1968
|
+
}
|
|
1969
|
+
const envPath = resolve3(ENV_FILE_PATH);
|
|
1970
|
+
const upd = updateEnvFile(envPath, updates);
|
|
1971
|
+
const summary = [
|
|
1972
|
+
upd.added.length > 0 ? `added ${upd.added.join(",")}` : null,
|
|
1973
|
+
upd.changed.length > 0 ? `changed ${upd.changed.join(",")}` : null,
|
|
1974
|
+
upd.unchanged.length > 0 ? `unchanged ${upd.unchanged.join(",")}` : null
|
|
1975
|
+
].filter(Boolean).join("; ");
|
|
1976
|
+
process.stdout.write(`
|
|
1977
|
+
\u2713 ${envPath}: ${summary}
|
|
1978
|
+
`);
|
|
1979
|
+
}
|
|
1980
|
+
return 0;
|
|
1981
|
+
}
|
|
1982
|
+
__name(main, "main");
|
|
1983
|
+
runMain(main, 1);
|
|
1984
|
+
export {
|
|
1985
|
+
parseArgs,
|
|
1986
|
+
resolveCliPermissions,
|
|
1987
|
+
shouldPersistPermissions
|
|
1988
|
+
};
|