catalyst-core 1.1.0 → 1.2.0-canary.2

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.
Files changed (69) hide show
  1. package/bin/catalyst.js +54 -6
  2. package/changelog.md +27 -1
  3. package/dist/errors/registry.js +306 -18
  4. package/dist/errors-index.json +273 -70
  5. package/dist/native/androidProject/app/build.gradle.kts +53 -79
  6. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
  11. package/dist/native/androidSetup.js +76 -170
  12. package/dist/native/androidToolchain.js +642 -0
  13. package/dist/native/appIcon.js +100 -0
  14. package/dist/native/buildAndroid/assets.js +29 -5
  15. package/dist/native/buildAndroid/build.js +110 -223
  16. package/dist/native/buildAndroid/config.js +57 -65
  17. package/dist/native/buildAndroid/devices.js +176 -0
  18. package/dist/native/buildAndroid/index.js +271 -46
  19. package/dist/native/buildAndroid/output.js +43 -0
  20. package/dist/native/buildAndroid/signing.js +456 -0
  21. package/dist/native/buildAppAndroid.js +27 -7
  22. package/dist/native/buildAppIos.js +139 -20
  23. package/dist/native/buildIos/assets.js +76 -4
  24. package/dist/native/buildIos/build.js +31 -181
  25. package/dist/native/buildIos/config.js +126 -11
  26. package/dist/native/buildIos/index.js +354 -34
  27. package/dist/native/buildIos/localhostCert.js +87 -0
  28. package/dist/native/buildIos/output.js +28 -0
  29. package/dist/native/buildIos/signing.js +596 -0
  30. package/dist/native/credentials.js +309 -0
  31. package/dist/native/exportCredentials.js +51 -0
  32. package/dist/native/iosToolchain.js +583 -0
  33. package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
  34. package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
  35. package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
  36. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
  37. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
  38. package/dist/native/localState.js +148 -0
  39. package/dist/native/nativeConfig.js +621 -0
  40. package/dist/native/releaseOutput.js +53 -0
  41. package/dist/native/renameAndroidProject.js +154 -369
  42. package/dist/native/setupEmulatorIos.js +58 -275
  43. package/dist/native/setupServer.js +520 -124
  44. package/dist/native/utils.js +418 -115
  45. package/dist/vite/resolveDevServerConfig.js +21 -0
  46. package/mcp_v2/knowledge-base.json +13 -11
  47. package/mcp_v2/test/config.test.ts +40 -0
  48. package/mcp_v2/tools/build.js +80 -104
  49. package/mcp_v2/tools/config.js +181 -174
  50. package/package.json +1 -1
  51. package/test/cli/nativeFlags.test.ts +97 -0
  52. package/test/native/androidDevices.test.ts +230 -0
  53. package/test/native/androidElfAlignment.test.ts +148 -0
  54. package/test/native/androidSigning.test.ts +646 -0
  55. package/test/native/androidToolchain.test.ts +766 -0
  56. package/test/native/appIcon.test.ts +124 -0
  57. package/test/native/credentials.test.ts +241 -0
  58. package/test/native/interaction.test.ts +179 -0
  59. package/test/native/iosConfigPhase.test.ts +611 -0
  60. package/test/native/iosSigning.test.ts +1060 -0
  61. package/test/native/iosToolchain.test.ts +821 -0
  62. package/test/native/localState.test.ts +80 -0
  63. package/test/native/nativeConfig.test.ts +602 -0
  64. package/test/native/setupServer.test.ts +529 -0
  65. package/test/native/utils.test.ts +102 -127
  66. package/test/vite/resolveDevServerConfig.test.js +25 -0
  67. package/dist/native/androidProject/keystore/Untitled +0 -0
  68. package/dist/native/androidProject/keystore/release-key.jks +0 -0
  69. package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
@@ -0,0 +1,821 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import { describe, expect, it } from "vitest"
4
+
5
+ import {
6
+ DEFAULT_SIMULATOR_NAME,
7
+ assertFullXcode,
8
+ availableIosRuntimes,
9
+ chooseIosTarget,
10
+ devHostFor,
11
+ ensureIosRuntime,
12
+ ensureSimulator,
13
+ ensureXcodeReady,
14
+ newestIphoneDeviceType,
15
+ listConnectedIphones,
16
+ parseDevicectlDevices,
17
+ pendingXcodeSetup,
18
+ prepareIosDebugTarget,
19
+ probe,
20
+ runDevicectlList,
21
+ simctlJson,
22
+ } from "../../src/native/iosToolchain.js"
23
+
24
+ async function rejection(promise: Promise<unknown>): Promise<any> {
25
+ try {
26
+ await promise
27
+ } catch (error) {
28
+ return error
29
+ }
30
+ throw new Error("expected a rejection")
31
+ }
32
+
33
+ function thrown(fn: () => unknown): any {
34
+ try {
35
+ fn()
36
+ } catch (error) {
37
+ return error
38
+ }
39
+ throw new Error("expected a throw")
40
+ }
41
+
42
+ /** A probe stand-in: xcode-select's answer plus which setup checks pass. */
43
+ function xcode({
44
+ dir = "/Applications/Xcode.app/Contents/Developer",
45
+ licence = true,
46
+ firstLaunch = true,
47
+ } = {}) {
48
+ const state = { licence, firstLaunch }
49
+ const run = (command: string, args: string[]) => {
50
+ if (command === "xcode-select") return { status: dir ? 0 : 2, output: dir }
51
+ if (args.includes("-license")) return { status: state.licence ? 0 : 1, output: "" }
52
+ if (args.includes("-checkFirstLaunchStatus"))
53
+ return { status: state.firstLaunch ? 0 : 69, output: "" }
54
+ return { status: 0, output: "" }
55
+ }
56
+ return { run, state }
57
+ }
58
+
59
+ const runtime = (version: string, extra: object = {}) => ({
60
+ name: `iOS ${version}`,
61
+ version,
62
+ identifier: `com.apple.CoreSimulator.SimRuntime.iOS-${version.replace(/\./g, "-")}`,
63
+ platform: "iOS",
64
+ isAvailable: true,
65
+ ...extra,
66
+ })
67
+
68
+ describe("Xcode checks", () => {
69
+ it("accepts a developer directory inside Xcode.app", () => {
70
+ expect(assertFullXcode({ run: xcode().run })).toBe("/Applications/Xcode.app/Contents/Developer")
71
+ expect(
72
+ assertFullXcode({ run: xcode({ dir: "/Applications/Xcode-beta.app/Contents/Developer" }).run })
73
+ ).toContain("Xcode-beta")
74
+ })
75
+
76
+ it("fails with IOS-002 on the Command Line Tools alone", () => {
77
+ const error = thrown(() =>
78
+ assertFullXcode({ run: xcode({ dir: "/Library/Developer/CommandLineTools" }).run })
79
+ )
80
+ expect(error.code).toBe("IOS-002")
81
+ expect(error.message).toContain("CommandLineTools")
82
+ expect(thrown(() => assertFullXcode({ run: xcode({ dir: "" }).run })).code).toBe("IOS-002")
83
+ })
84
+
85
+ it("lists the pending licence and first-launch steps", () => {
86
+ const pending = pendingXcodeSetup({ run: xcode({ licence: false, firstLaunch: false }).run })
87
+ expect(pending.map((step) => step.fix.join(" "))).toEqual([
88
+ "sudo xcodebuild -license accept",
89
+ "sudo xcodebuild -runFirstLaunch",
90
+ ])
91
+ })
92
+
93
+ it("never runs sudo without a terminal or a flag: NATIVE-005 names the flag and the command", async () => {
94
+ const sudo: string[][] = []
95
+ const logs: string[] = []
96
+ const error = await rejection(
97
+ ensureXcodeReady({
98
+ run: xcode({ licence: false }).run,
99
+ isTTY: false,
100
+ log: (m: string) => void logs.push(m),
101
+ runInherited: async (command: string, args: string[]) => void sudo.push([command, ...args]),
102
+ })
103
+ )
104
+ expect(error.code).toBe("NATIVE-005")
105
+ expect(error.message).toContain("--accept-licenses")
106
+ expect(error.hint).toContain("sudo xcodebuild -license accept")
107
+ expect(logs.join("\n")).toContain("sudo xcodebuild -license accept")
108
+ expect(sudo).toEqual([])
109
+ })
110
+
111
+ it("does not take --yes for the licence", async () => {
112
+ const error = await rejection(
113
+ ensureXcodeReady({ run: xcode({ licence: false }).run, isTTY: false, flags: { yes: true } })
114
+ )
115
+ expect(error.code).toBe("NATIVE-005")
116
+ })
117
+
118
+ it("asks for the licence as a licence and the first launch as an install", async () => {
119
+ const { run, state } = xcode({ licence: false, firstLaunch: false })
120
+ const asked: string[] = []
121
+ const sudo: string[][] = []
122
+ await ensureXcodeReady({
123
+ run,
124
+ isTTY: true,
125
+ ask: async (kind: string) => {
126
+ asked.push(kind)
127
+ return true
128
+ },
129
+ runInherited: async (command: string, args: string[]) => {
130
+ sudo.push([command, ...args])
131
+ if (args.includes("-license")) state.licence = true
132
+ else state.firstLaunch = true
133
+ },
134
+ })
135
+ expect(asked).toEqual(["licence", "install"])
136
+ expect(sudo).toEqual([
137
+ ["sudo", "xcodebuild", "-license", "accept"],
138
+ ["sudo", "xcodebuild", "-runFirstLaunch"],
139
+ ])
140
+ })
141
+
142
+ it("runs sudo -n without a terminal when the flag answers (CI)", async () => {
143
+ const { run, state } = xcode({ licence: false })
144
+ const sudo: string[][] = []
145
+ await ensureXcodeReady({
146
+ run,
147
+ isTTY: false,
148
+ flags: { acceptLicenses: true },
149
+ runInherited: async (command: string, args: string[]) => {
150
+ sudo.push([command, ...args])
151
+ state.licence = true
152
+ },
153
+ })
154
+ expect(sudo).toEqual([["sudo", "-n", "xcodebuild", "-license", "accept"]])
155
+ })
156
+
157
+ it("fails with IOS-003 naming the command when the user declines or sudo fails", async () => {
158
+ const declined = await rejection(
159
+ ensureXcodeReady({ run: xcode({ licence: false }).run, isTTY: true, ask: async () => false })
160
+ )
161
+ expect(declined.code).toBe("IOS-003")
162
+ expect(declined.message).toContain("sudo xcodebuild -license accept")
163
+ const failed = await rejection(
164
+ ensureXcodeReady({
165
+ run: xcode({ licence: false }).run,
166
+ isTTY: false,
167
+ flags: { acceptLicenses: true },
168
+ runInherited: async () => {
169
+ throw new Error("sudo: a password is required")
170
+ },
171
+ })
172
+ )
173
+ expect(failed.code).toBe("IOS-003")
174
+ })
175
+ })
176
+
177
+ describe("iOS runtime", () => {
178
+ it("keeps available iOS runtimes, newest first", () => {
179
+ const runtimes = availableIosRuntimes({
180
+ runtimes: [
181
+ runtime("18.2"),
182
+ runtime("26.0"),
183
+ runtime("17.5", { isAvailable: false }),
184
+ {
185
+ ...runtime("11.0"),
186
+ platform: "watchOS",
187
+ identifier: "com.apple.CoreSimulator.SimRuntime.watchOS-11-0",
188
+ },
189
+ ],
190
+ })
191
+ expect(runtimes.map((r) => r.version)).toEqual(["26.0", "18.2"])
192
+ })
193
+
194
+ it("orders equal versions stably and treats a missing part as 0", () => {
195
+ const runtimes = availableIosRuntimes({
196
+ runtimes: [
197
+ runtime("26.0", { name: "first" }),
198
+ runtime("26", { name: "second" }),
199
+ runtime("26.0.1"),
200
+ {
201
+ ...runtime("18.0"),
202
+ platform: undefined,
203
+ availability: "(available)",
204
+ isAvailable: undefined,
205
+ },
206
+ ],
207
+ })
208
+ expect(runtimes.map((r: any) => r.version)).toEqual(["26.0.1", "26.0", "26", "18.0"])
209
+ expect(availableIosRuntimes(undefined)).toEqual([])
210
+ })
211
+
212
+ it("fails with IOS-004 when the download leaves no runtime", async () => {
213
+ const error = await rejection(
214
+ ensureIosRuntime({
215
+ flags: { yes: true },
216
+ isTTY: false,
217
+ listRuntimes: () => ({ runtimes: [] }),
218
+ runInherited: async () => {},
219
+ })
220
+ )
221
+ expect(error.code).toBe("IOS-004")
222
+ expect(error.message).toContain("without an available runtime")
223
+ })
224
+
225
+ it("fails with NATIVE-005 naming --yes without a terminal, without downloading", async () => {
226
+ const downloads: string[][] = []
227
+ const error = await rejection(
228
+ ensureIosRuntime({
229
+ isTTY: false,
230
+ listRuntimes: () => ({ runtimes: [] }),
231
+ runInherited: async (_c: string, args: string[]) => void downloads.push(args),
232
+ })
233
+ )
234
+ expect(error.code).toBe("NATIVE-005")
235
+ expect(error.message).toContain("--yes")
236
+ expect(downloads).toEqual([])
237
+ })
238
+
239
+ it("fails with IOS-004 when the user declines the download", async () => {
240
+ const error = await rejection(
241
+ ensureIosRuntime({ isTTY: true, ask: async () => false, listRuntimes: () => ({ runtimes: [] }) })
242
+ )
243
+ expect(error.code).toBe("IOS-004")
244
+ })
245
+
246
+ it("downloads the platform with --yes", async () => {
247
+ let installed = false
248
+ const downloads: string[][] = []
249
+ const result = await ensureIosRuntime({
250
+ flags: { yes: true },
251
+ isTTY: false,
252
+ listRuntimes: () => ({ runtimes: installed ? [runtime("26.0")] : [] }),
253
+ runInherited: async (command: string, args: string[]) => {
254
+ downloads.push([command, ...args])
255
+ installed = true
256
+ },
257
+ })
258
+ expect(downloads).toEqual([["xcodebuild", "-downloadPlatform", "iOS"]])
259
+ expect(result.version).toBe("26.0")
260
+ })
261
+ })
262
+
263
+ describe("simulator", () => {
264
+ const rt = runtime("26.0", {
265
+ supportedDeviceTypes: [
266
+ { name: "iPad Pro", identifier: "ipad", productFamily: "iPad" },
267
+ { name: "iPhone 17 Pro", identifier: "iphone-17-pro", productFamily: "iPhone" },
268
+ { name: "iPhone 17", identifier: "iphone-17", productFamily: "iPhone" },
269
+ ],
270
+ })
271
+ const devices = (names: string[]) => ({
272
+ devices: { [rt.identifier]: names.map((name, i) => ({ name, udid: `U${i}`, isAvailable: true })) },
273
+ })
274
+
275
+ it("takes the runtime's newest iPhone, falling back to the global list", () => {
276
+ expect(newestIphoneDeviceType(rt, {})?.identifier).toBe("iphone-17-pro")
277
+ expect(
278
+ newestIphoneDeviceType(runtime("26.0"), {
279
+ devicetypes: [
280
+ {
281
+ name: "iPhone 15",
282
+ identifier: "iphone-15",
283
+ productFamily: "iPhone",
284
+ minRuntimeVersion: 1,
285
+ },
286
+ {
287
+ name: "iPhone 16",
288
+ identifier: "iphone-16",
289
+ productFamily: "iPhone",
290
+ minRuntimeVersion: 2,
291
+ },
292
+ ],
293
+ })?.identifier
294
+ ).toBe("iphone-16")
295
+ })
296
+
297
+ it("uses an existing simulator without asking", async () => {
298
+ const simulator = await ensureSimulator({
299
+ runtime: rt,
300
+ name: "iPhone 17",
301
+ devicesJson: devices([DEFAULT_SIMULATOR_NAME, "iPhone 17"]),
302
+ deviceTypesJson: {},
303
+ ask: async () => {
304
+ throw new Error("should not ask")
305
+ },
306
+ })
307
+ expect(simulator).toEqual({ name: "iPhone 17", udid: "U1", created: false })
308
+ })
309
+
310
+ // What Xcode 26 puts on a fresh Mac: stock iPhones and iPads on iOS 26.0,
311
+ // and older ones on an iOS 18.2 runtime.
312
+ const ios26 = runtime("26.0", {
313
+ supportedDeviceTypes: [
314
+ "iPhone-17-Pro",
315
+ "iPhone-17-Pro-Max",
316
+ "iPhone-Air",
317
+ "iPhone-17",
318
+ "iPhone-16e",
319
+ ].map((t) => ({
320
+ name: t.replace(/-/g, " "),
321
+ identifier: `com.apple.CoreSimulator.SimDeviceType.${t}`,
322
+ productFamily: "iPhone",
323
+ })),
324
+ })
325
+ const ios18 = runtime("18.2")
326
+ const sim = (name: string, udid: string, extra: object = {}) => ({
327
+ name,
328
+ udid,
329
+ state: "Shutdown",
330
+ isAvailable: true,
331
+ deviceTypeIdentifier: `com.apple.CoreSimulator.SimDeviceType.${name.replace(/[ ()]+/g, "-")}`,
332
+ ...extra,
333
+ })
334
+ const stock = (overrides: Record<string, object> = {}) => {
335
+ const at = (name: string, udid: string) => sim(name, udid, overrides[name] || {})
336
+ return {
337
+ devices: {
338
+ [ios18.identifier]: [at("iPhone 16 Pro", "OLD-16PRO"), at("iPhone 16", "OLD-16")],
339
+ [ios26.identifier]: [
340
+ at("iPhone 17 Pro Max", "PROMAX"),
341
+ at("iPhone 17", "SEVENTEEN"),
342
+ at("iPhone Air", "AIR"),
343
+ at("iPhone 16e", "SIXTEENE"),
344
+ at("iPad Pro 11-inch (M4)", "IPAD"),
345
+ at("iPhone 17 Pro", "PRO"),
346
+ ],
347
+ "com.apple.CoreSimulator.SimRuntime.watchOS-26-0": [sim("Apple Watch Series 11", "WATCH")],
348
+ },
349
+ }
350
+ }
351
+ const pick = (options: Record<string, any>) =>
352
+ ensureSimulator({
353
+ runtime: ios26,
354
+ deviceTypesJson: {},
355
+ isTTY: false,
356
+ ask: async () => {
357
+ throw new Error("should not ask")
358
+ },
359
+ create: () => {
360
+ throw new Error("should not create")
361
+ },
362
+ ...options,
363
+ })
364
+
365
+ it("takes the newest Pro (not Max) on the chosen runtime without asking or creating", async () => {
366
+ expect(await pick({ devicesJson: stock() })).toEqual({
367
+ name: "iPhone 17 Pro",
368
+ udid: "PRO",
369
+ created: false,
370
+ })
371
+ })
372
+
373
+ it("takes the newest iPhone when the runtime has no Pro, the same one every run", async () => {
374
+ const noPro = stock()
375
+ noPro.devices[ios26.identifier] = noPro.devices[ios26.identifier].filter(
376
+ (s: any) => s.udid !== "PRO" && s.udid !== "PROMAX"
377
+ )
378
+ const first = await pick({ devicesJson: noPro })
379
+ noPro.devices[ios26.identifier].reverse()
380
+ expect(first).toEqual({ name: "iPhone Air", udid: "AIR", created: false })
381
+ expect(await pick({ devicesJson: noPro })).toEqual(first)
382
+ })
383
+
384
+ it("falls back to another runtime's iPhones before creating one", async () => {
385
+ const only18 = stock()
386
+ only18.devices[ios26.identifier] = []
387
+ expect(await pick({ devicesJson: only18 })).toEqual({
388
+ name: "iPhone 16 Pro",
389
+ udid: "OLD-16PRO",
390
+ created: false,
391
+ })
392
+ })
393
+
394
+ it("prefers a booted iPhone over the newest one", async () => {
395
+ expect(await pick({ devicesJson: stock({ "iPhone 16e": { state: "Booted" } }) })).toEqual({
396
+ name: "iPhone 16e",
397
+ udid: "SIXTEENE",
398
+ created: false,
399
+ })
400
+ })
401
+
402
+ it("prefers the remembered pick while its udid is available, over a booted one", async () => {
403
+ const devicesJson = stock({ "iPhone 16e": { state: "Booted" } })
404
+ const last = { target: "simulator", udid: "AIR", name: "iPhone Air" }
405
+ expect(await pick({ devicesJson, last })).toEqual({ name: "iPhone Air", udid: "AIR", created: false })
406
+ const gone = stock({ "iPhone Air": { isAvailable: false }, "iPhone 16e": { state: "Booted" } })
407
+ expect(await pick({ devicesJson: gone, last })).toMatchObject({ udid: "SIXTEENE" })
408
+ expect(
409
+ await pick({ devicesJson, last: { target: "device", udid: "AIR", name: "Jane's iPhone" } })
410
+ ).toMatchObject({ udid: "SIXTEENE" })
411
+ })
412
+
413
+ it("prefers ios.simulatorName over the remembered pick, and warns when it is missing", async () => {
414
+ const last = { target: "simulator", udid: "AIR", name: "iPhone Air" }
415
+ expect(await pick({ devicesJson: stock(), name: "iPhone 16", last })).toEqual({
416
+ name: "iPhone 16",
417
+ udid: "OLD-16",
418
+ created: false,
419
+ })
420
+ const logs: string[][] = []
421
+ const result = await pick({
422
+ devicesJson: stock(),
423
+ name: "iPhone 15",
424
+ last,
425
+ log: (message: string, type: string) => void logs.push([message, type]),
426
+ })
427
+ expect(result).toMatchObject({ udid: "AIR" })
428
+ expect(logs).toEqual([[expect.stringContaining('"iPhone 15"'), "warning"]])
429
+ })
430
+
431
+ it("does not count iPads or other platforms as an iPhone simulator", async () => {
432
+ const created: string[] = []
433
+ const result = await pick({
434
+ devicesJson: {
435
+ devices: {
436
+ [ios26.identifier]: [sim("iPad Pro 11-inch (M4)", "IPAD")],
437
+ "com.apple.CoreSimulator.SimRuntime.watchOS-26-0": [
438
+ sim("Apple Watch Series 11", "WATCH"),
439
+ ],
440
+ },
441
+ },
442
+ flags: { yes: true },
443
+ ask: async () => true,
444
+ create: (name: string) => {
445
+ created.push(name)
446
+ return "NEW\n"
447
+ },
448
+ })
449
+ expect(created).toEqual([DEFAULT_SIMULATOR_NAME])
450
+ expect(result).toEqual({ name: DEFAULT_SIMULATOR_NAME, udid: "NEW", created: true })
451
+ })
452
+
453
+ it("creates Catalyst iPhone after an install consent, and returns its udid", async () => {
454
+ const created: string[][] = []
455
+ const asked: string[] = []
456
+ const simulator = await ensureSimulator({
457
+ runtime: rt,
458
+ devicesJson: devices(["iPad Pro"]),
459
+ deviceTypesJson: {},
460
+ isTTY: false,
461
+ flags: { yes: true },
462
+ ask: async (kind: string, question: string, options: any) => {
463
+ asked.push(kind)
464
+ return Boolean(options.flags.yes)
465
+ },
466
+ create: (name: string, type: any, r: any) => {
467
+ created.push([name, type.identifier, r.identifier])
468
+ return "ABCDEF01-2345-6789-ABCD-EF0123456789\n"
469
+ },
470
+ })
471
+ expect(asked).toEqual(["install"])
472
+ expect(created).toEqual([[DEFAULT_SIMULATOR_NAME, "iphone-17-pro", rt.identifier]])
473
+ expect(simulator).toEqual({
474
+ name: DEFAULT_SIMULATOR_NAME,
475
+ udid: "ABCDEF01-2345-6789-ABCD-EF0123456789",
476
+ created: true,
477
+ })
478
+ })
479
+
480
+ it("falls back to the newest other runtime, wherever it sits in simctl's list", async () => {
481
+ const sim = (name: string, udid: string) => ({
482
+ name,
483
+ udid,
484
+ isAvailable: true,
485
+ deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-15",
486
+ })
487
+ const simulator = await ensureSimulator({
488
+ runtime: rt,
489
+ devicesJson: {
490
+ devices: {
491
+ "com.apple.CoreSimulator.SimRuntime.iOS-17-0": [sim("iPhone 15", "OLD-17")],
492
+ [rt.identifier]: [],
493
+ "com.apple.CoreSimulator.SimRuntime.iOS-18-2": [sim("iPhone 15", "NEW-18")],
494
+ "com.apple.CoreSimulator.SimRuntime.iOS-18-0": [sim("iPhone 15", "MID-18")],
495
+ },
496
+ },
497
+ deviceTypesJson: {},
498
+ isTTY: false,
499
+ })
500
+ expect(simulator).toEqual({ name: "iPhone 15", udid: "NEW-18", created: false })
501
+ })
502
+
503
+ it("fails when the user declines to create one, pointing to Xcode", async () => {
504
+ let created = false
505
+ const error = await rejection(
506
+ ensureSimulator({
507
+ runtime: rt,
508
+ devicesJson: devices([]),
509
+ deviceTypesJson: {},
510
+ isTTY: true,
511
+ ask: async () => false,
512
+ create: () => {
513
+ created = true
514
+ return ""
515
+ },
516
+ })
517
+ )
518
+ expect(error.message).toContain("Devices and Simulators")
519
+ expect(created).toBe(false)
520
+ })
521
+
522
+ it("fails with NATIVE-005 naming --yes when it would create one without a terminal", async () => {
523
+ const error = await rejection(
524
+ ensureSimulator({ runtime: rt, devicesJson: devices([]), deviceTypesJson: {}, isTTY: false })
525
+ )
526
+ expect(error.code).toBe("NATIVE-005")
527
+ expect(error.message).toContain("--yes")
528
+ })
529
+ })
530
+
531
+ describe("connected iPhones", () => {
532
+ const entry = (name: string, udid: string, extra: Record<string, any> = {}) => ({
533
+ identifier: `id-${udid}`,
534
+ deviceProperties: { name, osVersionNumber: "26.0" },
535
+ hardwareProperties: { reality: "physical", udid, platform: "iOS" },
536
+ connectionProperties: { tunnelState: "connected" },
537
+ ...extra,
538
+ })
539
+
540
+ it("keeps reachable physical iOS devices with a safe udid", () => {
541
+ const json = {
542
+ result: {
543
+ devices: [
544
+ entry("Jane's iPhone", "00008110-001A2B3C4D5E"),
545
+ entry("Old iPhone", "00008030-AAAA", {
546
+ connectionProperties: { tunnelState: "unavailable" },
547
+ }),
548
+ entry("Sim", "1111", { hardwareProperties: { reality: "virtual", udid: "1111" } }),
549
+ entry("Evil", "00; rm -rf /"),
550
+ entry("Watch", "2222", {
551
+ hardwareProperties: { reality: "physical", udid: "2222", platform: "watchOS" },
552
+ }),
553
+ ],
554
+ },
555
+ }
556
+ expect(parseDevicectlDevices(json)).toEqual([
557
+ { name: "Jane's iPhone", udid: "00008110-001A2B3C4D5E", version: "26.0" },
558
+ ])
559
+ expect(parseDevicectlDevices({})).toEqual([])
560
+ })
561
+
562
+ it("lists through devicectl's JSON file, and is empty when devicectl fails", () => {
563
+ const json = { result: { devices: [entry("Jane's iPhone", "00008110-001A2B3C4D5E")] } }
564
+ let written = ""
565
+ const devices = listConnectedIphones({
566
+ list: () =>
567
+ runDevicectlList({
568
+ exec: ((command: string, args: string[], options: any) => {
569
+ expect(command).toBe("xcrun")
570
+ expect(args.slice(0, 4)).toEqual(["devicectl", "list", "devices", "--json-output"])
571
+ expect(options.timeout).toBe(30000)
572
+ written = args[4]
573
+ // A private folder from mkdtemp, not a guessable name in tmp.
574
+ expect(fs.statSync(path.dirname(written)).mode & 0o077).toBe(0)
575
+ fs.writeFileSync(written, JSON.stringify(json))
576
+ return ""
577
+ }) as any,
578
+ }),
579
+ })
580
+ expect(devices).toEqual([{ name: "Jane's iPhone", udid: "00008110-001A2B3C4D5E", version: "26.0" }])
581
+ expect(fs.existsSync(path.dirname(written))).toBe(false)
582
+
583
+ const logs: string[] = []
584
+ let failedFile = ""
585
+ expect(
586
+ listConnectedIphones({
587
+ list: () =>
588
+ runDevicectlList({
589
+ exec: ((_c: string, args: string[]) => {
590
+ failedFile = args[4]
591
+ throw new Error("xcrun: error: unable to find utility devicectl")
592
+ }) as any,
593
+ }),
594
+ log: ((message: string) => void logs.push(message)) as any,
595
+ })
596
+ ).toEqual([])
597
+ expect(logs[0]).toContain("unable to find utility devicectl")
598
+ expect(fs.existsSync(path.dirname(failedFile))).toBe(false)
599
+ })
600
+ })
601
+
602
+ describe("process probes", () => {
603
+ it("reports the exit status and both streams, and null when the tool cannot start", () => {
604
+ const ok = probe("xcode-select", ["-p"], {
605
+ spawn: (() => ({
606
+ status: 0,
607
+ stdout: "/Applications/Xcode.app/Contents/Developer\n",
608
+ stderr: "",
609
+ })) as any,
610
+ })
611
+ expect(ok).toEqual({ status: 0, output: "/Applications/Xcode.app/Contents/Developer" })
612
+ const missing = probe("xcodebuild", ["-license", "check"], {
613
+ spawn: (() => ({ error: new Error("ENOENT"), status: null, stdout: null, stderr: null })) as any,
614
+ })
615
+ expect(missing).toEqual({ status: null, output: "" })
616
+ const real = probe(process.execPath, ["-e", "process.stderr.write('warn'); process.exit(2)"])
617
+ expect(real).toEqual({ status: 2, output: "warn" })
618
+ })
619
+
620
+ it("reads simctl JSON with -j appended", () => {
621
+ const calls: any[] = []
622
+ const json = simctlJson(["list", "runtimes"], {
623
+ exec: ((...args: any[]) => {
624
+ calls.push(args)
625
+ return '{"runtimes":[]}'
626
+ }) as any,
627
+ })
628
+ expect(json).toEqual({ runtimes: [] })
629
+ expect(calls[0].slice(0, 2)).toEqual(["xcrun", ["simctl", "list", "runtimes", "-j"]])
630
+ })
631
+ })
632
+
633
+ describe("target choice", () => {
634
+ const jane = { name: "Jane's iPhone", udid: "00008110-AAAA", version: "26.0" }
635
+ const ravi = { name: "Ravi's iPhone", udid: "00008110-BBBB", version: "18.5" }
636
+ const never = async () => {
637
+ throw new Error("should not ask")
638
+ }
639
+
640
+ it("is the simulator by default, even with an iPhone connected, without a terminal", async () => {
641
+ expect(await chooseIosTarget({ devices: [jane], isTTY: false, select: never })).toEqual({
642
+ target: "simulator",
643
+ })
644
+ })
645
+
646
+ it("does not ask in a terminal when no iPhone is connected", async () => {
647
+ expect(await chooseIosTarget({ devices: [], isTTY: true, select: never })).toEqual({
648
+ target: "simulator",
649
+ })
650
+ })
651
+
652
+ it("asks in a terminal when both are available, defaulting to the remembered pick", async () => {
653
+ const seen: any[] = []
654
+ const select = async (_q: string, choices: any[], options: any) => {
655
+ seen.push({ values: choices.map((c) => c.value), defaultValue: options.defaultValue })
656
+ return options.defaultValue
657
+ }
658
+ expect(await chooseIosTarget({ devices: [jane], isTTY: true, select })).toEqual({
659
+ target: "simulator",
660
+ })
661
+ expect(
662
+ await chooseIosTarget({
663
+ devices: [jane, ravi],
664
+ isTTY: true,
665
+ last: { target: "device", udid: ravi.udid },
666
+ select,
667
+ })
668
+ ).toEqual({ target: "device", udid: ravi.udid, name: ravi.name })
669
+ expect(seen).toEqual([
670
+ { values: ["simulator", jane.udid], defaultValue: "simulator" },
671
+ { values: ["simulator", jane.udid, ravi.udid], defaultValue: ravi.udid },
672
+ ])
673
+ })
674
+
675
+ it("--device takes the one connected iPhone without asking", async () => {
676
+ expect(await chooseIosTarget({ device: true, devices: [jane], isTTY: false, select: never })).toEqual(
677
+ {
678
+ target: "device",
679
+ udid: jane.udid,
680
+ name: jane.name,
681
+ }
682
+ )
683
+ })
684
+
685
+ it("--device fails with IOS-014 when no iPhone is connected", async () => {
686
+ const error = await rejection(chooseIosTarget({ device: true, devices: [], isTTY: true }))
687
+ expect(error.code).toBe("IOS-014")
688
+ })
689
+
690
+ it("--device with several iPhones: ios.deviceUDID, else a choice in a terminal, else NATIVE-005", async () => {
691
+ expect(
692
+ await chooseIosTarget({
693
+ device: true,
694
+ devices: [jane, ravi],
695
+ configuredUdid: ravi.udid,
696
+ isTTY: false,
697
+ select: never,
698
+ })
699
+ ).toMatchObject({ udid: ravi.udid })
700
+ expect(
701
+ await chooseIosTarget({
702
+ device: true,
703
+ devices: [jane, ravi],
704
+ isTTY: true,
705
+ select: async (_q: string, choices: any[]) => choices[1].value,
706
+ })
707
+ ).toMatchObject({ udid: ravi.udid })
708
+ const error = await rejection(chooseIosTarget({ device: true, devices: [jane, ravi], isTTY: false }))
709
+ expect(error.code).toBe("NATIVE-005")
710
+ expect(error.hint).toContain("deviceUDID")
711
+ })
712
+ })
713
+
714
+ describe("dev server host", () => {
715
+ it("is localhost for the simulator, which shares the Mac's network", () => {
716
+ expect(
717
+ devHostFor(
718
+ { target: "simulator" },
719
+ {
720
+ reachableHost: () => {
721
+ throw new Error("not needed")
722
+ },
723
+ }
724
+ )
725
+ ).toBe("localhost")
726
+ })
727
+
728
+ it("is the Mac's .local name for an iPhone, else its LAN IP", () => {
729
+ const iphone = { target: "device", name: "Jane's iPhone" }
730
+ expect(
731
+ devHostFor(iphone, { reachableHost: () => ({ host: "janes-mac.local", source: "bonjour" }) })
732
+ ).toBe("janes-mac.local")
733
+ expect(devHostFor(iphone, { reachableHost: () => ({ host: "192.168.1.20", source: "lan" }) })).toBe(
734
+ "192.168.1.20"
735
+ )
736
+ })
737
+
738
+ it("fails with IOS-015 when the iPhone has no way to reach the Mac", () => {
739
+ expect(thrown(() => devHostFor({ target: "device" }, { reachableHost: () => null })).code).toBe(
740
+ "IOS-015"
741
+ )
742
+ })
743
+ })
744
+
745
+ describe("prepareIosDebugTarget", () => {
746
+ const rt = runtime("26.0")
747
+ const jane = { name: "Jane's iPhone", udid: "00008110-AAAA", version: "26.0" }
748
+
749
+ function deps(overrides: Record<string, any> = {}) {
750
+ const saved: any[] = []
751
+ const options = {
752
+ cwd: "/app",
753
+ last: null,
754
+ listDevices: () => [jane],
755
+ ensureRuntime: async () => rt,
756
+ ensureSim: async ({ name }: any) => ({
757
+ name: name || "iPhone 17 Pro",
758
+ udid: "SIM-UDID",
759
+ created: false,
760
+ }),
761
+ reachableHost: () => ({ host: "janes-mac.local", source: "bonjour" }),
762
+ save: (platform: string, target: any) => void saved.push([platform, target]),
763
+ ...overrides,
764
+ }
765
+ return { options, saved }
766
+ }
767
+
768
+ it("builds for the simulator on localhost without a terminal, and remembers nothing", async () => {
769
+ const { options, saved } = deps({ isTTY: false, iosConfig: { simulatorName: "iPhone 17" } })
770
+ expect(await prepareIosDebugTarget(options)).toEqual({
771
+ target: { target: "simulator", name: "iPhone 17", udid: "SIM-UDID" },
772
+ devHost: "localhost",
773
+ })
774
+ expect(saved).toEqual([])
775
+ })
776
+
777
+ it("builds for the iPhone on the Mac's .local name with --device, and remembers it", async () => {
778
+ const { options, saved } = deps({
779
+ isTTY: false,
780
+ flags: { device: true },
781
+ ensureRuntime: async () => {
782
+ throw new Error("a device build needs no simulator runtime")
783
+ },
784
+ })
785
+ const result = await prepareIosDebugTarget(options)
786
+ expect(result).toEqual({
787
+ target: { target: "device", udid: jane.udid, name: jane.name },
788
+ devHost: "janes-mac.local",
789
+ })
790
+ expect(saved).toEqual([["ios", result.target]])
791
+ })
792
+
793
+ it("remembers a pick made in a terminal", async () => {
794
+ const { options, saved } = deps({ isTTY: true, select: async () => "simulator" })
795
+ await prepareIosDebugTarget(options)
796
+ expect(saved).toEqual([["ios", { target: "simulator", name: "iPhone 17 Pro", udid: "SIM-UDID" }]])
797
+ })
798
+
799
+ it("hands the configured name and the remembered pick to the simulator choice", async () => {
800
+ const seen: any[] = []
801
+ const last = { target: "simulator", udid: "LAST-UDID", name: "iPhone Air" }
802
+ const { options } = deps({
803
+ isTTY: false,
804
+ last,
805
+ ensureSim: async (args: any) => {
806
+ seen.push({ name: args.name, last: args.last })
807
+ return { name: "iPhone Air", udid: "LAST-UDID", created: false }
808
+ },
809
+ })
810
+ expect((await prepareIosDebugTarget(options)).target).toEqual({
811
+ target: "simulator",
812
+ name: "iPhone Air",
813
+ udid: "LAST-UDID",
814
+ })
815
+ await prepareIosDebugTarget({ ...options, iosConfig: { simulatorName: "iPhone 16" } })
816
+ expect(seen).toEqual([
817
+ { name: undefined, last },
818
+ { name: "iPhone 16", last },
819
+ ])
820
+ })
821
+ })