catalyst-core 1.0.2-canary.1 → 1.0.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/changelog.md +34 -0
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +51 -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/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 +66 -95
- 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 +340 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +313 -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 +35 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +64 -15
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- 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 +18 -0
- package/dist/vite/vite.config.js +73 -13
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +15 -2
- package/dist/web-router/components/MetaTag.test.jsx +71 -2
- package/dist/webmcp/WebMcpProvider.jsx +3 -2
- package/dist/webmcp/useTool.js +4 -1
- package/mcp_v2/conversion-tasks.json +6 -5
- package/mcp_v2/knowledge-base.json +124 -7
- package/mcp_v2/test/config.test.ts +59 -0
- package/mcp_v2/tools/build.js +6 -6
- package/mcp_v2/tools/config.js +35 -18
- package/mcp_v2/tools/conversion.js +7 -13
- package/package.json +5 -5
- 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/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/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- 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
|
+
})
|
|
@@ -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
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Writable } from "node:stream"
|
|
2
|
+
import winston from "winston"
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest"
|
|
4
|
+
|
|
5
|
+
// The rotating file transport, replaced by one that records its options and
|
|
6
|
+
// what it was given, so enabling file logging touches no disk.
|
|
7
|
+
const rotated = vi.hoisted(() => ({
|
|
8
|
+
options: [] as Record<string, unknown>[],
|
|
9
|
+
records: [] as Record<string, unknown>[],
|
|
10
|
+
}))
|
|
11
|
+
vi.mock("winston-daily-rotate-file", async () => {
|
|
12
|
+
const { default: winston } = await import("winston")
|
|
13
|
+
// winston re-exports winston-transport at runtime; its typings do not.
|
|
14
|
+
const { Transport } = winston as unknown as {
|
|
15
|
+
Transport: new (options?: object) => object
|
|
16
|
+
}
|
|
17
|
+
class FakeRotate extends Transport {
|
|
18
|
+
constructor(options: Record<string, unknown>) {
|
|
19
|
+
super(options)
|
|
20
|
+
rotated.options.push(options)
|
|
21
|
+
}
|
|
22
|
+
log(info: Record<string, unknown>, done: () => void) {
|
|
23
|
+
rotated.records.push(info)
|
|
24
|
+
done()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { default: FakeRotate }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// What the file logs receive: every winston logger the app logger creates
|
|
31
|
+
// gets an extra in-memory transport, so the test reads the same serialized
|
|
32
|
+
// JSON lines a DailyRotateFile would write.
|
|
33
|
+
let written: Record<string, unknown>[]
|
|
34
|
+
let loggers: winston.Logger[]
|
|
35
|
+
let consoleLines: string[]
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
written = []
|
|
39
|
+
loggers = []
|
|
40
|
+
consoleLines = []
|
|
41
|
+
rotated.options.length = 0
|
|
42
|
+
rotated.records.length = 0
|
|
43
|
+
const createLogger = winston.createLogger
|
|
44
|
+
vi.spyOn(winston, "createLogger").mockImplementation((options) => {
|
|
45
|
+
const logger = createLogger(options)
|
|
46
|
+
const stream = new Writable({
|
|
47
|
+
write(chunk, _encoding, done) {
|
|
48
|
+
written.push(JSON.parse(String(chunk)))
|
|
49
|
+
done()
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
logger.add(new winston.transports.Stream({ stream }))
|
|
53
|
+
loggers.push(logger)
|
|
54
|
+
return logger
|
|
55
|
+
})
|
|
56
|
+
vi.spyOn(console, "log").mockImplementation((line: string) => {
|
|
57
|
+
consoleLines.push(String(line).replace(/\u001b\[[0-9;]*m/g, ""))
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.restoreAllMocks()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
type AppLogger = Record<"info" | "error" | "debug", (msg: unknown) => void>
|
|
66
|
+
|
|
67
|
+
async function appLogger(config: Record<string, unknown> = {}): Promise<AppLogger> {
|
|
68
|
+
vi.resetModules()
|
|
69
|
+
const { configureLogger } = await import("../../src/logger.js")
|
|
70
|
+
return configureLogger({ enableFileLogging: false, enableConsoleLogging: false, ...config } as never)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it("keeps a plain object's fields queryable in the file log", async () => {
|
|
74
|
+
const logger = await appLogger()
|
|
75
|
+
const payload = { message: "upstream failed", url: "/api/orders" }
|
|
76
|
+
logger.error(payload)
|
|
77
|
+
|
|
78
|
+
await vi.waitFor(() => expect(written).toHaveLength(1))
|
|
79
|
+
expect(written[0]).toMatchObject({ message: "upstream failed", url: "/api/orders", level: "error" })
|
|
80
|
+
// The caller's object is not written on.
|
|
81
|
+
expect(payload).toEqual({ message: "upstream failed", url: "/api/orders" })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("logs a circular object, and an Error inside it as its stack", async () => {
|
|
85
|
+
const logger = await appLogger()
|
|
86
|
+
const payload: Record<string, unknown> = { message: "bad", cause: new Error("root cause") }
|
|
87
|
+
payload.self = payload
|
|
88
|
+
logger.info(payload)
|
|
89
|
+
|
|
90
|
+
await vi.waitFor(() => expect(written).toHaveLength(1))
|
|
91
|
+
expect(written[0]).toMatchObject({ message: "bad", self: "[Circular]", level: "info" })
|
|
92
|
+
expect(written[0].cause).toMatch(/^Error: root cause\n\s+at /)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("logs strings and Errors as text", async () => {
|
|
96
|
+
const logger = await appLogger()
|
|
97
|
+
logger.info("plain words")
|
|
98
|
+
logger.error(new Error("boom"))
|
|
99
|
+
|
|
100
|
+
await vi.waitFor(() => expect(written).toHaveLength(2))
|
|
101
|
+
expect(written[0].message).toBe("plain words")
|
|
102
|
+
expect(written[1].message).toMatch(/^Error: boom\n\s+at /)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("echoes each level to the terminal with its label", async () => {
|
|
106
|
+
const logger = await appLogger()
|
|
107
|
+
logger.info("ready")
|
|
108
|
+
logger.error("failed")
|
|
109
|
+
logger.debug("details")
|
|
110
|
+
expect(consoleLines).toEqual(["INFO: ready", "ERROR: failed", "DEBUG: details"])
|
|
111
|
+
|
|
112
|
+
await vi.waitFor(() => expect(written).toHaveLength(2))
|
|
113
|
+
expect(written.map((record) => record.level)).toEqual(["info", "error"])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("makes debug a no-op when debug logs are off", async () => {
|
|
117
|
+
for (const enableDebugLogs of [false, "false"]) {
|
|
118
|
+
consoleLines.length = 0
|
|
119
|
+
const logger = await appLogger({ enableDebugLogs })
|
|
120
|
+
logger.debug("hidden")
|
|
121
|
+
expect(consoleLines).toEqual([])
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("adds the console transport and a rotating file per level when enabled", async () => {
|
|
126
|
+
const srcPath = process.env.src_path
|
|
127
|
+
process.env.src_path = "/app"
|
|
128
|
+
// The console transport is real but kept quiet.
|
|
129
|
+
const consoleLog = vi
|
|
130
|
+
.spyOn(winston.transports.Console.prototype, "log")
|
|
131
|
+
.mockImplementation(((_info: unknown, done: () => void) => done()) as never)
|
|
132
|
+
try {
|
|
133
|
+
const logger = await appLogger({ enableFileLogging: "true", enableConsoleLogging: true })
|
|
134
|
+
logger.info({ message: "booted", port: 3005 })
|
|
135
|
+
|
|
136
|
+
expect(rotated.options.map((options) => options.filename)).toEqual([
|
|
137
|
+
"/app/logs/info/%DATE%.info.log",
|
|
138
|
+
"/app/logs/debug/%DATE%.debug.log",
|
|
139
|
+
"/app/logs/error/%DATE%.error.log",
|
|
140
|
+
])
|
|
141
|
+
expect(rotated.options.map((options) => options.level)).toEqual(["info", "debug", "error"])
|
|
142
|
+
for (const created of loggers) {
|
|
143
|
+
expect(
|
|
144
|
+
created.transports.some((transport) => transport instanceof winston.transports.Console)
|
|
145
|
+
).toBe(true)
|
|
146
|
+
}
|
|
147
|
+
await vi.waitFor(() => expect(rotated.records).toHaveLength(1))
|
|
148
|
+
expect(rotated.records[0]).toMatchObject({ message: "booted", port: 3005, level: "info" })
|
|
149
|
+
expect(consoleLog).toHaveBeenCalled()
|
|
150
|
+
} finally {
|
|
151
|
+
if (srcPath === undefined) delete process.env.src_path
|
|
152
|
+
else process.env.src_path = srcPath
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("renders values JSON cannot, instead of dropping or throwing on them", async () => {
|
|
157
|
+
const logger = await appLogger()
|
|
158
|
+
const bare = new Error("no stack")
|
|
159
|
+
bare.stack = ""
|
|
160
|
+
logger.info({ count: BigInt("9007199254740993") })
|
|
161
|
+
logger.info(bare)
|
|
162
|
+
logger.info(function namedHandler() {})
|
|
163
|
+
logger.info({
|
|
164
|
+
toJSON() {
|
|
165
|
+
throw new Error("cannot serialise")
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
logger.info([1, "two"])
|
|
169
|
+
logger.info(42)
|
|
170
|
+
|
|
171
|
+
expect(consoleLines).toEqual([
|
|
172
|
+
'INFO: {"count":"9007199254740993"}',
|
|
173
|
+
"INFO: Error: no stack",
|
|
174
|
+
expect.stringMatching(/^INFO: function namedHandler\(\) \{\s*\}$/),
|
|
175
|
+
"INFO: [object Object]",
|
|
176
|
+
'INFO: [1,"two"]',
|
|
177
|
+
"INFO: 42",
|
|
178
|
+
])
|
|
179
|
+
await vi.waitFor(() => expect(written).toHaveLength(6))
|
|
180
|
+
// Only a plain object becomes a structured record; the rest stay text.
|
|
181
|
+
expect(JSON.stringify(written[0])).toContain('"count":"9007199254740993"')
|
|
182
|
+
expect(written[3].message).toBe("[object Object]")
|
|
183
|
+
expect(written[4].message).toBe('[1,"two"]')
|
|
184
|
+
expect(written[5].message).toBe("42")
|
|
185
|
+
})
|