@unotest/mobile 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/runner/init/environment-check.ts
5
+ import { execFileSync } from "child_process";
6
+ var ExecError = class extends Error {
7
+ constructor(cmd, args) {
8
+ super(`${cmd} ${args.join(" ")} failed`);
9
+ this.cmd = cmd;
10
+ this.args = args;
11
+ }
12
+ cmd;
13
+ args;
14
+ static {
15
+ __name(this, "ExecError");
16
+ }
17
+ };
18
+ function defaultExec(cmd, args) {
19
+ try {
20
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
21
+ } catch {
22
+ throw new ExecError(cmd, args);
23
+ }
24
+ }
25
+ __name(defaultExec, "defaultExec");
26
+ function runEnvironmentChecks(opts = {}) {
27
+ const results = [];
28
+ const platform = opts.platform ?? process.platform;
29
+ const nodeVersion = opts.nodeVersion ?? process.versions.node;
30
+ const exec = opts.exec ?? defaultExec;
31
+ if (platform !== "darwin") {
32
+ results.push({
33
+ name: "platform",
34
+ severity: opts.allowNonMacos ? "warning" : "error",
35
+ message: `macOS required (got "${platform}"). iOS simulators only run on macOS (Apple licensing).`,
36
+ 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)."
37
+ });
38
+ if (!opts.allowNonMacos) return results;
39
+ } else {
40
+ results.push({ name: "platform", severity: "ok", message: "macOS detected" });
41
+ }
42
+ const major = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10);
43
+ if (major < 20) {
44
+ results.push({
45
+ name: "node",
46
+ severity: "error",
47
+ message: `Node 20+ required (got ${nodeVersion}).`,
48
+ detail: "Upgrade via nvm/fnm/volta."
49
+ });
50
+ return results;
51
+ }
52
+ results.push({ name: "node", severity: "ok", message: `Node ${nodeVersion}` });
53
+ if (platform !== "darwin") return results;
54
+ try {
55
+ const xcodePath = exec("xcode-select", ["-p"]).trim();
56
+ results.push({ name: "xcode-cli", severity: "ok", message: `Xcode CLI tools at ${xcodePath}` });
57
+ } catch {
58
+ results.push({
59
+ name: "xcode-cli",
60
+ severity: "error",
61
+ message: "Xcode Command Line Tools not found.",
62
+ detail: "Install: `xcode-select --install`"
63
+ });
64
+ return results;
65
+ }
66
+ try {
67
+ exec("xcrun", ["simctl", "help"]);
68
+ results.push({ name: "simctl", severity: "ok", message: "xcrun simctl available" });
69
+ } catch {
70
+ results.push({
71
+ name: "simctl",
72
+ severity: "error",
73
+ message: "xcrun simctl not available.",
74
+ detail: "Install full Xcode (from Mac App Store), not just Command Line Tools."
75
+ });
76
+ return results;
77
+ }
78
+ try {
79
+ const json = exec("xcrun", ["simctl", "list", "devices", "available", "--json"]);
80
+ const parsed = JSON.parse(json);
81
+ const total = Object.values(parsed.devices).reduce((sum, list) => sum + list.length, 0);
82
+ if (total === 0) {
83
+ results.push({
84
+ name: "simulators",
85
+ severity: "warning",
86
+ message: "No iOS simulators found.",
87
+ detail: "Create one in Xcode \u2192 Window \u2192 Devices and Simulators \u2192 '+'."
88
+ });
89
+ } else {
90
+ results.push({ name: "simulators", severity: "ok", message: `${total} simulators available` });
91
+ }
92
+ } catch {
93
+ results.push({
94
+ name: "simulators",
95
+ severity: "warning",
96
+ message: "Could not enumerate simulators."
97
+ });
98
+ }
99
+ try {
100
+ const pkgRaw = exec("cat", ["package.json"]);
101
+ const pkg = JSON.parse(pkgRaw);
102
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
103
+ const isRn = "react-native" in deps || "expo" in deps;
104
+ if (!isRn) {
105
+ results.push({
106
+ name: "rn-project",
107
+ severity: "warning",
108
+ message: "package.json doesn't declare react-native or expo.",
109
+ detail: "unotest-mobile is iOS RN/Expo-focused. Continuing \u2014 but make sure this is intentional."
110
+ });
111
+ } else {
112
+ results.push({ name: "rn-project", severity: "ok", message: "React Native / Expo detected" });
113
+ }
114
+ } catch {
115
+ results.push({
116
+ name: "rn-project",
117
+ severity: "warning",
118
+ message: "No readable package.json in current directory.",
119
+ detail: "Run init from your project's root."
120
+ });
121
+ }
122
+ return results;
123
+ }
124
+ __name(runEnvironmentChecks, "runEnvironmentChecks");
125
+
126
+ // src/runner/doctor.ts
127
+ function symbol(severity) {
128
+ return severity === "ok" ? "\u2713" : severity === "warning" ? "\u26A0" : "\u2717";
129
+ }
130
+ __name(symbol, "symbol");
131
+ function main() {
132
+ const argv = process.argv.slice(2);
133
+ const allowNonMacos = argv.includes("--allow-non-macos");
134
+ console.log("unotest-mobile doctor\n");
135
+ const checks = runEnvironmentChecks({ allowNonMacos });
136
+ let hardFailed = false;
137
+ let warnings = 0;
138
+ for (const c of checks) {
139
+ console.log(`${symbol(c.severity)} ${c.name}: ${c.message}`);
140
+ if (c.detail) console.log(` ${c.detail}`);
141
+ if (c.severity === "error") hardFailed = true;
142
+ if (c.severity === "warning") warnings++;
143
+ }
144
+ if (hardFailed) {
145
+ console.error("\nEnvironment not ready.");
146
+ return 1;
147
+ }
148
+ if (warnings > 0) {
149
+ console.log(`
150
+ ${warnings} warning(s). Tests may still work, but review them.`);
151
+ return 0;
152
+ }
153
+ console.log("\nEnvironment OK.");
154
+ return 0;
155
+ }
156
+ __name(main, "main");
157
+ process.exit(main());
158
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/runner/init/environment-check.ts","../../src/runner/doctor.ts"],"sourcesContent":["// Environment validation for `unotest-mobile init` and `doctor`. Each check\n// is small + injectable (exec function) for unit testing without touching\n// the host environment.\n\nimport { execFileSync } from \"node:child_process\";\n\nexport type CheckSeverity = \"ok\" | \"warning\" | \"error\";\n\nexport interface CheckResult {\n name: string;\n severity: CheckSeverity;\n message: string;\n detail?: string;\n}\n\nexport interface EnvironmentCheckOptions {\n allowNonMacos?: boolean;\n // Injectable for tests:\n platform?: NodeJS.Platform;\n nodeVersion?: string;\n exec?: (cmd: string, args: string[]) => string;\n}\n\nclass ExecError extends Error {\n constructor(public readonly cmd: string, public readonly args: string[]) {\n super(`${cmd} ${args.join(\" \")} failed`);\n }\n}\n\nfunction defaultExec(cmd: string, args: string[]): string {\n try {\n return execFileSync(cmd, args, { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] });\n } catch {\n throw new ExecError(cmd, args);\n }\n}\n\nexport function runEnvironmentChecks(opts: EnvironmentCheckOptions = {}): CheckResult[] {\n const results: CheckResult[] = [];\n const platform = opts.platform ?? process.platform;\n const nodeVersion = opts.nodeVersion ?? process.versions.node;\n const exec = opts.exec ?? defaultExec;\n\n // 1. Platform — hard requirement for iOS Simulator (Apple-only).\n if (platform !== \"darwin\") {\n results.push({\n name: \"platform\",\n severity: opts.allowNonMacos ? \"warning\" : \"error\",\n message: `macOS required (got \"${platform}\"). iOS simulators only run on macOS (Apple licensing).`,\n detail: opts.allowNonMacos\n ? \"Continuing with --allow-non-macos. Tests cannot run without a macOS host.\"\n : \"Re-run with --allow-non-macos if you only need to scaffold files (e.g. preparing a macOS CI runner).\",\n });\n if (!opts.allowNonMacos) return results;\n } else {\n results.push({ name: \"platform\", severity: \"ok\", message: \"macOS detected\" });\n }\n\n // 2. Node version.\n const major = Number.parseInt(nodeVersion.split(\".\")[0] ?? \"0\", 10);\n if (major < 20) {\n results.push({\n name: \"node\",\n severity: \"error\",\n message: `Node 20+ required (got ${nodeVersion}).`,\n detail: \"Upgrade via nvm/fnm/volta.\",\n });\n return results;\n }\n results.push({ name: \"node\", severity: \"ok\", message: `Node ${nodeVersion}` });\n\n // Mac-only checks below.\n if (platform !== \"darwin\") return results;\n\n // 3. Xcode CLI tools.\n try {\n const xcodePath = exec(\"xcode-select\", [\"-p\"]).trim();\n results.push({ name: \"xcode-cli\", severity: \"ok\", message: `Xcode CLI tools at ${xcodePath}` });\n } catch {\n results.push({\n name: \"xcode-cli\",\n severity: \"error\",\n message: \"Xcode Command Line Tools not found.\",\n detail: \"Install: `xcode-select --install`\",\n });\n return results;\n }\n\n // 4. xcrun simctl.\n try {\n exec(\"xcrun\", [\"simctl\", \"help\"]);\n results.push({ name: \"simctl\", severity: \"ok\", message: \"xcrun simctl available\" });\n } catch {\n results.push({\n name: \"simctl\",\n severity: \"error\",\n message: \"xcrun simctl not available.\",\n detail: \"Install full Xcode (from Mac App Store), not just Command Line Tools.\",\n });\n return results;\n }\n\n // 5. iOS simulators present.\n try {\n const json = exec(\"xcrun\", [\"simctl\", \"list\", \"devices\", \"available\", \"--json\"]);\n const parsed = JSON.parse(json) as { devices: Record<string, unknown[]> };\n const total = Object.values(parsed.devices).reduce((sum, list) => sum + list.length, 0);\n if (total === 0) {\n results.push({\n name: \"simulators\",\n severity: \"warning\",\n message: \"No iOS simulators found.\",\n detail: \"Create one in Xcode → Window → Devices and Simulators → '+'.\",\n });\n } else {\n results.push({ name: \"simulators\", severity: \"ok\", message: `${total} simulators available` });\n }\n } catch {\n results.push({\n name: \"simulators\",\n severity: \"warning\",\n message: \"Could not enumerate simulators.\",\n });\n }\n\n // 6. Project type — soft warning if not RN/Expo.\n try {\n const pkgRaw = exec(\"cat\", [\"package.json\"]);\n const pkg = JSON.parse(pkgRaw) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n const deps = { ...pkg.dependencies, ...pkg.devDependencies };\n const isRn = \"react-native\" in deps || \"expo\" in deps;\n if (!isRn) {\n results.push({\n name: \"rn-project\",\n severity: \"warning\",\n message: \"package.json doesn't declare react-native or expo.\",\n detail: \"unotest-mobile is iOS RN/Expo-focused. Continuing — but make sure this is intentional.\",\n });\n } else {\n results.push({ name: \"rn-project\", severity: \"ok\", message: \"React Native / Expo detected\" });\n }\n } catch {\n results.push({\n name: \"rn-project\",\n severity: \"warning\",\n message: \"No readable package.json in current directory.\",\n detail: \"Run init from your project's root.\",\n });\n }\n\n return results;\n}\n","// `unotest-mobile doctor` — re-run environment checks without touching files.\n// Useful when init was run earlier but something changed (Xcode updated,\n// sims deleted, etc.).\n\nimport { runEnvironmentChecks } from \"./init/environment-check.ts\";\n\nfunction symbol(severity: \"ok\" | \"warning\" | \"error\"): string {\n return severity === \"ok\" ? \"✓\" : severity === \"warning\" ? \"⚠\" : \"✗\";\n}\n\nfunction main(): number {\n const argv = process.argv.slice(2);\n const allowNonMacos = argv.includes(\"--allow-non-macos\");\n\n console.log(\"unotest-mobile doctor\\n\");\n const checks = runEnvironmentChecks({ allowNonMacos });\n\n let hardFailed = false;\n let warnings = 0;\n for (const c of checks) {\n console.log(`${symbol(c.severity)} ${c.name}: ${c.message}`);\n if (c.detail) console.log(` ${c.detail}`);\n if (c.severity === \"error\") hardFailed = true;\n if (c.severity === \"warning\") warnings++;\n }\n\n if (hardFailed) {\n console.error(\"\\nEnvironment not ready.\");\n return 1;\n }\n if (warnings > 0) {\n console.log(`\\n${warnings} warning(s). Tests may still work, but review them.`);\n return 0;\n }\n console.log(\"\\nEnvironment OK.\");\n return 0;\n}\n\nprocess.exit(main());\n"],"mappings":"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","names":[]}
@@ -0,0 +1,453 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/runner/init.ts
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
6
+ import { dirname, join, relative, resolve } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // src/runner/init/environment-check.ts
10
+ import { execFileSync } from "child_process";
11
+ var ExecError = class extends Error {
12
+ constructor(cmd, args) {
13
+ super(`${cmd} ${args.join(" ")} failed`);
14
+ this.cmd = cmd;
15
+ this.args = args;
16
+ }
17
+ cmd;
18
+ args;
19
+ static {
20
+ __name(this, "ExecError");
21
+ }
22
+ };
23
+ function defaultExec(cmd, args) {
24
+ try {
25
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
26
+ } catch {
27
+ throw new ExecError(cmd, args);
28
+ }
29
+ }
30
+ __name(defaultExec, "defaultExec");
31
+ function runEnvironmentChecks(opts = {}) {
32
+ const results = [];
33
+ const platform = opts.platform ?? process.platform;
34
+ const nodeVersion = opts.nodeVersion ?? process.versions.node;
35
+ const exec = opts.exec ?? defaultExec;
36
+ if (platform !== "darwin") {
37
+ results.push({
38
+ name: "platform",
39
+ severity: opts.allowNonMacos ? "warning" : "error",
40
+ message: `macOS required (got "${platform}"). iOS simulators only run on macOS (Apple licensing).`,
41
+ 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)."
42
+ });
43
+ if (!opts.allowNonMacos) return results;
44
+ } else {
45
+ results.push({ name: "platform", severity: "ok", message: "macOS detected" });
46
+ }
47
+ const major = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10);
48
+ if (major < 20) {
49
+ results.push({
50
+ name: "node",
51
+ severity: "error",
52
+ message: `Node 20+ required (got ${nodeVersion}).`,
53
+ detail: "Upgrade via nvm/fnm/volta."
54
+ });
55
+ return results;
56
+ }
57
+ results.push({ name: "node", severity: "ok", message: `Node ${nodeVersion}` });
58
+ if (platform !== "darwin") return results;
59
+ try {
60
+ const xcodePath = exec("xcode-select", ["-p"]).trim();
61
+ results.push({ name: "xcode-cli", severity: "ok", message: `Xcode CLI tools at ${xcodePath}` });
62
+ } catch {
63
+ results.push({
64
+ name: "xcode-cli",
65
+ severity: "error",
66
+ message: "Xcode Command Line Tools not found.",
67
+ detail: "Install: `xcode-select --install`"
68
+ });
69
+ return results;
70
+ }
71
+ try {
72
+ exec("xcrun", ["simctl", "help"]);
73
+ results.push({ name: "simctl", severity: "ok", message: "xcrun simctl available" });
74
+ } catch {
75
+ results.push({
76
+ name: "simctl",
77
+ severity: "error",
78
+ message: "xcrun simctl not available.",
79
+ detail: "Install full Xcode (from Mac App Store), not just Command Line Tools."
80
+ });
81
+ return results;
82
+ }
83
+ try {
84
+ const json = exec("xcrun", ["simctl", "list", "devices", "available", "--json"]);
85
+ const parsed = JSON.parse(json);
86
+ const total = Object.values(parsed.devices).reduce((sum, list) => sum + list.length, 0);
87
+ if (total === 0) {
88
+ results.push({
89
+ name: "simulators",
90
+ severity: "warning",
91
+ message: "No iOS simulators found.",
92
+ detail: "Create one in Xcode \u2192 Window \u2192 Devices and Simulators \u2192 '+'."
93
+ });
94
+ } else {
95
+ results.push({ name: "simulators", severity: "ok", message: `${total} simulators available` });
96
+ }
97
+ } catch {
98
+ results.push({
99
+ name: "simulators",
100
+ severity: "warning",
101
+ message: "Could not enumerate simulators."
102
+ });
103
+ }
104
+ try {
105
+ const pkgRaw = exec("cat", ["package.json"]);
106
+ const pkg = JSON.parse(pkgRaw);
107
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
108
+ const isRn = "react-native" in deps || "expo" in deps;
109
+ if (!isRn) {
110
+ results.push({
111
+ name: "rn-project",
112
+ severity: "warning",
113
+ message: "package.json doesn't declare react-native or expo.",
114
+ detail: "unotest-mobile is iOS RN/Expo-focused. Continuing \u2014 but make sure this is intentional."
115
+ });
116
+ } else {
117
+ results.push({ name: "rn-project", severity: "ok", message: "React Native / Expo detected" });
118
+ }
119
+ } catch {
120
+ results.push({
121
+ name: "rn-project",
122
+ severity: "warning",
123
+ message: "No readable package.json in current directory.",
124
+ detail: "Run init from your project's root."
125
+ });
126
+ }
127
+ return results;
128
+ }
129
+ __name(runEnvironmentChecks, "runEnvironmentChecks");
130
+
131
+ // src/runner/init/file-templates.ts
132
+ var templates = {
133
+ smokeWelcome: `// id-smoke-welcome
134
+ // First-run sanity: harness reaches sim, WDA, and app launch handshake
135
+ // #00aa00
136
+ function test_smoke_welcome() {
137
+ setDevice("A");
138
+ appLaunch(true);
139
+ }
140
+ `,
141
+ scenarioTemplate: `// id-<your-scenario-id>
142
+ // <one-line description>
143
+ // #888888
144
+ function test_<your_name>() {
145
+ // 1. SETUP \u2014 DB / API fixtures (helpers from _helpers/)
146
+ // wipe_e2e_users();
147
+ // user_id = seed_user("e2e@example.com", "e2e-pass");
148
+
149
+ // 2. ENTER \u2014 bring app to initial UI state
150
+ setDevice("A");
151
+ appLaunch(true);
152
+ waitFor(getByTestId("screen-welcome"), 15000);
153
+
154
+ // 3. ACT \u2014 actions you're testing
155
+ // signin("e2e@example.com", "e2e-pass");
156
+ // tap(getByTestId("btn-something"));
157
+
158
+ // 4. ASSERT \u2014 UI + DB / API checks
159
+ // assertVisible(getByTestId("screen-target"));
160
+ // count = dbQuery("SELECT count(*)::text FROM x WHERE ...");
161
+ // assertEqual(count, "1");
162
+ }
163
+ `,
164
+ agentsMd: `# AI Agents: how to write e2e tests for this project
165
+
166
+ This project uses [\`@unotest/mobile\`](https://www.npmjs.com/package/@unotest/mobile)
167
+ for end-to-end testing of iOS React Native flows.
168
+
169
+ ## Layout
170
+
171
+ - \`unotest/e2e/\` \u2014 scenarios (one \`test_*\` entry function per file)
172
+ - \`unotest/e2e/_helpers/\` \u2014 project-specific helpers, visible to all scenarios
173
+ - \`unotest/e2e/<feature>/\` \u2014 feature-scoped subfolders (recommended)
174
+
175
+ ## How to write a test
176
+
177
+ The full guide \u2014 DSL functions, scenario shape, helper rules, linter codes,
178
+ debugging \u2014 is the \`write-e2e-test\` Claude Code skill at
179
+ \`.claude/skills/write-e2e-test.md\`. It is the single source of truth for
180
+ both Claude Code and other AI agents. Read it before writing tests.
181
+
182
+ ## Running
183
+
184
+ - \`npx @unotest/mobile e2e <name>\` \u2014 run \`unotest/e2e/<name>.js\`
185
+ - \`npx @unotest/mobile lint\` \u2014 static check of all scenarios
186
+ - \`npx @unotest/mobile doctor\` \u2014 re-check environment
187
+
188
+ Inside Claude Code, prefer the MCP \`run_test\` tool with
189
+ \`pauseOnFailure: true\` \u2014 it pauses on the failed step so you can
190
+ \`inspect_runtime\`, fix the scenario, and \`resume\`.
191
+ `,
192
+ envExample: `# unotest-mobile \u2014 copied to unotest/.env by \`init\`. Fill in below.
193
+
194
+ # --- App under test --------------------------------------------------------
195
+ APP_BUNDLE_ID=com.example.myapp
196
+ APP_URL_SCHEME=myapp
197
+ INVITE_DEEPLINK_PREFIX=myapp://invite/
198
+
199
+ # --- Backend ---------------------------------------------------------------
200
+ API_BASE_URL=http://localhost:3000/api
201
+ # Optional. Absolute path used as default cwd for the \`shell(...)\` DSL
202
+ # primitive. Set if your scenarios shell out to project-local CLIs
203
+ # (pnpm cli, rails runner, etc.) that must run from the project root.
204
+ PROJECT_ROOT=
205
+
206
+ # --- Database --------------------------------------------------------------
207
+ # Pick the driver matching your backend. Install peer-deps only as needed:
208
+ # npm i -D pg @types/pg # for postgresql://
209
+ # npm i -D mysql2 # for mysql://
210
+ # (SQLite via better-sqlite3 is bundled \u2014 no separate install.)
211
+ #
212
+ # Docker-compose Postgres/MySQL \u2014 ensure host port is exposed
213
+ # (\`ports: ["5432:5432"]\`). Native clients connect from the host, not from
214
+ # inside the compose network.
215
+ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/myapp_test
216
+
217
+ # --- Simulators ------------------------------------------------------------
218
+ # Names must match \`xcrun simctl list devices\`.
219
+ SIM_A_NAME=iPhone 15
220
+ SIM_B_NAME=iPhone 15 Plus
221
+ SIM_POOL=A,B
222
+
223
+ # --- Dev tooling -----------------------------------------------------------
224
+ METRO_URL=http://localhost:8081
225
+ # Expo dev-client: auto-open dev-client deep link after a clean launch.
226
+ EXPO_DEV_CLIENT=false
227
+ SESSION_LOG_PATH=sessions/current.jsonl
228
+ ARTIFACTS_DIR=artifacts
229
+
230
+ # --- WebDriverAgent --------------------------------------------------------
231
+ # Per-slot WDA ports. Each slot in SIM_POOL needs one. Format: "slot=port".
232
+ WDA_PORTS=A=8100,B=8101
233
+ WDA_DEFAULT_ACTION_WAIT_MS=2000
234
+ WDA_DEFAULT_WAITFOR_TIMEOUT_MS=10000
235
+
236
+ # --- D-17 paused-runtime TTL ----------------------------------------------
237
+ # How long a paused-on-failure runtime stays alive before auto-abort (ms).
238
+ # Default 30 minutes. Inspecting the runtime resets the timer.
239
+ PAUSED_RUNTIME_TTL_MS=1800000
240
+ `,
241
+ gitignoreLines: [
242
+ "",
243
+ "# unotest-mobile",
244
+ "unotest/.env",
245
+ "unotest/artifacts/",
246
+ "unotest/sessions/"
247
+ ],
248
+ mcpServerEntry: {
249
+ command: "npx",
250
+ args: ["-y", "@unotest/mobile"]
251
+ }
252
+ };
253
+
254
+ // src/runner/init/mcp-config-merger.ts
255
+ var McpJsonParseError = class extends Error {
256
+ static {
257
+ __name(this, "McpJsonParseError");
258
+ }
259
+ constructor(message) {
260
+ super(message);
261
+ this.name = "McpJsonParseError";
262
+ }
263
+ };
264
+ function mergeMcpConfig(existingSource, serverName, serverConfig, options = {}) {
265
+ if (existingSource === null) {
266
+ const content2 = JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2) + "\n";
267
+ return { action: "created", newContent: content2 };
268
+ }
269
+ let parsed;
270
+ try {
271
+ parsed = JSON.parse(existingSource);
272
+ } catch (e) {
273
+ throw new McpJsonParseError(
274
+ `Failed to parse .mcp.json as JSON: ${e.message}. Fix or remove the file and re-run init.`
275
+ );
276
+ }
277
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
278
+ parsed.mcpServers = {};
279
+ }
280
+ const alreadyPresent = serverName in parsed.mcpServers;
281
+ if (alreadyPresent && !options.force) {
282
+ return { action: "already-present", newContent: existingSource };
283
+ }
284
+ parsed.mcpServers[serverName] = serverConfig;
285
+ const content = JSON.stringify(parsed, null, 2) + "\n";
286
+ return {
287
+ action: alreadyPresent ? "force-overwrote" : "added",
288
+ newContent: content
289
+ };
290
+ }
291
+ __name(mergeMcpConfig, "mergeMcpConfig");
292
+
293
+ // src/runner/init/gitignore-updater.ts
294
+ function appendUniqueLines(existingContent, linesToAdd) {
295
+ const existing = existingContent ?? "";
296
+ const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
297
+ const added = [];
298
+ const alreadyPresent = [];
299
+ for (const line of linesToAdd) {
300
+ const trimmed = line.trim();
301
+ if (trimmed === "" || existingLines.has(trimmed)) {
302
+ if (trimmed !== "") alreadyPresent.push(line);
303
+ continue;
304
+ }
305
+ added.push(line);
306
+ existingLines.add(trimmed);
307
+ }
308
+ if (added.length === 0) {
309
+ return { added, alreadyPresent, newContent: existing };
310
+ }
311
+ const sep = existing === "" ? "" : existing.endsWith("\n") ? "" : "\n";
312
+ const newContent = existing + sep + added.join("\n") + "\n";
313
+ return { added, alreadyPresent, newContent };
314
+ }
315
+ __name(appendUniqueLines, "appendUniqueLines");
316
+
317
+ // src/runner/init.ts
318
+ var here = dirname(fileURLToPath(import.meta.url));
319
+ var packageRoot = resolve(here, "..", "..");
320
+ function parseArgs(argv) {
321
+ return {
322
+ force: argv.includes("--force"),
323
+ allowNonMacos: argv.includes("--allow-non-macos")
324
+ };
325
+ }
326
+ __name(parseArgs, "parseArgs");
327
+ function symbol(severity) {
328
+ return severity === "ok" ? "\u2713" : severity === "warning" ? "\u26A0" : "\u2717";
329
+ }
330
+ __name(symbol, "symbol");
331
+ function ensureDir(p) {
332
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
333
+ }
334
+ __name(ensureDir, "ensureDir");
335
+ function writeIfNeeded(filePath, content, force) {
336
+ const exists = existsSync(filePath);
337
+ if (exists && !force) return "skipped";
338
+ ensureDir(dirname(filePath));
339
+ writeFileSync(filePath, content);
340
+ return exists ? "overwrote" : "created";
341
+ }
342
+ __name(writeIfNeeded, "writeIfNeeded");
343
+ function readPackageFile(relativePath) {
344
+ const abs = resolve(packageRoot, relativePath);
345
+ if (!existsSync(abs)) return null;
346
+ return readFileSync(abs, "utf8");
347
+ }
348
+ __name(readPackageFile, "readPackageFile");
349
+ function main() {
350
+ const opts = parseArgs(process.argv.slice(2));
351
+ const target = process.cwd();
352
+ console.log("unotest-mobile init \u2014 bootstrapping project\n");
353
+ console.log("Environment:");
354
+ const checks = runEnvironmentChecks({ allowNonMacos: opts.allowNonMacos });
355
+ let hardFailed = false;
356
+ for (const c of checks) {
357
+ console.log(` ${symbol(c.severity)} ${c.name}: ${c.message}`);
358
+ if (c.detail) console.log(` ${c.detail}`);
359
+ if (c.severity === "error") hardFailed = true;
360
+ }
361
+ if (hardFailed) {
362
+ console.error("\nFix the errors above and re-run.");
363
+ return 1;
364
+ }
365
+ console.log("\nFiles:");
366
+ const summary = [];
367
+ ensureDir(join(target, "unotest/e2e/_helpers"));
368
+ summary.push({
369
+ path: "unotest/e2e/smoke-welcome.js",
370
+ status: writeIfNeeded(join(target, "unotest/e2e/smoke-welcome.js"), templates.smokeWelcome, opts.force)
371
+ });
372
+ summary.push({
373
+ path: "unotest/e2e/_template.js",
374
+ status: writeIfNeeded(join(target, "unotest/e2e/_template.js"), templates.scenarioTemplate, opts.force)
375
+ });
376
+ summary.push({
377
+ path: "unotest/AGENTS.md",
378
+ status: writeIfNeeded(join(target, "unotest/AGENTS.md"), templates.agentsMd, opts.force)
379
+ });
380
+ const skillSrc = readPackageFile(".claude/skills/write-e2e-test.md");
381
+ if (skillSrc !== null) {
382
+ summary.push({
383
+ path: ".claude/skills/write-e2e-test.md",
384
+ status: writeIfNeeded(join(target, ".claude/skills/write-e2e-test.md"), skillSrc, opts.force)
385
+ });
386
+ } else {
387
+ summary.push({ path: ".claude/skills/write-e2e-test.md", status: "missing-in-package" });
388
+ }
389
+ const mcpPath = join(target, ".mcp.json");
390
+ try {
391
+ const existing = existsSync(mcpPath) ? readFileSync(mcpPath, "utf8") : null;
392
+ const merge = mergeMcpConfig(
393
+ existing,
394
+ "unotest-mobile",
395
+ {
396
+ command: templates.mcpServerEntry.command,
397
+ args: [...templates.mcpServerEntry.args]
398
+ },
399
+ { force: opts.force }
400
+ );
401
+ if (merge.action !== "already-present") {
402
+ writeFileSync(mcpPath, merge.newContent);
403
+ }
404
+ summary.push({ path: ".mcp.json", status: merge.action });
405
+ } catch (e) {
406
+ if (e instanceof McpJsonParseError) {
407
+ console.error(`
408
+ \u2717 .mcp.json: ${e.message}`);
409
+ return 1;
410
+ }
411
+ throw e;
412
+ }
413
+ const envExamplePath = join(target, "unotest/.env.example");
414
+ summary.push({
415
+ path: "unotest/.env.example",
416
+ status: writeIfNeeded(envExamplePath, templates.envExample, true)
417
+ });
418
+ const envDst = join(target, "unotest/.env");
419
+ if (!existsSync(envDst)) {
420
+ writeFileSync(envDst, templates.envExample);
421
+ summary.push({ path: "unotest/.env", status: "created" });
422
+ } else {
423
+ summary.push({ path: "unotest/.env", status: "skipped (exists \u2014 fill in manually)" });
424
+ }
425
+ const gitignorePath = join(target, ".gitignore");
426
+ const gitignoreExisting = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : null;
427
+ const giUpdate = appendUniqueLines(gitignoreExisting, [...templates.gitignoreLines]);
428
+ if (giUpdate.added.length > 0) {
429
+ writeFileSync(gitignorePath, giUpdate.newContent);
430
+ summary.push({ path: ".gitignore", status: `added ${giUpdate.added.length} line(s)` });
431
+ } else {
432
+ summary.push({ path: ".gitignore", status: "already up-to-date" });
433
+ }
434
+ for (const item of summary) {
435
+ 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" : "?";
436
+ console.log(` ${symbolForStatus} ${item.path} \u2014 ${item.status}`);
437
+ }
438
+ console.log(`
439
+ Next:
440
+ 1. Edit ${relative(target, envDst) || "unotest/.env"} with your project's values
441
+ (DATABASE_URL, SIM_A_NAME, APP_BUNDLE_ID, ...)
442
+ 2. Boot iOS simulators matching SIM_A_NAME / SIM_B_NAME (Xcode \u2192 Devices)
443
+ 3. Open Claude Code in this directory \u2014 MCP server auto-registers via
444
+ .mcp.json. Ask the agent to write a test.
445
+ 4. CLI: \`npx @unotest/mobile e2e smoke-welcome\` to run the starter.
446
+
447
+ Re-check environment anytime: \`npx @unotest/mobile doctor\`
448
+ `);
449
+ return 0;
450
+ }
451
+ __name(main, "main");
452
+ process.exit(main());
453
+ //# sourceMappingURL=init.js.map