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,434 @@
1
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "fs"
2
+ import { tmpdir } from "os"
3
+ import path from "path"
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest"
5
+
6
+ import { createRequire } from "module"
7
+ import type buildErrorsModule from "../../src/cli/buildErrors.js"
8
+ import type themeModule from "../../src/cli/theme.js"
9
+
10
+ // diagnostic.js turns a failure into the block a person reads: what broke,
11
+ // where, the code around it, and what to do. Colour depends on the
12
+ // environment, so every assertion is on the ANSI-stripped text.
13
+ //
14
+ // The CLI modules are CommonJS and require one another, so they are loaded
15
+ // with require here too: v8 coverage then sees a single copy of each file
16
+ // instead of an import-transformed one and a required one that merge badly.
17
+ // `any` because tsc reads the JSDoc as exact signatures (every option
18
+ // required, `error` always an Error) and these tests pass partial input.
19
+ const load = createRequire(import.meta.url)
20
+ const diagnostics: any = load("../../src/cli/diagnostic.js")
21
+ const buildErrors: typeof buildErrorsModule = load("../../src/cli/buildErrors.js")
22
+ const theme: typeof themeModule = load("../../src/cli/theme.js")
23
+ const { diagnostic, buildFailure, registryFailure, runtimeFailure, verboseOutput } = diagnostics
24
+ const { codeFrame, parseFrames, isAppFrame } = diagnostics
25
+ const plain = (text: string | null) => (text == null ? text : String(text).replace(theme.ANSI, ""))
26
+ const lines = (text: string) => plain(text).split("\n")
27
+
28
+ const SOURCE = [
29
+ "import React from 'react'",
30
+ "",
31
+ "export default function Home() {",
32
+ "\tconst data = useData()",
33
+ " return <div>{data.title}</div>",
34
+ "}",
35
+ "",
36
+ ].join("\n")
37
+
38
+ let app: string
39
+ let file: string
40
+
41
+ beforeAll(() => {
42
+ // realpath: on macOS tmpdir() is a /var symlink to /private/var, and the
43
+ // module compares paths textually.
44
+ app = realpathSync(mkdtempSync(path.join(tmpdir(), "catalyst-diagnostic-")))
45
+ mkdirSync(path.join(app, "src"))
46
+ file = path.join(app, "src", "Home.js")
47
+ writeFileSync(file, SOURCE)
48
+ })
49
+
50
+ afterAll(() => {
51
+ rmSync(app, { recursive: true, force: true })
52
+ })
53
+
54
+ describe("diagnostic", () => {
55
+ it("renders a message-only failure as a complete block", () => {
56
+ expect(lines(diagnostic({ message: "Port is not a number" }))).toEqual([
57
+ "",
58
+ " error",
59
+ " Port is not a number",
60
+ "",
61
+ ])
62
+ })
63
+
64
+ it("labels the code and scope, then lists each detail line", () => {
65
+ const out = lines(
66
+ diagnostic({
67
+ message: "Build does not match config",
68
+ code: "CONFIG-004",
69
+ scope: "preflight",
70
+ details: "PUBLIC_STATIC_ASSET_URL: a\nBUILD_OUTPUT_PATH: b",
71
+ })
72
+ )
73
+ expect(out.slice(1, 5)).toEqual([
74
+ " error CONFIG-004 preflight",
75
+ " Build does not match config",
76
+ " PUBLIC_STATIC_ASSET_URL: a",
77
+ " BUILD_OUTPUT_PATH: b",
78
+ ])
79
+ })
80
+
81
+ it("points at the file relative to the app with a code frame and caret", () => {
82
+ const out = lines(diagnostic({ message: "data is undefined", file, line: 4, column: 19, cwd: app }))
83
+ expect(out).toContain(` at ${path.join("src", "Home.js")}:4:19`)
84
+ // Two lines of context either side, the failing one marked, tabs expanded.
85
+ const frame = out.slice(out.indexOf("") + 1).filter((l) => l.includes("│"))
86
+ expect(frame).toEqual([
87
+ " 2 │ ",
88
+ " 3 │ export default function Home() {",
89
+ " > 4 │ const data = useData()",
90
+ " │ ^",
91
+ " 5 │ return <div>{data.title}</div>",
92
+ " 6 │ }",
93
+ ])
94
+ })
95
+
96
+ it("keeps paths outside the app absolute and omits a missing column", () => {
97
+ const out = lines(diagnostic({ message: "x", file, line: 1, cwd: path.join(app, "elsewhere-app") }))
98
+ expect(out).toContain(` at ${file}:1`)
99
+ expect(out.some((l) => l.includes("^"))).toBe(false)
100
+ })
101
+
102
+ it("names a file without a line and shows no frame", () => {
103
+ const out = lines(diagnostic({ message: "x", file, cwd: app }))
104
+ expect(out).toContain(` at ${path.join("src", "Home.js")}`)
105
+ expect(out.some((l) => l.includes("│"))).toBe(false)
106
+ })
107
+
108
+ it("wraps a long hint at 72 columns under a hanging indent", () => {
109
+ const hint =
110
+ "Set the Android SDK path in config/config.json under WEBVIEW_CONFIG.android.sdkPath, or export ANDROID_HOME before you run the build again."
111
+ const out = lines(diagnostic({ message: "SDK missing", hint }))
112
+ const start = out.findIndex((l) => l.startsWith(" hint "))
113
+ expect(out[start - 1]).toBe("")
114
+ const hintLines = out.slice(start, out.length - 1)
115
+ expect(hintLines.length).toBeGreaterThan(1)
116
+ for (const line of hintLines.slice(1)) expect(line.startsWith(" ")).toBe(true)
117
+ expect(hintLines.map((l) => l.trim().replace(/^hint\s+/, "")).join(" ")).toBe(hint)
118
+ for (const line of hintLines)
119
+ expect(line.replace(/^\s+(hint\s+)?/, "").length).toBeLessThanOrEqual(72)
120
+ })
121
+
122
+ it("puts docs straight under a hint, but leaves a gap under a code frame", () => {
123
+ const withHint = lines(diagnostic({ message: "x", hint: "Do y.", docs: "https://d" }))
124
+ expect(withHint.slice(-3)).toEqual([" hint Do y.", " docs https://d", ""])
125
+
126
+ const withFrame = lines(diagnostic({ message: "x", file, line: 6, docs: "https://d", cwd: app }))
127
+ expect(withFrame.slice(-4)).toEqual([" 7 │ ", "", " docs https://d", ""])
128
+
129
+ const bare = lines(diagnostic({ message: "x", docs: "https://d" }))
130
+ expect(bare).toEqual(["", " error", " x", " docs https://d", ""])
131
+ })
132
+ })
133
+
134
+ describe("codeFrame", () => {
135
+ it("returns null rather than throwing when it cannot show the source", () => {
136
+ expect(codeFrame(path.join(app, "missing.js"), 1, 1)).toBeNull()
137
+ expect(codeFrame(file, 0, 1)).toBeNull()
138
+ expect(codeFrame(file, 99, 1)).toBeNull()
139
+ })
140
+
141
+ it("clips context at the start of the file and skips a zero column caret", () => {
142
+ const frame = lines(codeFrame(file, 1, 0))
143
+ expect(frame).toEqual([
144
+ " > 1 │ import React from 'react'",
145
+ " 2 │ ",
146
+ " 3 │ export default function Home() {",
147
+ ])
148
+ })
149
+ })
150
+
151
+ describe("registryFailure", () => {
152
+ it("renders a CatalystError with its cause, action and docs", () => {
153
+ const error = Object.assign(new Error("Server failed to start"), {
154
+ code: "SERVER-001",
155
+ cause: new Error("listen EADDRINUSE"),
156
+ suggestedAction: "Free the port.",
157
+ docUrl: "https://catalyst.1mg.com/errors/server-001",
158
+ })
159
+ expect(lines(registryFailure(error, { scope: "serve" }))).toEqual([
160
+ "",
161
+ " error SERVER-001 serve",
162
+ " Server failed to start",
163
+ " listen EADDRINUSE",
164
+ "",
165
+ " hint Free the port.",
166
+ " docs https://catalyst.1mg.com/errors/server-001",
167
+ "",
168
+ ])
169
+ })
170
+
171
+ it("falls back to details, a string cause, and drops non-string codes", () => {
172
+ const withDetails = lines(
173
+ registryFailure({ message: "Bad config", code: 7, details: "PORT missing" })
174
+ )
175
+ expect(withDetails.slice(1, 4)).toEqual([" error", " Bad config", " PORT missing"])
176
+
177
+ const stringCause = lines(registryFailure({ message: "Failed", cause: "timeout" }))
178
+ expect(stringCause[3]).toBe(" timeout")
179
+ })
180
+
181
+ it("accepts anything thrown, not only errors", () => {
182
+ expect(lines(registryFailure("plain string failure"))[2]).toBe(" plain string failure")
183
+ })
184
+ })
185
+
186
+ describe("buildFailure", () => {
187
+ const kotlinError = () =>
188
+ `> Task :app:compileDebugKotlin FAILED\ne: file://${file}:4:19 Unresolved reference: useData\n`
189
+
190
+ it("shows each compiler error with its location and source", () => {
191
+ const error = Object.assign(new Error("Command failed with exit code 1"), { output: kotlinError() })
192
+ const out = lines(buildFailure({ error, stage: "Gradle build", cwd: app }))
193
+ expect(out[1]).toBe(" error Gradle build")
194
+ expect(out).toContain(" Unresolved reference: useData")
195
+ expect(out).toContain(` at ${path.join("src", "Home.js")}:4:19`)
196
+ expect(out).toContain(" > 4 │ const data = useData()")
197
+ expect(out.join("\n")).not.toContain("hint")
198
+ })
199
+
200
+ it("counts several errors, reads output from the cause, and mines a hint", () => {
201
+ const output = [
202
+ "ld: error: code signing failed for target",
203
+ `${file}:5:9: error: cannot find 'data' in scope`,
204
+ ].join("\n")
205
+ const error = Object.assign(new Error("wrapped"), {
206
+ code: "BUNDLE-000",
207
+ suggestedAction: "Read the error above.",
208
+ cause: { output },
209
+ })
210
+ const out = lines(buildFailure({ error, stage: "Xcode build", cwd: app }))
211
+ expect(out[1]).toBe(" 2 errors BUNDLE-000 Xcode build")
212
+ expect(out).toContain(" ld: error: code signing failed for target")
213
+ expect(out).toContain(" cannot find 'data' in scope")
214
+ // Wrapper codes (-000) only say "read the error above", so the hint
215
+ // mined from the first error wins over their suggestedAction.
216
+ expect(out.find((l) => l.startsWith(" hint"))).toMatch(/pick a signing team/)
217
+ })
218
+
219
+ it("prefers the registry's authored advice over a mined hint", () => {
220
+ const error = Object.assign(new Error("Build failed"), {
221
+ code: "ANDROID-012",
222
+ suggestedAction: "Accept the SDK licences with sdkmanager --licenses.",
223
+ output: "FAILURE: SDK location not found.",
224
+ })
225
+ const out = lines(buildFailure({ error, stage: "Gradle build", cwd: app }))
226
+ expect(out).toContain(" hint Accept the SDK licences with sdkmanager --licenses.")
227
+ })
228
+
229
+ it("mines a hint from the first error when no advice was authored", () => {
230
+ const error = { message: "x", output: "error: SDK location not found." }
231
+ const out = lines(buildFailure({ error, stage: "Gradle build", cwd: app }))
232
+ expect(out.find((l) => l.startsWith(" hint"))).toMatch(/WEBVIEW_CONFIG\.android\.sdkPath/)
233
+ })
234
+
235
+ it("notes that more errors may follow once the cap is reached", () => {
236
+ const output = Array.from(
237
+ { length: buildErrors.MAX_ERRORS + 2 },
238
+ (_, i) => `/nowhere/F${i}.swift:1:1: error: e${i}`
239
+ ).join("\n")
240
+ const out = lines(buildFailure({ error: { output }, stage: "Xcode build", cwd: app }))
241
+ expect(out[1]).toBe(` ${buildErrors.MAX_ERRORS} errors Xcode build`)
242
+ expect(out).toContain(" … more errors may follow; the full build output is above.")
243
+ })
244
+
245
+ it("names the stage instead of a bare exit code and shows the output tail", () => {
246
+ const error = Object.assign(new Error("Command failed with exit code 65"), {
247
+ output: "Compiling...\nSomething odd happened\nBUILD FAILED in 3s\n",
248
+ })
249
+ const out = lines(buildFailure({ error, stage: "Xcode build", cwd: app }))
250
+ expect(out[1]).toBe(" error")
251
+ expect(out[2]).toBe(" Xcode build failed")
252
+ expect(out).toContain(" Last output from the build:")
253
+ expect(out).toContain(" │ Compiling...")
254
+ expect(out).toContain(" │ Something odd happened")
255
+ expect(out.join("\n")).not.toContain("BUILD FAILED in 3s")
256
+ })
257
+
258
+ it("keeps a real message and its stage, with the error's own hint and docs", () => {
259
+ const error = {
260
+ message: "Simulator could not be launched",
261
+ code: "IOS-003",
262
+ hint: "Reboot the simulator.",
263
+ docUrl: "https://d/ios-003",
264
+ }
265
+ expect(lines(buildFailure({ error, stage: "Launch", cwd: app }))).toEqual([
266
+ "",
267
+ " error IOS-003 Launch",
268
+ " Simulator could not be launched",
269
+ "",
270
+ " hint Reboot the simulator.",
271
+ " docs https://d/ios-003",
272
+ "",
273
+ ])
274
+ })
275
+
276
+ it("labels with the stage's wrapper code unless the error carries a registry code", () => {
277
+ const spawnError = Object.assign(new Error("spawnSync plutil ENOENT"), { code: "ENOENT" })
278
+ expect(lines(buildFailure({ error: spawnError, stage: "iOS build", code: "IOS-000" }))[1]).toBe(
279
+ " error IOS-000 iOS build"
280
+ )
281
+ const owned = { message: "Simulator could not be launched", code: "IOS-003" }
282
+ expect(lines(buildFailure({ error: owned, stage: "Launch", code: "IOS-000" }))[1]).toBe(
283
+ " error IOS-003 Launch"
284
+ )
285
+ })
286
+
287
+ it("falls back to a known hint, and to a generic message without a stage", () => {
288
+ const known = lines(buildFailure({ error: new Error("No booted simulator found"), cwd: app }))
289
+ expect(known).toContain(
290
+ " hint Open Simulator and boot a device, or run catalyst setupEmulator:ios."
291
+ )
292
+
293
+ expect(lines(buildFailure({ error: new Error("Command failed with exit code 1") }))[2]).toBe(
294
+ " The build failed"
295
+ )
296
+ expect(lines(buildFailure({ error: undefined, stage: "Gradle" }))[2]).toBe(" The build failed")
297
+ })
298
+ })
299
+
300
+ describe("parseFrames", () => {
301
+ it("parses named and anonymous V8 frames and skips everything else", () => {
302
+ const stack = [
303
+ "TypeError: x is not a function",
304
+ " at render (/app/src/Home.js:4:19)",
305
+ " at file:///app/src/routes.js:10:2",
306
+ " at new Promise (<anonymous>)",
307
+ " at node:internal/process/task_queues:95:5",
308
+ ].join("\n")
309
+ expect(parseFrames(stack)).toEqual([
310
+ { fn: "render", file: "/app/src/Home.js", line: 4, column: 19 },
311
+ { fn: null, file: "/app/src/routes.js", line: 10, column: 2 },
312
+ { fn: null, file: "node:internal/process/task_queues", line: 95, column: 5 },
313
+ ])
314
+ expect(parseFrames(undefined)).toEqual([])
315
+ })
316
+ })
317
+
318
+ describe("isAppFrame", () => {
319
+ it("accepts only frames inside the app and outside node_modules", () => {
320
+ const at = (f: string) => ({ file: f, line: 1, column: 1 })
321
+ expect(isAppFrame(at(file), app)).toBe(true)
322
+ expect(isAppFrame(at(path.join(app, "node_modules", "react-dom", "index.js")), app)).toBe(false)
323
+ expect(isAppFrame(at("node:internal/modules/cjs/loader"), app)).toBe(false)
324
+ expect(isAppFrame(at(path.join(path.dirname(app), "other", "x.js")), app)).toBe(false)
325
+ expect(isAppFrame(at(app), app)).toBe(false)
326
+ expect(isAppFrame({ file: null }, app)).toBe(false)
327
+ })
328
+ })
329
+
330
+ describe("runtimeFailure", () => {
331
+ const thrown = () => {
332
+ const error = new TypeError("data is undefined")
333
+ error.stack = [
334
+ "TypeError: data is undefined",
335
+ ` at ${path.join(app, "node_modules", "react-dom", "server.js")}:100:10`,
336
+ ` at Home (${file}:4:19)`,
337
+ " at node:internal/process/task_queues:95:5",
338
+ ` at render (${file}:5:5)`,
339
+ ].join("\n")
340
+ return error
341
+ }
342
+
343
+ it("points at the first app frame, skipping library frames", () => {
344
+ const out = lines(
345
+ runtimeFailure({ error: thrown(), code: "RUNTIME-WEB-001", scope: "GET /", cwd: app })
346
+ )
347
+ expect(out[1]).toBe(" error RUNTIME-WEB-001 GET /")
348
+ expect(out[2]).toBe(" TypeError: data is undefined")
349
+ expect(out).toContain(` at ${path.join("src", "Home.js")}:4:19`)
350
+ expect(out.join("\n")).not.toContain("react-dom")
351
+ expect(out.join("\n")).not.toContain("stack")
352
+ })
353
+
354
+ it("lists the app's frames in verbose mode", () => {
355
+ const out = lines(runtimeFailure({ error: thrown(), mode: "verbose", cwd: app }))
356
+ const rel = path.join("src", "Home.js")
357
+ expect(out).toContain(" stack")
358
+ expect(out).toContain(` at Home (${rel}:4:19)`)
359
+ expect(out).toContain(` at render (${rel}:5:5)`)
360
+ })
361
+
362
+ it("prints the whole stack and environment in debug mode", () => {
363
+ const out = lines(
364
+ runtimeFailure({ error: thrown(), mode: "debug", env: { NODE_ENV: "development" }, cwd: app })
365
+ )
366
+ expect(out).toContain(" at node:internal/process/task_queues:95:5")
367
+ expect(out).toContain(` at ${path.join(app, "node_modules", "react-dom", "server.js")}:100:10`)
368
+ expect(out).toContain(" NODE_ENV development")
369
+ })
370
+
371
+ it("uses a Vite transform location and keeps only the first sentence", () => {
372
+ const error = Object.assign(
373
+ new Error(
374
+ `Failed to resolve import "@api/client" from "src/Home.js". Does the file exist? Check the alias configuration and try again.`
375
+ ),
376
+ { loc: { file, line: 1, column: 8 } }
377
+ )
378
+ const out = lines(runtimeFailure({ error, cwd: app }))
379
+ expect(out[2]).toBe(` Failed to resolve import "@api/client" from "src/Home.js".`)
380
+ expect(out).toContain(` at ${path.join("src", "Home.js")}:1:8`)
381
+ expect(out.find((l) => l.startsWith(" hint"))).toMatch(/alias @api is not defined/)
382
+ expect(out).toContain(" docs https://catalyst.1mg.com/errors/unresolved-import")
383
+ })
384
+
385
+ it("handles non-Error throws and lets explicit docs win", () => {
386
+ const out = lines(
387
+ runtimeFailure({ error: "boom", docs: "https://d/runtime", mode: "debug", cwd: app })
388
+ )
389
+ expect(out).toEqual(["", " error", " boom", " docs https://d/runtime", ""])
390
+
391
+ const objectThrow = lines(runtimeFailure({ error: { message: "", name: "AbortError" }, cwd: app }))
392
+ expect(objectThrow[2]).toBe(" AbortError: AbortError")
393
+ })
394
+ })
395
+
396
+ describe("verboseOutput", () => {
397
+ it("is empty when there is nothing to add", () => {
398
+ expect(verboseOutput({})).toBe("")
399
+ expect(verboseOutput({ output: " \n" })).toBe("")
400
+ })
401
+
402
+ it("lists the code, docs and every line the tool printed", () => {
403
+ const out = lines(
404
+ verboseOutput({ code: "BUNDLE-000", docs: "https://d", output: "line one\nline two\n\n" })
405
+ )
406
+ expect(out).toEqual([
407
+ "",
408
+ " BUNDLE-000 · https://d",
409
+ " Full output:",
410
+ " │ line one",
411
+ " │ line two",
412
+ "",
413
+ "",
414
+ ])
415
+ })
416
+
417
+ it("adds the stack and environment in debug mode", () => {
418
+ const error = new Error("x")
419
+ error.stack = "Error: x\n at build (/app/build.js:1:1)"
420
+ const out = lines(
421
+ verboseOutput({ code: "BUNDLE-000", mode: "debug", error, env: { PLATFORM: "android" } })
422
+ )
423
+ expect(out).toEqual([
424
+ "",
425
+ " BUNDLE-000",
426
+ "",
427
+ " at build (/app/build.js:1:1)",
428
+ "",
429
+ " PLATFORM android",
430
+ "",
431
+ "",
432
+ ])
433
+ })
434
+ })
@@ -0,0 +1,90 @@
1
+ "use strict"
2
+
3
+ const assert = require("node:assert/strict")
4
+ const { test } = require("node:test")
5
+
6
+ const { extractBuildErrors } = require("../../src/cli/buildErrors.js")
7
+ const { formatDevLine } = require("../../src/cli/devOutput.js")
8
+ const { registryFailure, runtimeFailure } = require("../../src/cli/diagnostic.js")
9
+ const { ANSI } = require("../../src/cli/theme.js")
10
+
11
+ const plain = (text) => (text == null ? text : String(text).replace(ANSI, ""))
12
+
13
+ // Copied from a real gradle run with no local.properties: not a compiler
14
+ // error, so the file:line patterns find nothing and the cause is only in
15
+ // gradle's own "What went wrong" block.
16
+ const GRADLE_SDK_MISSING = [
17
+ "> Task :app:generateWebViewConfig",
18
+ "BUILD SUCCESSFUL in 737ms",
19
+ "FAILURE: Build failed with an exception.",
20
+ "",
21
+ "* What went wrong:",
22
+ "Could not determine the dependencies of task ':app:compileDebugJavaWithJavac'.",
23
+ "> SDK location not found. Define a valid SDK location with an ANDROID_HOME environment variable.",
24
+ "",
25
+ "* Try:",
26
+ "> Run with --stacktrace option to get the stack trace.",
27
+ ].join("\n")
28
+
29
+ test("extracts the cause from gradle's What went wrong block", () => {
30
+ assert.deepEqual(extractBuildErrors(GRADLE_SDK_MISSING), [{ message: "SDK location not found." }])
31
+ })
32
+
33
+ test("leaves Vite output to the Vite patterns", () => {
34
+ assert.deepEqual(extractBuildErrors(GRADLE_SDK_MISSING, { toolchain: "vite" }), [])
35
+ })
36
+
37
+ test("hides Vite dependency bookkeeping by default and keeps it under --verbose", () => {
38
+ const line = "1:41:19 pm [vite] (client) Re-optimizing dependencies because lockfile has changed"
39
+ assert.equal(formatDevLine(line, { seenWarnings: new Set(), verbose: false }), null)
40
+ assert.match(plain(formatDevLine(line, { seenWarnings: new Set(), verbose: true })), /Re-optimizing/)
41
+ })
42
+
43
+ test("shows one reload line when both bundles report the same file", () => {
44
+ const state = { seenWarnings: new Set(), verbose: false }
45
+ const ssr = formatDevLine("2:00:01 pm [vite] (ssr) page reload src/js/Home.js", state)
46
+ const client = formatDevLine("2:00:01 pm [vite] (client) page reload src/js/Home.js", state)
47
+ assert.match(plain(ssr), /↻ src\/js\/Home\.js/)
48
+ assert.equal(client, null)
49
+ })
50
+
51
+ test("drops Node's typeless-package warning unless verbose", () => {
52
+ const line =
53
+ "(node:123) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///app/server/index.js is not specified"
54
+ assert.equal(formatDevLine(line, { seenWarnings: new Set(), verbose: false }), null)
55
+ })
56
+
57
+ test("points a runtime error at the app's own frame, not the library's", () => {
58
+ const cwd = "/app"
59
+ const error = new Error("boom")
60
+ error.stack = [
61
+ "Error: boom",
62
+ " at renderWithHooks (/app/node_modules/react-dom/cjs/server.js:10:5)",
63
+ " at Home (/app/src/js/Home.js:7:9)",
64
+ ].join("\n")
65
+
66
+ const out = plain(runtimeFailure({ error, code: "RUNTIME-WEB-001", scope: "GET /", cwd }))
67
+ assert.match(out, /error RUNTIME-WEB-001 {2}GET \//)
68
+ assert.match(out, /at src\/js\/Home\.js:7:9/)
69
+ assert.doesNotMatch(out, /react-dom/)
70
+ })
71
+
72
+ test("renders a registry error as one diagnostic: code, details, hint and docs", () => {
73
+ const error = Object.assign(
74
+ new Error("The build is stale: config/config.json changed since it was built"),
75
+ {
76
+ code: "PREFLIGHT-022",
77
+ details: "These values differ:\n PUBLIC_STATIC_ASSET_URL: built with a, now b",
78
+ suggestedAction: "Run `npm run build` again, then `npm run serve`.",
79
+ docUrl: "https://example.test/PREFLIGHT-022.md",
80
+ }
81
+ )
82
+
83
+ const out = plain(registryFailure(error, { scope: "server" }))
84
+ assert.match(out, /error PREFLIGHT-022 {2}server/)
85
+ assert.match(out, /^ {2}The build is stale/m)
86
+ assert.match(out, /^ {4}PUBLIC_STATIC_ASSET_URL: built with a, now b$/m)
87
+ assert.match(out, /hint {2}Run `npm run build` again/)
88
+ assert.match(out, /docs {2}https:\/\/example\.test\/PREFLIGHT-022\.md/)
89
+ assert.doesNotMatch(out, /Suggested action|Docs:/)
90
+ })