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.
- package/bin/catalyst.js +105 -1
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +50 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +61 -94
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +335 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +314 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +115 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +65 -9
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +19 -1
- package/dist/vite/vite.config.js +96 -19
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +20 -14
- package/dist/web-router/components/MetaTag.test.jsx +108 -32
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +124 -0
- package/package.json +4 -7
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/preflight.test.ts +100 -2
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/manifestCache.test.ts +13 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/vitest.config.ts +4 -0
- package/dist/scripts/checkVersion.js +0 -3
- package/dist/scripts/cleanCache.js +0 -30
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events"
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { createRequire } from "node:module"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { PassThrough } from "node:stream"
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
8
|
+
|
|
9
|
+
import type themeModule from "../../src/cli/theme.js"
|
|
10
|
+
|
|
11
|
+
// Required, the way build.js itself loads it, so v8 coverage sees one copy.
|
|
12
|
+
const theme: typeof themeModule = createRequire(import.meta.url)("../../src/cli/theme.js")
|
|
13
|
+
|
|
14
|
+
// build.js runs on import, so each test re-imports it against a throwaway app
|
|
15
|
+
// with the preflight stubbed and spawn faked: every step "runs" by printing
|
|
16
|
+
// the given output and exiting with the given code.
|
|
17
|
+
vi.mock("../../src/scripts/preflight.js", () => ({
|
|
18
|
+
runStaticPreflightOrExit: () => {},
|
|
19
|
+
writeBuildInfo: () => {},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// `spawnError` makes the step fail to start at all; `run` stands in for what
|
|
23
|
+
// the step writes to disk.
|
|
24
|
+
type Step = { output: string; code: number; spawnError?: Error; run?: () => void }
|
|
25
|
+
const steps: Record<"server" | "client" | "manifest", Step> = {
|
|
26
|
+
server: { output: "", code: 0 },
|
|
27
|
+
client: { output: "", code: 0 },
|
|
28
|
+
manifest: { output: "", code: 0 },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
vi.mock("child_process", async (importOriginal) => {
|
|
32
|
+
const actual = await importOriginal<typeof import("child_process")>()
|
|
33
|
+
const spawn = (_cmd: string, args: string[]) => {
|
|
34
|
+
const name = args.includes("--ssr")
|
|
35
|
+
? "server"
|
|
36
|
+
: args.some((arg) => arg.includes("generateOfflineManifest"))
|
|
37
|
+
? "manifest"
|
|
38
|
+
: "client"
|
|
39
|
+
const child = Object.assign(new EventEmitter(), {
|
|
40
|
+
stdout: new PassThrough(),
|
|
41
|
+
stderr: new PassThrough(),
|
|
42
|
+
})
|
|
43
|
+
const step = steps[name]
|
|
44
|
+
setImmediate(() => {
|
|
45
|
+
if (step.spawnError) return child.emit("error", step.spawnError)
|
|
46
|
+
step.run?.()
|
|
47
|
+
child.stderr.write(step.output)
|
|
48
|
+
setImmediate(() => child.emit("close", step.code))
|
|
49
|
+
})
|
|
50
|
+
return child
|
|
51
|
+
}
|
|
52
|
+
return { ...actual, default: { ...actual, spawn }, spawn }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const plain = (text: string) => text.replace(theme.ANSI, "")
|
|
56
|
+
|
|
57
|
+
let appDir: string
|
|
58
|
+
let stderr: string
|
|
59
|
+
let stdout: string
|
|
60
|
+
let exitCode: Promise<number>
|
|
61
|
+
const originalArgv = process.argv
|
|
62
|
+
const originalPwd = process.env.PWD
|
|
63
|
+
|
|
64
|
+
async function runBuild(argv: string[] = []) {
|
|
65
|
+
process.argv = [originalArgv[0], "build.js", ...argv]
|
|
66
|
+
let settle: (code: number) => void
|
|
67
|
+
exitCode = new Promise((resolve) => (settle = resolve))
|
|
68
|
+
let exited = false
|
|
69
|
+
// The first exit is the one under test; throwing stops build() there, and
|
|
70
|
+
// its top-level catch then calls exit again, which is ignored.
|
|
71
|
+
vi.spyOn(process, "exit").mockImplementation(((code: number) => {
|
|
72
|
+
if (exited) return undefined as never
|
|
73
|
+
exited = true
|
|
74
|
+
settle(code)
|
|
75
|
+
throw new Error("exit")
|
|
76
|
+
}) as never)
|
|
77
|
+
vi.resetModules()
|
|
78
|
+
await import("../../src/scripts/build.js")
|
|
79
|
+
return exitCode
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
appDir = mkdtempSync(path.join(tmpdir(), "catalyst-build-"))
|
|
84
|
+
mkdirSync(path.join(appDir, "config"))
|
|
85
|
+
writeFileSync(path.join(appDir, "config", "config.json"), JSON.stringify({ BUILD_OUTPUT_PATH: "build" }))
|
|
86
|
+
writeFileSync(path.join(appDir, "package.json"), JSON.stringify({ name: "fixture-app" }))
|
|
87
|
+
process.env.PWD = appDir
|
|
88
|
+
stderr = ""
|
|
89
|
+
vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string) => {
|
|
90
|
+
stderr += String(chunk)
|
|
91
|
+
return true
|
|
92
|
+
}) as never)
|
|
93
|
+
stdout = ""
|
|
94
|
+
// Spinner lines go to stdout.write, the rest through console.log.
|
|
95
|
+
vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string) => {
|
|
96
|
+
stdout += String(chunk)
|
|
97
|
+
return true
|
|
98
|
+
}) as never)
|
|
99
|
+
vi.spyOn(console, "log").mockImplementation((line: string) => {
|
|
100
|
+
stdout += `${String(line)}\n`
|
|
101
|
+
})
|
|
102
|
+
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
103
|
+
for (const name of Object.keys(steps) as (keyof typeof steps)[]) steps[name] = { output: "", code: 0 }
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.restoreAllMocks()
|
|
108
|
+
process.argv = originalArgv
|
|
109
|
+
process.env.PWD = originalPwd
|
|
110
|
+
rmSync(appDir, { recursive: true, force: true })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("catalyst build failure output", () => {
|
|
114
|
+
it("reports both bundles when only one bundle's error is recognised", async () => {
|
|
115
|
+
steps.server = { output: "src/js/App.js:3:10: ERROR: Expected ';' but found 'x'\n", code: 1 }
|
|
116
|
+
steps.client = { output: "something the patterns do not know\n", code: 1 }
|
|
117
|
+
|
|
118
|
+
expect(await runBuild()).toBe(1)
|
|
119
|
+
const text = plain(stderr)
|
|
120
|
+
expect(text).toMatch(/Expected ';' but found 'x'/)
|
|
121
|
+
expect(text).toMatch(/The client bundle failed/)
|
|
122
|
+
expect(text).toMatch(/something the patterns do not know/)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("shows the tail of a failed offline manifest step", async () => {
|
|
126
|
+
steps.manifest = {
|
|
127
|
+
output: "TypeError: Cannot read properties of undefined\n at generate (/app/gen.js:4:2)\n",
|
|
128
|
+
code: 2,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
expect(await runBuild()).toBe(2)
|
|
132
|
+
const text = plain(stderr)
|
|
133
|
+
expect(text).toMatch(/offline manifest failed/)
|
|
134
|
+
expect(text).toMatch(/TypeError: Cannot read properties of undefined/)
|
|
135
|
+
expect(text).not.toMatch(/Full output:/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("shows the manifest step's full output under --verbose", async () => {
|
|
139
|
+
steps.manifest = {
|
|
140
|
+
output: "TypeError: Cannot read properties of undefined\n at generate (/app/gen.js:4:2)\n",
|
|
141
|
+
code: 1,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
expect(await runBuild(["--verbose"])).toBe(1)
|
|
145
|
+
const text = plain(stderr)
|
|
146
|
+
expect(text).toMatch(/Full output:/)
|
|
147
|
+
expect(text).toMatch(/at generate \(\/app\/gen\.js:4:2\)/)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("catalyst build failure reporting", () => {
|
|
152
|
+
it("reports one cause once when it broke both bundles", async () => {
|
|
153
|
+
const error = "src/js/App.js:3:10: ERROR: Expected ';' but found 'x'\n"
|
|
154
|
+
steps.server = { output: error, code: 1 }
|
|
155
|
+
steps.client = { output: error, code: 1 }
|
|
156
|
+
|
|
157
|
+
expect(await runBuild()).toBe(1)
|
|
158
|
+
const text = plain(stderr)
|
|
159
|
+
expect(text.match(/Expected ';' but found 'x'/g)).toHaveLength(1)
|
|
160
|
+
expect(text).toMatch(/server \+ client bundle/)
|
|
161
|
+
expect(plain(stdout)).toMatch(/✗ Build failed/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("adds the registry code and the bundlers' full output under --verbose", async () => {
|
|
165
|
+
steps.client = { output: "something the patterns do not know\n", code: 4 }
|
|
166
|
+
|
|
167
|
+
expect(await runBuild(["--verbose"])).toBe(4)
|
|
168
|
+
const text = plain(stderr)
|
|
169
|
+
expect(text).toMatch(/The client bundle failed/)
|
|
170
|
+
expect(text).toMatch(/Full output:/)
|
|
171
|
+
expect(text).toMatch(/\[client\]/)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("includes the environment under --debug", async () => {
|
|
175
|
+
steps.server = { output: "unknown failure\n", code: 1 }
|
|
176
|
+
|
|
177
|
+
expect(await runBuild(["--debug"])).toBe(1)
|
|
178
|
+
expect(plain(stderr)).toContain(process.version)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("exits 1 rather than crashing when a bundler cannot be spawned", async () => {
|
|
182
|
+
steps.server = {
|
|
183
|
+
output: "",
|
|
184
|
+
code: 0,
|
|
185
|
+
spawnError: Object.assign(new Error("spawn node ENOENT"), { code: "ENOENT" }),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
expect(await runBuild()).toBe(1)
|
|
189
|
+
// A spawn error carries no bundle label, so the report stays generic.
|
|
190
|
+
expect(plain(stderr)).toMatch(/The production bundle failed/)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe("catalyst build success output", () => {
|
|
195
|
+
async function runSuccessfulBuild(argv: string[] = []) {
|
|
196
|
+
void runBuild(argv)
|
|
197
|
+
await vi.waitFor(() => expect(plain(stdout)).toMatch(/Built in/))
|
|
198
|
+
return plain(stdout)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it("cleans the previous output, surfaces bundle warnings, and lists the assets", async () => {
|
|
202
|
+
const buildDir = path.join(appDir, "build")
|
|
203
|
+
mkdirSync(buildDir)
|
|
204
|
+
writeFileSync(path.join(buildDir, "stale.js"), "old")
|
|
205
|
+
|
|
206
|
+
steps.client = {
|
|
207
|
+
output: [
|
|
208
|
+
"vite v5 building for production...",
|
|
209
|
+
"(!) Some chunks are larger than 500 kB after minification.",
|
|
210
|
+
"(!) Some chunks are larger than 500 kB after minification.",
|
|
211
|
+
"✓ built in 1.2s",
|
|
212
|
+
].join("\n"),
|
|
213
|
+
code: 0,
|
|
214
|
+
}
|
|
215
|
+
steps.server = { output: "[plugin vite:reporter] dynamic import will not move module\n", code: 0 }
|
|
216
|
+
// The manifest step runs after the bundles, so it stands in for
|
|
217
|
+
// what they wrote.
|
|
218
|
+
steps.manifest = {
|
|
219
|
+
output: "",
|
|
220
|
+
code: 0,
|
|
221
|
+
run: () => {
|
|
222
|
+
mkdirSync(path.join(buildDir, "public", "client"), { recursive: true })
|
|
223
|
+
mkdirSync(path.join(buildDir, "server"), { recursive: true })
|
|
224
|
+
for (let i = 0; i < 13; i++) {
|
|
225
|
+
writeFileSync(
|
|
226
|
+
path.join(buildDir, "public", "client", `chunk-${i}.js`),
|
|
227
|
+
"x".repeat(100 * (i + 1))
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
writeFileSync(path.join(buildDir, "public", "client", "main.css"), "body{}".repeat(500))
|
|
231
|
+
writeFileSync(path.join(buildDir, "public", "client", "readme.txt"), "not an asset")
|
|
232
|
+
writeFileSync(path.join(buildDir, "server", "index.js"), "export default 1")
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const text = await runSuccessfulBuild()
|
|
237
|
+
expect(text).toMatch(/Cleaned previous output/)
|
|
238
|
+
expect(existsSync(path.join(buildDir, "stale.js"))).toBe(false)
|
|
239
|
+
expect(text.match(/client Some chunks are larger than 500 kB/g)).toHaveLength(1)
|
|
240
|
+
expect(text).toMatch(/server \[plugin vite:reporter\] dynamic import will not move module/)
|
|
241
|
+
expect(plain(stderr)).toBe("")
|
|
242
|
+
|
|
243
|
+
expect(text).toMatch(/Client {2}build\//)
|
|
244
|
+
// Largest first, capped at twelve rows.
|
|
245
|
+
expect(text).toMatch(/├ public\/client\/main\.css\s+2\.9 kB\s+\d+ B gzip/)
|
|
246
|
+
expect(text).toMatch(/and 2 more/)
|
|
247
|
+
expect(text).not.toMatch(/readme\.txt/)
|
|
248
|
+
expect(text).toMatch(/Server {2}build\/server\/ {2}1 file, 16 B/)
|
|
249
|
+
expect(text).toMatch(/Run catalyst serve to start the production server/)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("closes the table on its last row and skips it when nothing was produced", async () => {
|
|
253
|
+
steps.manifest = {
|
|
254
|
+
output: "",
|
|
255
|
+
code: 0,
|
|
256
|
+
run: () => {
|
|
257
|
+
mkdirSync(path.join(appDir, "build", "server"), { recursive: true })
|
|
258
|
+
writeFileSync(path.join(appDir, "build", "app.js"), "console.log(1)")
|
|
259
|
+
writeFileSync(path.join(appDir, "build", "server", "a.js"), "1")
|
|
260
|
+
writeFileSync(path.join(appDir, "build", "server", "b.js"), "2")
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
const text = await runSuccessfulBuild()
|
|
264
|
+
expect(text).toMatch(/└ app\.js/)
|
|
265
|
+
expect(text).toMatch(/2 files, 2 B/)
|
|
266
|
+
expect(text).not.toMatch(/Cleaned previous output/)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("prints no asset table when the build wrote nothing", async () => {
|
|
270
|
+
const text = await runSuccessfulBuild()
|
|
271
|
+
expect(text).not.toMatch(/Client {2}/)
|
|
272
|
+
expect(text).toMatch(/Built client and server bundles/)
|
|
273
|
+
expect(text).toMatch(/Generated offline manifest/)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
|
2
2
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"
|
|
3
3
|
import { tmpdir } from "os"
|
|
4
4
|
import path from "path"
|
|
5
|
-
import { runStaticPreflight } from "../../src/scripts/preflight.js"
|
|
5
|
+
import { runStaticPreflight, writeBuildInfo, checkBuildMatchesConfig } from "../../src/scripts/preflight.js"
|
|
6
6
|
import { ERROR_CODES } from "../../src/errors/registry.js"
|
|
7
7
|
|
|
8
8
|
// runStaticPreflight(appDir) reads config/config.json + package.json from a
|
|
@@ -116,3 +116,101 @@ describe("runStaticPreflight", () => {
|
|
|
116
116
|
}
|
|
117
117
|
})
|
|
118
118
|
})
|
|
119
|
+
|
|
120
|
+
describe("checkBuildMatchesConfig", () => {
|
|
121
|
+
const CONFIG = {
|
|
122
|
+
...VALID_CONFIG,
|
|
123
|
+
API_URL: "https://api.example.com",
|
|
124
|
+
CLIENT_ENV_VARIABLES: ["API_URL"],
|
|
125
|
+
}
|
|
126
|
+
const SKIPPED = expect.stringContaining("Skipping the build/config check")
|
|
127
|
+
|
|
128
|
+
function buildWith(config: Record<string, unknown>, args = {}) {
|
|
129
|
+
mkdirSync(path.join(appDir, "build"), { recursive: true })
|
|
130
|
+
writeBuildInfo(appDir, config, args)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
135
|
+
})
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
vi.restoreAllMocks()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("returns null when config.json is unchanged since the build", () => {
|
|
141
|
+
writeApp({ config: CONFIG })
|
|
142
|
+
buildWith(CONFIG)
|
|
143
|
+
expect(checkBuildMatchesConfig(appDir)).toBeNull()
|
|
144
|
+
expect(console.warn).not.toHaveBeenCalledWith(SKIPPED)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("ignores config keys that are not inlined into the build", () => {
|
|
148
|
+
writeApp({ config: { ...CONFIG, NODE_SERVER_PORT: 4000 } })
|
|
149
|
+
buildWith(CONFIG)
|
|
150
|
+
expect(checkBuildMatchesConfig(appDir)).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("PREFLIGHT-022 when PUBLIC_STATIC_ASSET_URL changed", () => {
|
|
154
|
+
writeApp({ config: { ...CONFIG, PUBLIC_STATIC_ASSET_URL: "http://192.168.1.5:3000" } })
|
|
155
|
+
buildWith(CONFIG)
|
|
156
|
+
const err = checkBuildMatchesConfig(appDir)
|
|
157
|
+
expect(err?.code).toBe(ERROR_CODES.PREFLIGHT_BUILD_STALE)
|
|
158
|
+
expect(err?.details).toContain(
|
|
159
|
+
'PUBLIC_STATIC_ASSET_URL: built with "http://localhost:3000", now "http://192.168.1.5:3000"'
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("PREFLIGHT-022 when a CLIENT_ENV_VARIABLES value changed", () => {
|
|
164
|
+
writeApp({ config: { ...CONFIG, API_URL: "https://staging.example.com" } })
|
|
165
|
+
buildWith(CONFIG)
|
|
166
|
+
expect(checkBuildMatchesConfig(appDir)?.details).toContain(
|
|
167
|
+
'API_URL: built with "https://api.example.com", now "https://staging.example.com"'
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("PREFLIGHT-022 when a variable is added to CLIENT_ENV_VARIABLES", () => {
|
|
172
|
+
writeApp({ config: { ...CONFIG, CUSTOM_VAR: "x", CLIENT_ENV_VARIABLES: ["API_URL", "CUSTOM_VAR"] } })
|
|
173
|
+
buildWith(CONFIG)
|
|
174
|
+
expect(checkBuildMatchesConfig(appDir)?.details).toContain('CUSTOM_VAR: built with unset, now "x"')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("compares against KEY=value CLI arguments, which win over config.json", () => {
|
|
178
|
+
const args = { PUBLIC_STATIC_ASSET_URL: "http://cdn.example.com" }
|
|
179
|
+
writeApp({ config: CONFIG })
|
|
180
|
+
buildWith(CONFIG, args)
|
|
181
|
+
expect(checkBuildMatchesConfig(appDir, args)).toBeNull()
|
|
182
|
+
expect(checkBuildMatchesConfig(appDir)?.code).toBe(ERROR_CODES.PREFLIGHT_BUILD_STALE)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("treats a CLI argument equal to the config.json object as unchanged", () => {
|
|
186
|
+
const config = { ...CONFIG, OPTIONS: { enabled: true }, CLIENT_ENV_VARIABLES: ["OPTIONS"] }
|
|
187
|
+
writeApp({ config })
|
|
188
|
+
buildWith(config)
|
|
189
|
+
expect(checkBuildMatchesConfig(appDir, { OPTIONS: '{"enabled":true}' })).toBeNull()
|
|
190
|
+
expect(checkBuildMatchesConfig(appDir, { CLIENT_ENV_VARIABLES: '["OPTIONS"]' })).toBeNull()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("warns and passes when the build has no build info file", () => {
|
|
194
|
+
writeApp({ config: CONFIG })
|
|
195
|
+
expect(checkBuildMatchesConfig(appDir)).toBeNull()
|
|
196
|
+
expect(console.warn).toHaveBeenCalledWith(SKIPPED)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it.each([["{ not json"], ["null"], ["[]"]])(
|
|
200
|
+
"warns and passes when the build info file is %s",
|
|
201
|
+
(contents) => {
|
|
202
|
+
writeApp({ config: CONFIG })
|
|
203
|
+
mkdirSync(path.join(appDir, "build"), { recursive: true })
|
|
204
|
+
writeFileSync(path.join(appDir, "build", ".catalyst-build.json"), contents)
|
|
205
|
+
expect(checkBuildMatchesConfig(appDir)).toBeNull()
|
|
206
|
+
expect(console.warn).toHaveBeenCalledWith(SKIPPED)
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
it("warns and passes when CLIENT_ENV_VARIABLES cannot be parsed", () => {
|
|
211
|
+
writeApp({ config: CONFIG })
|
|
212
|
+
buildWith(CONFIG)
|
|
213
|
+
expect(checkBuildMatchesConfig(appDir, { CLIENT_ENV_VARIABLES: "not-json" })).toBeNull()
|
|
214
|
+
expect(console.warn).toHaveBeenCalledWith(SKIPPED)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events"
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest"
|
|
6
|
+
|
|
7
|
+
// serve.js runs on import; the preflight is stubbed and spawn is recorded so
|
|
8
|
+
// the test can check how the production server is started.
|
|
9
|
+
const preflight = vi.hoisted(() => ({ staleBuild: null as null | { code: string } }))
|
|
10
|
+
vi.mock("../../src/scripts/preflight.js", () => ({
|
|
11
|
+
runStaticPreflightOrExit: () => {},
|
|
12
|
+
checkBuildMatchesConfig: () => preflight.staleBuild,
|
|
13
|
+
renderRegistryError: (error: { code: string }) => `rendered ${error.code}\n`,
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const spawned: { args: string[]; options: { stdio: unknown; env: Record<string, string> } }[] = []
|
|
17
|
+
vi.mock("child_process", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("child_process")>()
|
|
19
|
+
const spawn = (_cmd: string, args: string[], options: (typeof spawned)[number]["options"]) => {
|
|
20
|
+
spawned.push({ args, options })
|
|
21
|
+
return Object.assign(new EventEmitter(), { stdout: null, stderr: null, kill: () => true })
|
|
22
|
+
}
|
|
23
|
+
return { ...actual, default: { ...actual, spawn }, spawn }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
let appDir: string
|
|
27
|
+
let stderr: string
|
|
28
|
+
let exit: ReturnType<typeof vi.spyOn>
|
|
29
|
+
const originalPwd = process.env.PWD
|
|
30
|
+
|
|
31
|
+
// serve.js runs on import, so each test loads a fresh copy.
|
|
32
|
+
async function serve() {
|
|
33
|
+
vi.resetModules()
|
|
34
|
+
await import("../../src/scripts/serve.js")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
appDir = mkdtempSync(path.join(tmpdir(), "catalyst-serve-"))
|
|
39
|
+
mkdirSync(path.join(appDir, "config"))
|
|
40
|
+
mkdirSync(path.join(appDir, "build"))
|
|
41
|
+
writeFileSync(path.join(appDir, "config", "config.json"), JSON.stringify({ BUILD_OUTPUT_PATH: "build" }))
|
|
42
|
+
writeFileSync(path.join(appDir, "package.json"), JSON.stringify({ name: "fixture-app" }))
|
|
43
|
+
process.env.PWD = appDir
|
|
44
|
+
spawned.length = 0
|
|
45
|
+
preflight.staleBuild = null
|
|
46
|
+
stderr = ""
|
|
47
|
+
// Not thrown: startProd is not awaited, so a throw would surface as an
|
|
48
|
+
// unhandled rejection rather than stop it.
|
|
49
|
+
exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never)
|
|
50
|
+
vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string) => {
|
|
51
|
+
stderr += String(chunk)
|
|
52
|
+
return true
|
|
53
|
+
}) as never)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.restoreAllMocks()
|
|
58
|
+
process.env.PWD = originalPwd
|
|
59
|
+
for (const signal of ["SIGTERM", "SIGHUP"] as const) process.removeAllListeners(signal)
|
|
60
|
+
rmSync(appDir, { recursive: true, force: true })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("hands the production server the terminal's own stdout and stderr", async () => {
|
|
64
|
+
await serve()
|
|
65
|
+
await vi.waitFor(() => expect(spawned).toHaveLength(1))
|
|
66
|
+
|
|
67
|
+
const { options } = spawned[0]
|
|
68
|
+
// Inherited, so log shippers read the app's lines exactly as written --
|
|
69
|
+
// and unwrapped, so the server prints no banner fences.
|
|
70
|
+
expect(options.stdio).toBe("inherit")
|
|
71
|
+
expect(options.env.CATALYST_WRAPPED).toBeUndefined()
|
|
72
|
+
expect(options.env.NODE_ENV).toBe("production")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("says to build first when there is no production build", async () => {
|
|
76
|
+
rmSync(path.join(appDir, "build"), { recursive: true })
|
|
77
|
+
await serve()
|
|
78
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
|
79
|
+
|
|
80
|
+
expect(stderr).toMatch(/No production build in build\//)
|
|
81
|
+
expect(stderr).toMatch(/Run catalyst build first, then catalyst serve\./)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("refuses a build that no longer matches the config", async () => {
|
|
85
|
+
preflight.staleBuild = { code: "BUILD_CONFIG_MISMATCH" }
|
|
86
|
+
await serve()
|
|
87
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
|
88
|
+
|
|
89
|
+
expect(stderr).toBe("rendered BUILD_CONFIG_MISMATCH\n")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("passes --inspect through to the server", async () => {
|
|
93
|
+
const argv = process.argv
|
|
94
|
+
process.argv = [argv[0], "serve.js", "--inspect"]
|
|
95
|
+
try {
|
|
96
|
+
await serve()
|
|
97
|
+
await vi.waitFor(() => expect(spawned).toHaveLength(1))
|
|
98
|
+
} finally {
|
|
99
|
+
process.argv = argv
|
|
100
|
+
}
|
|
101
|
+
expect(spawned[0].args[0]).toBe("--inspect")
|
|
102
|
+
})
|
|
@@ -62,8 +62,16 @@ const FetcherErrorLoadable = loadable({
|
|
|
62
62
|
}),
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
// A component that throws while rendering -- the most common dev-time
|
|
66
|
+
// failure. The handler must report it once and answer with a 500, not
|
|
67
|
+
// re-render it from every fallback and leave the request open.
|
|
68
|
+
function RenderErrorPage() {
|
|
69
|
+
throw new Error("render exploded")
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
const routes = [
|
|
66
73
|
{ path: "/", component: HomePage },
|
|
74
|
+
{ path: "/render-error", component: RenderErrorPage },
|
|
67
75
|
{ path: "/widget", component: WidgetPage },
|
|
68
76
|
{ path: "/fetcher-error", component: FetcherErrorLoadable },
|
|
69
77
|
{ path: "*", component: NotFoundPage },
|
|
@@ -160,6 +160,23 @@ describe("SSR handler", () => {
|
|
|
160
160
|
expect(res.getHtml()).toContain("<html")
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
+
it("reports a component render error once and answers 500 instead of hanging", async () => {
|
|
164
|
+
const { default: handler } = await import("../../src/server/renderer/handler.jsx")
|
|
165
|
+
const { req, res } = makeReqRes("/render-error")
|
|
166
|
+
|
|
167
|
+
await handler(req, res)
|
|
168
|
+
await res.waitForEnd()
|
|
169
|
+
|
|
170
|
+
// One report: the render failure used to be logged again by the
|
|
171
|
+
// fetcher, serverSideFunction and request catch sites, each of which
|
|
172
|
+
// re-rendered the same throwing tree.
|
|
173
|
+
const logged = (console.error as any).mock.calls.flat().join("\n")
|
|
174
|
+
expect(logged.match(/\[RUNTIME-WEB-\d+\]/g)).toEqual(["[RUNTIME-WEB-001]"])
|
|
175
|
+
// And the request is finished rather than left open.
|
|
176
|
+
expect(res.statusCode).toBe(500)
|
|
177
|
+
expect(res.isEnded()).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
163
180
|
it("fails the request with 500 (no render) when configureStore is not a function", async () => {
|
|
164
181
|
// handler.jsx imports createStore as a module-scope default binding,
|
|
165
182
|
// so swap the fixture module before the fresh import. A non-function
|