catalyst-core 1.0.1 → 1.0.2-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 (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. package/dist/scripts/cleanCache.js +0 -30
@@ -0,0 +1,532 @@
1
+ import { execSync, spawn } from "child_process"
2
+ import fs from "fs"
3
+ import os from "os"
4
+ import path from "path"
5
+ import readline from "readline"
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
7
+
8
+ // utils.js is ESM, so it is imported here the same way redactSecrets.test.ts
9
+ // imports it: one module copy for v8 coverage to merge.
10
+ //
11
+ // execSync and spawn are wrapped so each test can fake a tool's answer, while
12
+ // anything a test does not stub still runs for real (node itself, never adb
13
+ // or an emulator).
14
+ vi.mock("child_process", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("child_process")>()
16
+ return {
17
+ ...actual,
18
+ default: actual,
19
+ execSync: vi.fn(actual.execSync),
20
+ spawn: vi.fn(actual.spawn),
21
+ }
22
+ })
23
+
24
+ import {
25
+ ANDROID_SDK_HINT,
26
+ commandExists,
27
+ detectAndroidSdk,
28
+ ensureLocalProperties,
29
+ listAvds,
30
+ promptUser,
31
+ redactError,
32
+ redactSecrets,
33
+ runCommand,
34
+ runInteractiveCommand,
35
+ runSdkManagerCommand,
36
+ validateAndCompleteConfig,
37
+ } from "../../src/native/utils.js"
38
+
39
+ const mockedExec = vi.mocked(execSync)
40
+ const mockedSpawn = vi.mocked(spawn)
41
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g")
42
+ const plain = (text: unknown) => String(text).replace(ANSI, "")
43
+
44
+ const temps: string[] = []
45
+ function tempDir() {
46
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-native-utils-"))
47
+ temps.push(dir)
48
+ return dir
49
+ }
50
+
51
+ /** A directory laid out like an SDK: what detectAndroidSdk checks for. */
52
+ function fakeSdk() {
53
+ const sdk = tempDir()
54
+ fs.mkdirSync(path.join(sdk, "platform-tools"))
55
+ fs.writeFileSync(path.join(sdk, "platform-tools", process.platform === "win32" ? "adb.exe" : "adb"), "")
56
+ return sdk
57
+ }
58
+
59
+ function setTTY(value: boolean | undefined) {
60
+ Object.defineProperty(process.stdin, "isTTY", { value, configurable: true, writable: true })
61
+ }
62
+
63
+ const originalTTY = process.stdin.isTTY
64
+
65
+ beforeEach(() => {
66
+ vi.spyOn(console, "error").mockImplementation(() => {})
67
+ vi.spyOn(console, "log").mockImplementation(() => {})
68
+ })
69
+
70
+ afterEach(() => {
71
+ vi.restoreAllMocks()
72
+ // mockReset puts back the real implementation vi.fn was given.
73
+ mockedExec.mockReset()
74
+ mockedSpawn.mockReset()
75
+ setTTY(originalTTY)
76
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
77
+ })
78
+
79
+ describe("runCommand", () => {
80
+ it("returns the command's stdout with stderr captured, not inherited", () => {
81
+ expect(runCommand("echo hello")).toBe("hello\n")
82
+ expect(mockedExec).toHaveBeenCalledWith("echo hello", {
83
+ encoding: "utf8",
84
+ stdio: ["ignore", "pipe", "pipe"],
85
+ timeout: undefined,
86
+ })
87
+ })
88
+
89
+ it("logs the failing command with its passwords redacted and rethrows", () => {
90
+ const failure = Object.assign(new Error("Command failed: keytool -storepass hunter2"), { status: 1 })
91
+ mockedExec.mockImplementationOnce(() => {
92
+ throw failure
93
+ })
94
+
95
+ expect(() => runCommand("keytool -storepass hunter2")).toThrow(failure)
96
+ expect(failure.message).toBe("Command failed: keytool -storepass ***")
97
+ const logged = vi.mocked(console.error).mock.calls.flat().join("\n")
98
+ expect(logged).toContain("Error executing command: keytool -storepass ***")
99
+ expect(logged).not.toContain("hunter2")
100
+ })
101
+
102
+ it("turns an adb timeout into a named error with the reset that clears it", () => {
103
+ mockedExec.mockImplementationOnce(() => {
104
+ throw Object.assign(new Error("spawnSync /bin/sh ETIMEDOUT"), { code: "ETIMEDOUT" })
105
+ })
106
+
107
+ let error: any
108
+ try {
109
+ runCommand('"/sdk/platform-tools/adb" devices', { timeout: 10000 })
110
+ } catch (caught) {
111
+ error = caught
112
+ }
113
+ expect(error.message).toBe("adb did not respond within 10s")
114
+ expect(error.code).toBe("ETOOLTIMEOUT")
115
+ expect(error.hint).toContain("adb kill-server && adb start-server")
116
+ expect(console.error).not.toHaveBeenCalled()
117
+ })
118
+
119
+ it("recognises a timeout reported as a SIGTERM kill, with tool-specific hints", () => {
120
+ const killed = () => {
121
+ throw Object.assign(new Error("killed"), { signal: "SIGTERM", status: null })
122
+ }
123
+ const timeoutOf = (command: string) => {
124
+ mockedExec.mockImplementationOnce(killed)
125
+ try {
126
+ runCommand(command, { timeout: 2500 })
127
+ } catch (error: any) {
128
+ return error
129
+ }
130
+ }
131
+
132
+ const xcrun = timeoutOf("xcrun devicectl list devices")
133
+ expect(xcrun.message).toBe("xcrun did not respond within 3s")
134
+ expect(xcrun.hint).toContain("Quit Xcode and Simulator")
135
+
136
+ const other = timeoutOf(" /usr/bin/java -version")
137
+ expect(other.message).toBe("java did not respond within 3s")
138
+ expect(other.hint).toBe("Check that java works on its own, then retry.")
139
+ })
140
+
141
+ it("does not treat a SIGTERM as a timeout when no timeout was asked for", () => {
142
+ const killed = Object.assign(new Error("killed"), { signal: "SIGTERM", status: null })
143
+ mockedExec.mockImplementationOnce(() => {
144
+ throw killed
145
+ })
146
+ expect(() => runCommand("adb devices")).toThrow(killed)
147
+ })
148
+ })
149
+
150
+ describe("redactSecrets and redactError", () => {
151
+ it("redacts every password form and passes non-strings through", () => {
152
+ expect(
153
+ redactSecrets(
154
+ 'x -storepass "a b" -keypass k signing.store.password=s signing.key.password="q" --password=p --password z'
155
+ )
156
+ ).toBe(
157
+ "x -storepass *** -keypass *** signing.store.password=*** signing.key.password=*** --password=*** --password ***"
158
+ )
159
+ expect(redactSecrets(undefined)).toBe(undefined)
160
+ expect(redactSecrets(42 as any)).toBe(42)
161
+ })
162
+
163
+ it("scrubs every string field and the output array, leaving the rest", () => {
164
+ const error: any = {
165
+ message: "failed: -keypass sekret",
166
+ stack: "at sekret",
167
+ cmd: "cmd -keypass sekret",
168
+ stdout: "echo sekret",
169
+ stderr: "err sekret",
170
+ output: [null, "out sekret", "fine"],
171
+ status: 1,
172
+ }
173
+ expect(redactError(error, "cmd -keypass sekret")).toBe(error)
174
+ expect(JSON.stringify(error)).not.toContain("sekret")
175
+ expect(error.output).toEqual([null, "out ***", "fine"])
176
+ expect(error.status).toBe(1)
177
+ })
178
+
179
+ it("ignores anything that is not an error object", () => {
180
+ expect(redactError(null, "x")).toBe(null)
181
+ expect(redactError("text" as any, "x")).toBe("text")
182
+ })
183
+ })
184
+
185
+ describe("commandExists", () => {
186
+ it("asks `which` and reports whether it found the tool", () => {
187
+ mockedExec.mockImplementationOnce(() => "")
188
+ expect(commandExists("adb")).toBe(true)
189
+ expect(mockedExec).toHaveBeenLastCalledWith("which adb", { stdio: "ignore" })
190
+
191
+ mockedExec.mockImplementationOnce(() => {
192
+ throw new Error("not found")
193
+ })
194
+ expect(commandExists("adb")).toBe(false)
195
+ })
196
+ })
197
+
198
+ describe("detectAndroidSdk", () => {
199
+ it("prefers ANDROID_HOME, then ANDROID_SDK_ROOT", () => {
200
+ const home = fakeSdk()
201
+ const root = fakeSdk()
202
+ expect(detectAndroidSdk({ ANDROID_HOME: home, ANDROID_SDK_ROOT: root })).toBe(home)
203
+ expect(detectAndroidSdk({ ANDROID_SDK_ROOT: root })).toBe(root)
204
+ })
205
+
206
+ it("skips a directory without adb and falls back to Android Studio's location", () => {
207
+ const empty = tempDir()
208
+ const home = tempDir()
209
+ vi.spyOn(os, "homedir").mockReturnValue(home)
210
+ const studio = path.join(home, "Android", "Sdk")
211
+ fs.mkdirSync(path.join(studio, "platform-tools"), { recursive: true })
212
+ fs.writeFileSync(
213
+ path.join(studio, "platform-tools", process.platform === "win32" ? "adb.exe" : "adb"),
214
+ ""
215
+ )
216
+
217
+ expect(detectAndroidSdk({ ANDROID_HOME: empty, LOCALAPPDATA: tempDir() })).toBe(studio)
218
+ })
219
+
220
+ it("returns null when nothing has adb", () => {
221
+ vi.spyOn(os, "homedir").mockReturnValue(tempDir())
222
+ expect(detectAndroidSdk({})).toBe(null)
223
+ })
224
+
225
+ it("reads process.env by default", () => {
226
+ const sdk = fakeSdk()
227
+ vi.stubEnv("ANDROID_HOME", sdk)
228
+ try {
229
+ expect(detectAndroidSdk()).toBe(sdk)
230
+ } finally {
231
+ vi.unstubAllEnvs()
232
+ }
233
+ })
234
+ })
235
+
236
+ describe("listAvds", () => {
237
+ it("asks the SDK's emulator, expanding ~ and dropping INFO noise", () => {
238
+ const home = tempDir()
239
+ vi.spyOn(os, "homedir").mockReturnValue(home)
240
+ mockedExec.mockImplementationOnce(() => "INFO | Storing crashdata\nPixel_7_API_34\n\n Tablet \n")
241
+
242
+ expect(listAvds("~/sdk")).toEqual(["Pixel_7_API_34", "Tablet"])
243
+ expect(mockedExec).toHaveBeenLastCalledWith(
244
+ `"${path.join(home, "sdk", "emulator", "emulator")}" -list-avds`,
245
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }
246
+ )
247
+ })
248
+
249
+ it("returns [] without an SDK or when the emulator cannot be run", () => {
250
+ expect(listAvds(null)).toEqual([])
251
+ expect(mockedExec).not.toHaveBeenCalled()
252
+
253
+ mockedExec.mockImplementationOnce(() => {
254
+ throw new Error("ENOENT")
255
+ })
256
+ expect(listAvds("/no/sdk")).toEqual([])
257
+ })
258
+ })
259
+
260
+ describe("ensureLocalProperties", () => {
261
+ it("writes sdk.dir when local.properties is missing", () => {
262
+ const project = tempDir()
263
+ expect(ensureLocalProperties(project, "/opt/sdk")).toBe(true)
264
+ expect(fs.readFileSync(path.join(project, "local.properties"), "utf8")).toBe("sdk.dir=/opt/sdk\n")
265
+ })
266
+
267
+ it("does nothing when the line is already there", () => {
268
+ const project = tempDir()
269
+ const file = path.join(project, "local.properties")
270
+ fs.writeFileSync(file, "ndk.dir=/ndk\nsdk.dir=/opt/sdk\n")
271
+ expect(ensureLocalProperties(project, "/opt/sdk")).toBe(false)
272
+ expect(fs.readFileSync(file, "utf8")).toBe("ndk.dir=/ndk\nsdk.dir=/opt/sdk\n")
273
+ })
274
+
275
+ it("replaces a stale sdk.dir, keeps other settings, expands ~ and escapes backslashes", () => {
276
+ const project = tempDir()
277
+ const file = path.join(project, "local.properties")
278
+ fs.writeFileSync(file, "sdk.dir=/old\n\nndk.dir=/ndk\n")
279
+ const home = tempDir()
280
+ vi.spyOn(os, "homedir").mockReturnValue(home)
281
+
282
+ expect(ensureLocalProperties(project, "~/Android/sdk")).toBe(true)
283
+ expect(fs.readFileSync(file, "utf8")).toBe(
284
+ `ndk.dir=/ndk\nsdk.dir=${path.join(home, "Android/sdk")}\n`
285
+ )
286
+
287
+ expect(ensureLocalProperties(project, "C:\\Android\\sdk")).toBe(true)
288
+ expect(fs.readFileSync(file, "utf8")).toBe("ndk.dir=/ndk\nsdk.dir=C:\\\\Android\\\\sdk\n")
289
+ })
290
+
291
+ it("names the config field to set in the SDK hint", () => {
292
+ expect(ANDROID_SDK_HINT).toContain("WEBVIEW_CONFIG.android.sdkPath")
293
+ })
294
+ })
295
+
296
+ describe("promptUser", () => {
297
+ it("returns the default without a terminal", async () => {
298
+ setTTY(undefined)
299
+ await expect(promptUser("Port the app is served on", { defaultValue: "3005" })).resolves.toBe("3005")
300
+ })
301
+
302
+ it("fails with ENOTTY and a hint when there is no terminal and no default", async () => {
303
+ setTTY(false)
304
+ const error: any = await promptUser("Android SDK path: ").catch((caught) => caught)
305
+ expect(error.message).toBe("Missing configuration, and no terminal to ask on: Android SDK path")
306
+ expect(error.code).toBe("ENOTTY")
307
+ expect(error.hint).toContain("config/config.json")
308
+ })
309
+
310
+ function fakeTerminal(answer: string) {
311
+ setTTY(true)
312
+ const asked: string[] = []
313
+ const close = vi.fn()
314
+ vi.spyOn(readline, "createInterface").mockReturnValue({
315
+ question: (text: string, callback: (answer: string) => void) => {
316
+ asked.push(text)
317
+ callback(answer)
318
+ },
319
+ close,
320
+ } as any)
321
+ const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
322
+ return { asked, close, write }
323
+ }
324
+
325
+ it("asks on the terminal with the default offered, and settles the answer in place", async () => {
326
+ const { asked, close, write } = fakeTerminal("")
327
+ await expect(promptUser("Android emulator (AVD) name", { defaultValue: "Pixel_7" })).resolves.toBe(
328
+ "Pixel_7"
329
+ )
330
+ expect(plain(asked[0])).toContain("? Android emulator (AVD) name (Pixel_7) ")
331
+ expect(close).toHaveBeenCalled()
332
+ expect(plain(write.mock.calls[0][0])).toContain("✓ Android emulator (AVD) name Pixel_7")
333
+ })
334
+
335
+ it("uses the typed answer, trimmed, and an empty string with no default", async () => {
336
+ fakeTerminal(" com.example.app ")
337
+ await expect(promptUser("iOS bundle ID")).resolves.toBe("com.example.app")
338
+
339
+ vi.restoreAllMocks()
340
+ const { asked } = fakeTerminal(" ")
341
+ await expect(promptUser("iOS bundle ID")).resolves.toBe("")
342
+ expect(plain(asked[0])).not.toContain("(")
343
+ })
344
+ })
345
+
346
+ describe("runInteractiveCommand", () => {
347
+ const node = process.execPath
348
+
349
+ it("resolves with the output and reports each line, flushing a trailing partial one", async () => {
350
+ const lines: string[] = []
351
+ const output = await runInteractiveCommand(
352
+ node,
353
+ [
354
+ "-e",
355
+ "process.stdout.write('one\\ntw'); setTimeout(() => { process.stdout.write('o\\nlast'); process.stderr.write('warn') }, 20)",
356
+ ],
357
+ {},
358
+ { onLine: (line: string) => lines.push(line) }
359
+ )
360
+ expect(output).toContain("one\ntwo\nlast")
361
+ expect(lines).toEqual(expect.arrayContaining(["one", "two", "last", "warn"]))
362
+ expect(lines).toHaveLength(4)
363
+ })
364
+
365
+ it("captures silently without onLine", async () => {
366
+ await expect(runInteractiveCommand(node, ["-e", "console.log('quiet')"])).resolves.toBe("quiet\n")
367
+ })
368
+
369
+ it("rejects with the exit code and the output that explains it", async () => {
370
+ const error: any = await runInteractiveCommand(node, [
371
+ "-e",
372
+ "console.error('error: cannot find symbol'); console.log('partial'); process.exitCode = 3",
373
+ ]).catch((caught) => caught)
374
+ expect(error.message).toBe("Command failed with exit code 3")
375
+ expect(error.code).toBe(3)
376
+ expect(error.output).toContain("error: cannot find symbol")
377
+ expect(error.output).toContain("partial")
378
+ })
379
+
380
+ it("rejects instead of hanging when the binary is missing", async () => {
381
+ const error: any = await runInteractiveCommand("catalyst-no-such-binary", []).catch(
382
+ (caught) => caught
383
+ )
384
+ expect(error.message).toBe("Command not found: catalyst-no-such-binary")
385
+ expect(error.cause.code).toBe("ENOENT")
386
+ })
387
+
388
+ it.skipIf(process.platform === "win32")("rejects when the binary cannot be started", async () => {
389
+ const file = path.join(tempDir(), "not-executable")
390
+ fs.writeFileSync(file, "#!/bin/sh\n", { mode: 0o644 })
391
+ const error: any = await runInteractiveCommand(file, []).catch((caught) => caught)
392
+ expect(error.message).toMatch(new RegExp(`^Failed to start ${file}: `))
393
+ expect(error.cause.code).toBe("EACCES")
394
+ })
395
+
396
+ it("answers each prompt once and keeps only a bounded tail of the output", async () => {
397
+ // Prints a prompt, reads the answer, echoes it, then floods well past
398
+ // the 1 MiB output cap and the 8 KiB prompt window.
399
+ const script = `
400
+ process.stdout.write("Accept? (y/N): ")
401
+ process.stdin.once("data", (answer) => {
402
+ process.stdout.write("got:" + String(answer).trim() + "\\n")
403
+ const chunk = "x".repeat(64 * 1024) + "\\n"
404
+ for (let i = 0; i < 20; i++) process.stdout.write(chunk)
405
+ process.stdout.write("END\\n")
406
+ process.stdin.destroy()
407
+ })
408
+ `
409
+ const output = await runInteractiveCommand(node, ["-e", script], { "(y/N)": "y" })
410
+ expect(output.length).toBeLessThanOrEqual(1024 * 1024)
411
+ expect(output.endsWith("END\n")).toBe(true)
412
+ expect(output).not.toContain("got:")
413
+ })
414
+
415
+ it("feeds sdkmanager's licence prompts a yes", async () => {
416
+ const script = `
417
+ process.stdout.write("Accept? (y/N): ")
418
+ process.stdin.once("data", (answer) => { console.log("answer=" + String(answer).trim()); process.stdin.destroy() })
419
+ `
420
+ await expect(runSdkManagerCommand(node, ["-e", script])).resolves.toContain("answer=y")
421
+ })
422
+ })
423
+
424
+ describe("validateAndCompleteConfig", () => {
425
+ function writeConfig(config: unknown) {
426
+ const file = path.join(tempDir(), "config.json")
427
+ fs.writeFileSync(file, typeof config === "string" ? config : JSON.stringify(config))
428
+ return file
429
+ }
430
+ const read = (file: string) => JSON.parse(fs.readFileSync(file, "utf8"))
431
+
432
+ it("returns a complete config untouched", async () => {
433
+ const android = { buildType: "release", sdkPath: "/sdk", emulatorName: "Pixel" }
434
+ const file = writeConfig({ WEBVIEW_CONFIG: { port: "3005", android } })
435
+ mockedExec.mockImplementation(() => "Pixel\n")
436
+ const before = fs.readFileSync(file, "utf8")
437
+
438
+ await expect(validateAndCompleteConfig("android", file)).resolves.toEqual({ port: "3005", android })
439
+ expect(fs.readFileSync(file, "utf8")).toBe(before)
440
+ })
441
+
442
+ it("fills what it can work out for Android and saves it without asking", async () => {
443
+ const sdk = fakeSdk()
444
+ const file = writeConfig({ NODE_SERVER_PORT: 3005, WEBVIEW_CONFIG: { android: { sdkPath: sdk } } })
445
+ mockedExec.mockImplementation(() => "Pixel_7_API_34\n")
446
+ setTTY(false)
447
+
448
+ const result = await validateAndCompleteConfig("android", file)
449
+
450
+ expect(result).toEqual({
451
+ port: "3005",
452
+ android: { sdkPath: sdk, buildType: "debug", emulatorName: "Pixel_7_API_34" },
453
+ })
454
+ expect(read(file).WEBVIEW_CONFIG).toEqual(result)
455
+ const saved = plain(vi.mocked(console.log).mock.calls.flat().join("\n"))
456
+ expect(saved).toContain("Saved port, buildType, emulatorName to config/config.json")
457
+ })
458
+
459
+ it("detects the SDK when none is configured", async () => {
460
+ const sdk = fakeSdk()
461
+ vi.stubEnv("ANDROID_HOME", sdk)
462
+ mockedExec.mockImplementation(() => {
463
+ throw new Error("no emulator")
464
+ })
465
+ const file = writeConfig({
466
+ WEBVIEW_CONFIG: { port: "3005", android: { buildType: "debug", emulatorName: "Pixel" } },
467
+ })
468
+ setTTY(false)
469
+ try {
470
+ const result = await validateAndCompleteConfig("android", file)
471
+ expect(result.android.sdkPath).toBe(sdk)
472
+ } finally {
473
+ vi.unstubAllEnvs()
474
+ }
475
+ })
476
+
477
+ it("fails with ENOTTY for an iOS field it cannot guess in CI", async () => {
478
+ const file = writeConfig({ WEBVIEW_CONFIG: { port: "3005", ios: { buildType: "debug" } } })
479
+ setTTY(false)
480
+ const error: any = await validateAndCompleteConfig("ios", file).catch((caught) => caught)
481
+ expect(error.code).toBe("ENOTTY")
482
+ expect(error.message).toContain("iOS bundle ID")
483
+ })
484
+
485
+ it("asks on the terminal for what it cannot guess", async () => {
486
+ const file = writeConfig({ WEBVIEW_CONFIG: { port: "3005", ios: { buildType: "debug" } } })
487
+ setTTY(true)
488
+ const answers = ["com.example.app", "iPhone 16 Pro"]
489
+ vi.spyOn(readline, "createInterface").mockImplementation(
490
+ () =>
491
+ ({
492
+ question: (_text: string, callback: (answer: string) => void) =>
493
+ callback(answers.shift()!),
494
+ close: () => {},
495
+ }) as any
496
+ )
497
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true)
498
+
499
+ await expect(validateAndCompleteConfig("ios", file)).resolves.toEqual({
500
+ port: "3005",
501
+ ios: { buildType: "debug", appBundleId: "com.example.app", simulatorName: "iPhone 16 Pro" },
502
+ })
503
+ expect(read(file).WEBVIEW_CONFIG.ios.simulatorName).toBe("iPhone 16 Pro")
504
+ })
505
+
506
+ it("starts from an empty config when the file is unreadable, then rejects an unknown platform", async () => {
507
+ const file = writeConfig("{ not json")
508
+ await expect(validateAndCompleteConfig("windows", file)).rejects.toThrow(
509
+ 'Invalid platform. Must be either "android" or "ios"'
510
+ )
511
+ expect(plain(vi.mocked(console.error).mock.calls.flat().join("\n"))).toContain(
512
+ "Error reading config file"
513
+ )
514
+ })
515
+
516
+ it("surfaces a config file it cannot save", async () => {
517
+ const file = writeConfig({
518
+ WEBVIEW_CONFIG: {
519
+ port: "3005",
520
+ ios: { buildType: "", appBundleId: "a.b", simulatorName: "iPhone" },
521
+ },
522
+ })
523
+ vi.spyOn(fs, "writeFileSync").mockImplementation(() => {
524
+ throw new Error("EROFS: read-only file system")
525
+ })
526
+
527
+ await expect(validateAndCompleteConfig("ios", file)).rejects.toThrow("EROFS")
528
+ expect(plain(vi.mocked(console.error).mock.calls.flat().join("\n"))).toContain(
529
+ "Error saving config file: EROFS"
530
+ )
531
+ })
532
+ })