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,529 @@
1
+ import { spawnSync } from "child_process"
2
+ import { EventEmitter } from "events"
3
+ import fs from "fs"
4
+ import http from "http"
5
+ import net from "net"
6
+ import os from "os"
7
+ import path from "path"
8
+ import { afterEach, describe, expect, it } from "vitest"
9
+
10
+ import {
11
+ devServerPorts,
12
+ ensureDevServer,
13
+ getDeviceReachableHost,
14
+ getLocalIPAddress,
15
+ isProcessAlive,
16
+ isServerAnswering,
17
+ migrateLanAddresses,
18
+ planLanAddressMigration,
19
+ readHmrPort,
20
+ readLocalHostName,
21
+ startDevServerProcess,
22
+ } from "../../src/native/setupServer.js"
23
+ import { readLocalState, writeLocalState } from "../../src/native/localState.js"
24
+
25
+ const temps: string[] = []
26
+ function tempDir() {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-setup-server-"))
28
+ temps.push(dir)
29
+ return dir
30
+ }
31
+ afterEach(() => {
32
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
33
+ })
34
+
35
+ const v4 = (address: string, internal = false) => ({ family: "IPv4", address, internal })
36
+
37
+ describe("getLocalIPAddress", () => {
38
+ it("prefers en0 and Wi-Fi/Ethernet over tunnels and bridges", () => {
39
+ expect(getLocalIPAddress({ utun3: [v4("100.64.0.2")], en0: [v4("192.168.1.9")] } as any)).toBe(
40
+ "192.168.1.9"
41
+ )
42
+ expect(getLocalIPAddress({ docker0: [v4("172.17.0.1")], wlp2s0: [v4("10.0.0.7")] } as any)).toBe(
43
+ "10.0.0.7"
44
+ )
45
+ })
46
+
47
+ it("is null with only loopback", () => {
48
+ expect(getLocalIPAddress({ lo: [v4("127.0.0.1", true)] } as any)).toBeNull()
49
+ })
50
+ })
51
+
52
+ describe("getDeviceReachableHost", () => {
53
+ it("uses the Bonjour name first", () => {
54
+ expect(
55
+ getDeviceReachableHost({ localHostName: () => "Janes-MacBook-Pro\n", interfaces: {} as any })
56
+ ).toEqual({
57
+ host: "Janes-MacBook-Pro.local",
58
+ source: "bonjour",
59
+ })
60
+ })
61
+
62
+ it("falls back to the LAN IP, then null", () => {
63
+ const fails = () => {
64
+ throw new Error("no scutil")
65
+ }
66
+ expect(
67
+ getDeviceReachableHost({ localHostName: fails, interfaces: { en0: [v4("192.168.1.9")] } as any })
68
+ ).toEqual({
69
+ host: "192.168.1.9",
70
+ source: "lan",
71
+ })
72
+ expect(getDeviceReachableHost({ localHostName: () => "", interfaces: {} as any })).toBeNull()
73
+ })
74
+ })
75
+
76
+ describe("readLocalHostName", () => {
77
+ it("asks scutil on macOS only", () => {
78
+ const calls: any[] = []
79
+ const exec = ((...args: any[]) => {
80
+ calls.push(args)
81
+ return "Janes-Mac\n"
82
+ }) as any
83
+ expect(readLocalHostName({ platform: "linux", exec })).toBe("")
84
+ expect(calls).toHaveLength(0)
85
+ expect(readLocalHostName({ platform: "darwin", exec })).toBe("Janes-Mac\n")
86
+ expect(calls[0][0]).toBe("scutil")
87
+ expect(calls[0][1]).toEqual(["--get", "LocalHostName"])
88
+ expect(calls[0][2]).toMatchObject({ encoding: "utf8", timeout: 5000 })
89
+ })
90
+ })
91
+
92
+ describe("devServerPorts", () => {
93
+ it("lists the server port, the HMR port and every localhost port in config", () => {
94
+ const config = {
95
+ NODE_SERVER_PORT: 3005,
96
+ API_URL: "http://localhost:8080/api",
97
+ PUBLIC_STATIC_ASSET_URL: "http://localhost:3005",
98
+ WEBVIEW_CONFIG: { apiBaseUrl: "http://127.0.0.1:9000" },
99
+ OTHER: ["https://example.com:8443"],
100
+ }
101
+ expect(devServerPorts(config)).toEqual([3005, 8080, 9000, 24678])
102
+ expect(devServerPorts({}, { hmrPort: 5000 })).toEqual([3005, 5000])
103
+ })
104
+
105
+ it("reads the HMR port from buildConfig.js", async () => {
106
+ const cwd = tempDir()
107
+ expect(await readHmrPort({ cwd })).toBe(24678)
108
+ fs.writeFileSync(
109
+ path.join(cwd, "buildConfig.js"),
110
+ "module.exports = { devServer: { hmr: { port: 24999 } } }\n"
111
+ )
112
+ expect(await readHmrPort({ cwd })).toBe(24999)
113
+ })
114
+
115
+ it("falls back to the default HMR port for a buildConfig.js that does not load or names a bad port", async () => {
116
+ const broken = tempDir()
117
+ fs.writeFileSync(path.join(broken, "buildConfig.js"), "module.exports = {\n")
118
+ expect(await readHmrPort({ cwd: broken })).toBe(24678)
119
+ const outOfRange = tempDir()
120
+ fs.writeFileSync(
121
+ path.join(outOfRange, "webpackConfig.js"),
122
+ "module.exports = { devServer: { hmr: { port: 70000 } } }\n"
123
+ )
124
+ expect(await readHmrPort({ cwd: outOfRange })).toBe(24678)
125
+ })
126
+ })
127
+
128
+ describe("startDevServerProcess", () => {
129
+ it("starts catalyst start detached, logging to an owner-only file under .catalyst/", () => {
130
+ const cwd = tempDir()
131
+ let spawned: any
132
+ let unrefs = 0
133
+ const child = Object.assign(new EventEmitter(), {
134
+ pid: 4242,
135
+ unref: () => void unrefs++,
136
+ })
137
+ const { child: returned, logFile } = startDevServerProcess({
138
+ cwd,
139
+ port: 3005,
140
+ overrides: { NODE_SERVER_HOSTNAME: "0.0.0.0", PORT: 3005 },
141
+ spawnProcess: ((command: string, args: string[], options: any) => {
142
+ spawned = { command, args, options }
143
+ return child
144
+ }) as any,
145
+ })
146
+ expect(returned).toBe(child)
147
+ expect(logFile).toBe(path.join(cwd, ".catalyst", "dev-server-3005.log"))
148
+ expect(logFile.startsWith(os.tmpdir() + path.sep + "catalyst-dev-server")).toBe(false)
149
+ expect(fs.statSync(logFile).mode & 0o777).toBe(0o600)
150
+ expect(fs.readFileSync(path.join(cwd, ".catalyst", ".gitignore"), "utf8")).toBe("*\n")
151
+ expect(spawned.command).toBe(process.execPath)
152
+ expect(path.basename(spawned.args[0])).toBe("catalyst.js")
153
+ expect(spawned.args.slice(1)).toEqual(["start", "NODE_SERVER_HOSTNAME=0.0.0.0", "PORT=3005"])
154
+ expect(spawned.options).toMatchObject({ cwd, detached: true })
155
+ expect(spawned.options.env.PWD).toBe(cwd)
156
+ expect(spawned.options.stdio[0]).toBe("ignore")
157
+ expect(unrefs).toBe(1)
158
+ // A spawn failure after the fact is swallowed, not an unhandled error.
159
+ expect(() => child.emit("error", new Error("ENOENT"))).not.toThrow()
160
+
161
+ // Without a port the file says so.
162
+ const second = startDevServerProcess({
163
+ cwd,
164
+ spawnProcess: (() => Object.assign(new EventEmitter(), { unref() {} })) as any,
165
+ })
166
+ expect(path.basename(second.logFile)).toBe("dev-server-default.log")
167
+ })
168
+ })
169
+
170
+ describe("isProcessAlive", () => {
171
+ it("is true for a running process and false once it has exited", () => {
172
+ expect(isProcessAlive(process.pid)).toBe(true)
173
+ const done = spawnSync(process.execPath, ["-e", "process.stdout.write(String(process.pid))"], {
174
+ encoding: "utf8",
175
+ })
176
+ expect(isProcessAlive(Number(done.stdout))).toBe(false)
177
+ })
178
+ })
179
+
180
+ describe("ensureDevServer", () => {
181
+ const config = { NODE_SERVER_PORT: 3005 }
182
+
183
+ it("does nothing when the server answers", async () => {
184
+ let started = false
185
+ const result = await ensureDevServer({
186
+ config,
187
+ probe: async () => true,
188
+ start: (() => {
189
+ started = true
190
+ }) as any,
191
+ })
192
+ expect(result).toEqual({ started: false, url: "http://localhost:3005", port: 3005 })
193
+ expect(started).toBe(false)
194
+ })
195
+
196
+ it("starts catalyst start and waits until it answers", async () => {
197
+ let calls = 0
198
+ let startedWith: any
199
+ const result = await ensureDevServer({
200
+ config,
201
+ intervalMs: 1,
202
+ probe: async () => ++calls > 3,
203
+ start: ((options: any) => {
204
+ startedWith = options
205
+ return { child: null, logFile: "/tmp/x.log" }
206
+ }) as any,
207
+ })
208
+ expect(result).toMatchObject({ started: true, url: "http://localhost:3005", logFile: "/tmp/x.log" })
209
+ expect(startedWith.overrides).toEqual({})
210
+ })
211
+
212
+ it("binds all interfaces and serves assets on a device host", async () => {
213
+ let startedWith: any
214
+ let probes = 0
215
+ await ensureDevServer({
216
+ host: "jane-mac.local",
217
+ config,
218
+ intervalMs: 1,
219
+ probe: async ({ host }: any) => host === "jane-mac.local" && ++probes > 1,
220
+ start: ((options: any) => {
221
+ startedWith = options
222
+ return { child: null, logFile: "/tmp/x.log" }
223
+ }) as any,
224
+ })
225
+ expect(startedWith.overrides).toEqual({
226
+ NODE_SERVER_HOSTNAME: "0.0.0.0",
227
+ PUBLIC_STATIC_ASSET_URL: "http://jane-mac.local:3005",
228
+ // Vite 6 answers 403 to a .local Host header unless it is allowed.
229
+ CATALYST_DEV_ALLOWED_HOSTS: "jane-mac.local",
230
+ })
231
+ })
232
+
233
+ it("does not add an allowed host for a LAN IP (Vite accepts IPs)", async () => {
234
+ let startedWith: any
235
+ let probes = 0
236
+ await ensureDevServer({
237
+ host: "192.168.1.20",
238
+ config,
239
+ intervalMs: 1,
240
+ probe: async ({ host }: any) => host === "192.168.1.20" && ++probes > 1,
241
+ start: ((options: any) => {
242
+ startedWith = options
243
+ return { child: null, logFile: "/tmp/x.log" }
244
+ }) as any,
245
+ })
246
+ expect(startedWith.overrides).toEqual({
247
+ NODE_SERVER_HOSTNAME: "0.0.0.0",
248
+ PUBLIC_STATIC_ASSET_URL: "http://192.168.1.20:3005",
249
+ })
250
+ })
251
+
252
+ it("refuses a server that only answers on localhost when a device needs it (NATIVE-006)", async () => {
253
+ const error = await ensureDevServer({
254
+ host: "jane-mac.local",
255
+ config,
256
+ probe: async ({ host }: any) => host === "localhost",
257
+ start: (() => {
258
+ throw new Error("must not start")
259
+ }) as any,
260
+ }).catch((e) => e)
261
+ expect(error.code).toBe("NATIVE-006")
262
+ })
263
+
264
+ it("records the host a started server is for", async () => {
265
+ const cwd = tempDir()
266
+ let probes = 0
267
+ await ensureDevServer({
268
+ host: "jane-mac.local",
269
+ cwd,
270
+ config,
271
+ intervalMs: 1,
272
+ probe: async ({ host }: any) => host === "jane-mac.local" && ++probes > 1,
273
+ start: (() => ({ child: { pid: 4242, on: () => {} }, logFile: "/tmp/x.log" })) as any,
274
+ })
275
+ expect(readLocalState({ cwd }).devServer).toEqual({ host: "jane-mac.local", port: 3005, pid: 4242 })
276
+ })
277
+
278
+ it("refuses to reuse a server it started for another target (NATIVE-006)", async () => {
279
+ const cwd = tempDir()
280
+ writeLocalState({ devServer: { host: "jane-mac.local", port: 3005, pid: 4242 } }, { cwd })
281
+ const run = (host: string, alive: boolean) =>
282
+ ensureDevServer({
283
+ host,
284
+ cwd,
285
+ config,
286
+ probe: async () => true,
287
+ isAlive: () => alive,
288
+ start: (() => {
289
+ throw new Error("must not start")
290
+ }) as any,
291
+ })
292
+
293
+ // The simulator after an iPhone build: the running server serves
294
+ // assets on jane-mac.local, not localhost.
295
+ const error = await run("localhost", true).catch((e) => e)
296
+ expect(error.code).toBe("NATIVE-006")
297
+ expect(error.message).toContain("started for jane-mac.local")
298
+ expect(error.hint).toContain("kill 4242")
299
+
300
+ // The same target reuses it, and a recorded process that is gone is ignored.
301
+ expect(await run("jane-mac.local", true)).toMatchObject({ started: false })
302
+ expect(await run("localhost", false)).toMatchObject({ started: false })
303
+ })
304
+
305
+ it("reuses a server the user started on localhost for a localhost target", async () => {
306
+ const cwd = tempDir()
307
+ const result = await ensureDevServer({
308
+ cwd,
309
+ config,
310
+ probe: async () => true,
311
+ isAlive: () => true,
312
+ start: (() => {
313
+ throw new Error("must not start")
314
+ }) as any,
315
+ })
316
+ expect(result).toEqual({ started: false, url: "http://localhost:3005", port: 3005 })
317
+ })
318
+
319
+ it("fails with NATIVE-006 when catalyst start exits before the server answers", async () => {
320
+ const exitWith = (code: number | null) => {
321
+ const child = new EventEmitter()
322
+ setTimeout(() => child.emit("exit", code), 5)
323
+ return child
324
+ }
325
+ for (const [code, shown] of [
326
+ [1, "exited (1)"],
327
+ [null, "exited (signal)"],
328
+ ] as const) {
329
+ const error = await ensureDevServer({
330
+ config,
331
+ intervalMs: 2,
332
+ probe: async () => false,
333
+ start: (() => ({
334
+ child: exitWith(code),
335
+ logFile: "/app/.catalyst/dev-server-3005.log",
336
+ })) as any,
337
+ }).catch((e) => e)
338
+ expect(error.code).toBe("NATIVE-006")
339
+ expect(error.message).toContain(shown)
340
+ expect(error.message).toContain("/app/.catalyst/dev-server-3005.log")
341
+ }
342
+ })
343
+
344
+ it("times out with NATIVE-006", async () => {
345
+ const error = await ensureDevServer({
346
+ config,
347
+ timeoutMs: 20,
348
+ intervalMs: 5,
349
+ probe: async () => false,
350
+ start: (() => ({ child: null, logFile: "/tmp/x.log" })) as any,
351
+ }).catch((e) => e)
352
+ expect(error.code).toBe("NATIVE-006")
353
+ expect(error.message).toContain("/tmp/x.log")
354
+ })
355
+
356
+ it("probes a real HTTP server", async () => {
357
+ const server = http.createServer((_req, res) => {
358
+ res.statusCode = 500
359
+ res.end()
360
+ })
361
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
362
+ const { port } = server.address() as any
363
+ try {
364
+ expect(await isServerAnswering({ host: "127.0.0.1", port })).toBe(true)
365
+ } finally {
366
+ await new Promise((resolve) => server.close(resolve))
367
+ }
368
+ expect(await isServerAnswering({ host: "127.0.0.1", port, timeoutMs: 200 })).toBe(false)
369
+ })
370
+
371
+ it("gives up on a server that accepts but never answers", async () => {
372
+ const sockets: net.Socket[] = []
373
+ const server = net.createServer((socket) => void sockets.push(socket))
374
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
375
+ const { port } = server.address() as any
376
+ try {
377
+ expect(await isServerAnswering({ host: "127.0.0.1", port, timeoutMs: 50 })).toBe(false)
378
+ } finally {
379
+ for (const socket of sockets) socket.destroy()
380
+ await new Promise((resolve) => server.close(resolve))
381
+ }
382
+ })
383
+ })
384
+
385
+ describe("LAN address migration", () => {
386
+ // This machine is 192.168.1.20; LOCAL_IP still holds the address an
387
+ // earlier setup wrote before DHCP moved the machine.
388
+ const HERE = new Set(["127.0.0.1", "::1", "192.168.1.20", "jane-mac"])
389
+ const saved = {
390
+ NODE_SERVER_HOSTNAME: "192.168.1.20",
391
+ NODE_SERVER_PORT: 3005,
392
+ PUBLIC_STATIC_ASSET_URL: "http://192.168.1.20:3005",
393
+ API_URL: "https://api.acme.com",
394
+ CDN: "http://8.8.8.8/x",
395
+ OLD_API: "http://10.0.0.4:8080/api",
396
+ DB_HOST: "172.18.0.5",
397
+ BIND: "0.0.0.0",
398
+ LOOPBACK: "http://127.0.0.1:9000",
399
+ WEBVIEW_CONFIG: {
400
+ LOCAL_IP: "10.0.0.4",
401
+ port: "3005",
402
+ accessControl: { allowedUrls: ["http://192.168.1.20:3005/*", "https://acme.com/*"] },
403
+ },
404
+ }
405
+
406
+ it("puts localhost back for this machine's addresses and the saved LOCAL_IP only", () => {
407
+ const { config, changes } = planLanAddressMigration(saved, { localHosts: HERE })
408
+ expect(config).toMatchObject({
409
+ NODE_SERVER_HOSTNAME: "localhost",
410
+ PUBLIC_STATIC_ASSET_URL: "http://localhost:3005",
411
+ API_URL: "https://api.acme.com",
412
+ CDN: "http://8.8.8.8/x",
413
+ OLD_API: "http://localhost:8080/api",
414
+ WEBVIEW_CONFIG: {
415
+ LOCAL_IP: "localhost",
416
+ accessControl: { allowedUrls: ["http://localhost:3005/*", "https://acme.com/*"] },
417
+ },
418
+ })
419
+ expect(changes.map((change) => change.path)).toEqual([
420
+ "NODE_SERVER_HOSTNAME",
421
+ "PUBLIC_STATIC_ASSET_URL",
422
+ "OLD_API",
423
+ "WEBVIEW_CONFIG.LOCAL_IP",
424
+ "WEBVIEW_CONFIG.accessControl.allowedUrls[0]",
425
+ ])
426
+ expect(saved.NODE_SERVER_HOSTNAME).toBe("192.168.1.20")
427
+ })
428
+
429
+ it("leaves a Docker bind address, a container IP and loopback alone", () => {
430
+ const docker = {
431
+ NODE_SERVER_HOSTNAME: "0.0.0.0",
432
+ DB_HOST: "172.18.0.5",
433
+ REDIS_URL: "http://172.18.0.6:6379",
434
+ LOOPBACK: "http://127.0.0.1:9000",
435
+ WEBVIEW_CONFIG: { LOCAL_IP: "localhost" },
436
+ }
437
+ expect(planLanAddressMigration(docker, { localHosts: HERE }).changes).toEqual([])
438
+ // Even when 0.0.0.0 or a loopback address is reported as this machine's.
439
+ const odd = new Set(["0.0.0.0", "127.0.0.1"])
440
+ expect(planLanAddressMigration(docker, { localHosts: odd }).changes).toEqual([])
441
+ // And a LOCAL_IP of 0.0.0.0 or 127.x is not a saved LAN address.
442
+ const bound = { BIND: "0.0.0.0", WEBVIEW_CONFIG: { LOCAL_IP: "0.0.0.0" } }
443
+ expect(planLanAddressMigration(bound, { localHosts: new Set() }).changes).toEqual([])
444
+ })
445
+
446
+ it("leaves WEBVIEW_CONFIG.url alone, and everything when allowPrivateHost is set", () => {
447
+ const withUrl = { WEBVIEW_CONFIG: { url: "http://10.0.0.4:3005", LOCAL_IP: "10.0.0.4" } }
448
+ expect(
449
+ planLanAddressMigration(withUrl, { localHosts: new Set() }).changes.map((c: any) => c.path)
450
+ ).toEqual(["WEBVIEW_CONFIG.LOCAL_IP"])
451
+ const internal = { ...saved, WEBVIEW_CONFIG: { ...saved.WEBVIEW_CONFIG, allowPrivateHost: true } }
452
+ expect(planLanAddressMigration(internal, { localHosts: HERE }).changes).toEqual([])
453
+ })
454
+
455
+ function appWith(config: object) {
456
+ const cwd = tempDir()
457
+ const file = path.join(cwd, "config", "config.json")
458
+ fs.mkdirSync(path.dirname(file))
459
+ fs.writeFileSync(file, JSON.stringify(config, null, 4) + "\n")
460
+ return { cwd, file }
461
+ }
462
+
463
+ it("rewrites config.json once, keeping its indentation, and says what changed", () => {
464
+ const { cwd, file } = appWith(saved)
465
+ const logs: string[] = []
466
+ const first = migrateLanAddresses({
467
+ cwd,
468
+ env: {},
469
+ localHosts: HERE,
470
+ log: (message: string) => logs.push(message),
471
+ })
472
+ expect(first.ran).toBe(true)
473
+ expect(first.changes).toHaveLength(5)
474
+ const text = fs.readFileSync(file, "utf8")
475
+ expect(text).toMatch(/^\{\n {4}"NODE_SERVER_HOSTNAME": "localhost"/)
476
+ expect(text.endsWith("}\n")).toBe(true)
477
+ expect(JSON.parse(text).DB_HOST).toBe("172.18.0.5")
478
+ expect(logs.join("\n")).toContain("NODE_SERVER_HOSTNAME: 192.168.1.20 -> localhost")
479
+ expect(readLocalState({ cwd }).migrations.lanAddresses).toBeTruthy()
480
+
481
+ // A private address added later is the user's own: left alone.
482
+ fs.writeFileSync(file, JSON.stringify({ API_URL: "http://192.168.1.20:8080" }, null, 4))
483
+ expect(migrateLanAddresses({ cwd, env: {}, localHosts: HERE })).toEqual({ ran: false, changes: [] })
484
+ expect(JSON.parse(fs.readFileSync(file, "utf8")).API_URL).toBe("http://192.168.1.20:8080")
485
+ })
486
+
487
+ it("never runs for a release or in CI", () => {
488
+ const releaseConfig = {
489
+ ...saved,
490
+ WEBVIEW_CONFIG: { ...saved.WEBVIEW_CONFIG, android: { buildType: "release" } },
491
+ }
492
+ const cases: Array<[object, Record<string, unknown>]> = [
493
+ [saved, { release: true, env: {} }],
494
+ [saved, { env: { CI: "true" } }],
495
+ [releaseConfig, { platform: "android", env: {} }],
496
+ ]
497
+ for (const [config, options] of cases) {
498
+ const { cwd, file } = appWith(config)
499
+ const before = fs.readFileSync(file, "utf8")
500
+ expect(migrateLanAddresses({ cwd, localHosts: HERE, ...options })).toEqual({
501
+ ran: false,
502
+ changes: [],
503
+ })
504
+ expect(fs.readFileSync(file, "utf8")).toBe(before)
505
+ // Not recorded either: the next debug build on this machine still runs it.
506
+ expect(readLocalState({ cwd }).migrations).toBeUndefined()
507
+ }
508
+ })
509
+
510
+ it("never throws on a broken config", () => {
511
+ const cwd = tempDir()
512
+ const logs: string[] = []
513
+ expect(migrateLanAddresses({ cwd, env: {}, log: (message: string) => logs.push(message) })).toEqual({
514
+ ran: false,
515
+ changes: [],
516
+ })
517
+ expect(logs[0]).toContain("Could not check")
518
+ })
519
+
520
+ it("leaves an unchanged config untouched", () => {
521
+ const cwd = tempDir()
522
+ const file = path.join(cwd, "config", "config.json")
523
+ fs.mkdirSync(path.dirname(file))
524
+ const text = '{"NODE_SERVER_HOSTNAME":"localhost"}'
525
+ fs.writeFileSync(file, text)
526
+ expect(migrateLanAddresses({ cwd, env: {} }).changes).toEqual([])
527
+ expect(fs.readFileSync(file, "utf8")).toBe(text)
528
+ })
529
+ })