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,549 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { EventEmitter } from "node:events"
|
|
3
|
+
import { createRequire } from "node:module"
|
|
4
|
+
import { PassThrough } from "node:stream"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
7
|
+
|
|
8
|
+
import type themeModule from "../../src/cli/theme.js"
|
|
9
|
+
|
|
10
|
+
// Loaded with require, the way serve.js and the native commands load them:
|
|
11
|
+
// v8 coverage then sees one copy of each CommonJS file rather than an
|
|
12
|
+
// import-transformed one and a required one that merge badly.
|
|
13
|
+
const load = createRequire(import.meta.url)
|
|
14
|
+
const devOutput: unknown = load("../../src/cli/devOutput.js")
|
|
15
|
+
const theme: typeof themeModule = load("../../src/cli/theme.js")
|
|
16
|
+
|
|
17
|
+
// devOutput.js shapes `catalyst start`'s server output and supervises the
|
|
18
|
+
// `catalyst serve` child. Colour depends on the environment, so assertions
|
|
19
|
+
// are on the ANSI-stripped text.
|
|
20
|
+
// Loosely typed: the JSDoc types are narrower than what the tests pass in
|
|
21
|
+
// (a state with `verbose`, a fake child process).
|
|
22
|
+
type State = Record<string, unknown>
|
|
23
|
+
const {
|
|
24
|
+
formatDevLine,
|
|
25
|
+
wireServerOutput,
|
|
26
|
+
superviseServer,
|
|
27
|
+
ensurePortFree,
|
|
28
|
+
createLineReader,
|
|
29
|
+
BANNER_START,
|
|
30
|
+
BANNER_END,
|
|
31
|
+
NEXT_STEP,
|
|
32
|
+
} = devOutput as {
|
|
33
|
+
formatDevLine: (line: string, state: State) => string | null
|
|
34
|
+
wireServerOutput: (child: unknown) => {
|
|
35
|
+
state: State
|
|
36
|
+
flushRemaining: (options?: { dropLastError?: boolean }) => void
|
|
37
|
+
}
|
|
38
|
+
superviseServer: (
|
|
39
|
+
child: unknown,
|
|
40
|
+
options: { scope: string; label: string; passthrough?: boolean }
|
|
41
|
+
) => State
|
|
42
|
+
ensurePortFree: (options: { port: unknown; host?: string; scope: string }) => Promise<void>
|
|
43
|
+
createLineReader: (onLine: (line: string) => void) => { push: (chunk: string) => void; flush: () => void }
|
|
44
|
+
BANNER_START: string
|
|
45
|
+
BANNER_END: string
|
|
46
|
+
NEXT_STEP: string
|
|
47
|
+
}
|
|
48
|
+
const plain = (text: string | null) => (text == null ? text : String(text).replace(theme.ANSI, ""))
|
|
49
|
+
const DEV_OUTPUT = fileURLToPath(new URL("../../src/cli/devOutput.js", import.meta.url))
|
|
50
|
+
|
|
51
|
+
function fakeChild() {
|
|
52
|
+
const child = Object.assign(new EventEmitter(), {
|
|
53
|
+
stdout: new PassThrough(),
|
|
54
|
+
stderr: new PassThrough(),
|
|
55
|
+
kill: vi.fn(() => true),
|
|
56
|
+
})
|
|
57
|
+
return child
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("formatDevLine before the banner", () => {
|
|
61
|
+
it("shows a startup Error line instead of only remembering it", () => {
|
|
62
|
+
const state: State = { seenWarnings: new Set(), verbose: false }
|
|
63
|
+
const line = formatDevLine("Error: connect ECONNREFUSED 127.0.0.1:6379", state)
|
|
64
|
+
expect(plain(line)).toMatch(/✗ connect ECONNREFUSED 127\.0\.0\.1:6379/)
|
|
65
|
+
expect(state.lastError).toBe("connect ECONNREFUSED 127.0.0.1:6379")
|
|
66
|
+
expect(state.lastErrorLine).toBe(line)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("shows an unhandled rejection", () => {
|
|
70
|
+
const state = { seenWarnings: new Set(), verbose: false }
|
|
71
|
+
expect(plain(formatDevLine("unhandledRejection in Catalyst boom", state))).toMatch(
|
|
72
|
+
/✗ Unhandled promise rejection boom/
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("keeps stack frames under --verbose and condenses them otherwise", () => {
|
|
77
|
+
const frame = " at connect (/app/server/index.js:3:9)"
|
|
78
|
+
expect(formatDevLine(frame, { seenWarnings: new Set(), verbose: false })).toBeNull()
|
|
79
|
+
expect(plain(formatDevLine(frame, { seenWarnings: new Set(), verbose: true }))).toMatch(
|
|
80
|
+
/at connect \(\/app\/server\/index\.js:3:9\)/
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("wireServerOutput", () => {
|
|
86
|
+
let logged: string[]
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.useFakeTimers()
|
|
90
|
+
logged = []
|
|
91
|
+
vi.spyOn(console, "log").mockImplementation((line: string) => {
|
|
92
|
+
logged.push(plain(String(line)) as string)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.useRealTimers()
|
|
97
|
+
vi.restoreAllMocks()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("prints a startup diagnostic promptly when the banner never arrives", () => {
|
|
101
|
+
const child = fakeChild()
|
|
102
|
+
wireServerOutput(child)
|
|
103
|
+
child.stderr.write(
|
|
104
|
+
"\n error dev server\n Could not start the server: listen EADDRNOTAVAIL 10.0.0.9:3005\n"
|
|
105
|
+
)
|
|
106
|
+
expect(logged).toEqual([])
|
|
107
|
+
|
|
108
|
+
vi.advanceTimersByTime(600)
|
|
109
|
+
expect(logged.join("\n")).toMatch(/EADDRNOTAVAIL/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("prints a non-fatal startup error promptly", () => {
|
|
113
|
+
const child = fakeChild()
|
|
114
|
+
wireServerOutput(child)
|
|
115
|
+
child.stderr.write("Error: Redis down\n")
|
|
116
|
+
vi.advanceTimersByTime(600)
|
|
117
|
+
expect(logged.join("\n")).toMatch(/✗ Redis down/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("still holds plain notices for the banner", () => {
|
|
121
|
+
const child = fakeChild()
|
|
122
|
+
wireServerOutput(child)
|
|
123
|
+
child.stdout.write("8:00:53 pm [vite] (client) something worth reading\n")
|
|
124
|
+
vi.advanceTimersByTime(600)
|
|
125
|
+
expect(logged).toEqual([])
|
|
126
|
+
|
|
127
|
+
child.stdout.write(`${BANNER_START}\nbanner\n${BANNER_END}\n`)
|
|
128
|
+
vi.advanceTimersByTime(300)
|
|
129
|
+
expect(logged.join("\n")).toMatch(/banner[\s\S]*Notices[\s\S]*something worth reading/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("leaves out the one-line error the exit diagnostic is about to repeat", () => {
|
|
133
|
+
const child = fakeChild()
|
|
134
|
+
const { flushRemaining } = wireServerOutput(child)
|
|
135
|
+
child.stderr.write("Error: Cannot find module 'x'\n")
|
|
136
|
+
flushRemaining({ dropLastError: true })
|
|
137
|
+
expect(logged.join("\n")).not.toMatch(/Cannot find module/)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("superviseServer passthrough (catalyst serve)", () => {
|
|
142
|
+
it("propagates the exit code without wiring output", () => {
|
|
143
|
+
const child = fakeChild()
|
|
144
|
+
const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never)
|
|
145
|
+
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
|
|
146
|
+
try {
|
|
147
|
+
superviseServer(child, { scope: "server", label: "production server", passthrough: true })
|
|
148
|
+
child.emit("close", 3, null)
|
|
149
|
+
expect(exit).toHaveBeenCalledWith(3)
|
|
150
|
+
// The app's own output already said why; nothing is added.
|
|
151
|
+
expect(stderr).not.toHaveBeenCalled()
|
|
152
|
+
expect(child.stdout.listenerCount("data")).toBe(0)
|
|
153
|
+
} finally {
|
|
154
|
+
vi.restoreAllMocks()
|
|
155
|
+
for (const signal of ["SIGTERM", "SIGHUP"] as const) process.removeAllListeners(signal)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("delivers the app's stdout and stderr byte-for-byte to the right streams", () => {
|
|
160
|
+
// A real wrapper process around a real inherited child, as serve.js
|
|
161
|
+
// runs it: JSON log lines, a logged error object and a stack must
|
|
162
|
+
// arrive exactly as written.
|
|
163
|
+
const app = [
|
|
164
|
+
`process.stdout.write('{"level":"info","message":"order placed"}\\n')`,
|
|
165
|
+
`process.stderr.write("{\\n code: 'ECONNRESET',\\n port: 6379\\n}\\n")`,
|
|
166
|
+
`process.stderr.write("Error: upstream timeout\\n at handler (/app/server.js:10:5)\\n")`,
|
|
167
|
+
`process.exit(3)`,
|
|
168
|
+
].join(";")
|
|
169
|
+
const wrapper = [
|
|
170
|
+
`const { spawn } = require("child_process")`,
|
|
171
|
+
`const { superviseServer } = require(${JSON.stringify(DEV_OUTPUT)})`,
|
|
172
|
+
`const child = spawn(process.execPath, ["-e", ${JSON.stringify(app)}], { stdio: "inherit" })`,
|
|
173
|
+
`superviseServer(child, { scope: "server", label: "production server", passthrough: true })`,
|
|
174
|
+
].join(";")
|
|
175
|
+
const result = spawnSync(process.execPath, ["-e", wrapper], { encoding: "utf8" })
|
|
176
|
+
|
|
177
|
+
expect(result.status).toBe(3)
|
|
178
|
+
expect(result.stdout).toBe('{"level":"info","message":"order placed"}\n')
|
|
179
|
+
expect(result.stderr).toBe(
|
|
180
|
+
"{\n code: 'ECONNRESET',\n port: 6379\n}\nError: upstream timeout\n at handler (/app/server.js:10:5)\n"
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("formatDevLine classification", () => {
|
|
186
|
+
const fresh = (extra: State = {}): State => ({ seenWarnings: new Set(), verbose: false, ...extra })
|
|
187
|
+
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
vi.restoreAllMocks()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("swallows Vite's own SSR evaluation report up to the server's diagnostic", () => {
|
|
193
|
+
const state = fresh()
|
|
194
|
+
expect(formatDevLine("Error when evaluating SSR module /src/App.js:", state)).toBeNull()
|
|
195
|
+
expect(formatDevLine(" file: /app/src/App.js:3:9", state)).toBeNull()
|
|
196
|
+
expect(formatDevLine("", state)).toBeNull()
|
|
197
|
+
// The blank line that opened the report was swallowed with Vite's block.
|
|
198
|
+
expect(formatDevLine(" error dev server", state)).toBe("\n error dev server")
|
|
199
|
+
expect(formatDevLine(" Cannot read properties of undefined", state)).toBe(
|
|
200
|
+
" Cannot read properties of undefined"
|
|
201
|
+
)
|
|
202
|
+
// Blank lines are layout once a diagnostic is open.
|
|
203
|
+
expect(formatDevLine("", state)).toBe("")
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("stops swallowing at Vite's next notice or after a bounded run", () => {
|
|
207
|
+
const state = fresh()
|
|
208
|
+
formatDevLine("Error when evaluating SSR module /src/App.js:", state)
|
|
209
|
+
expect(plain(formatDevLine("8:00:53 pm [vite] server restarted.", state))).toBe(
|
|
210
|
+
" • server restarted."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const bounded = fresh()
|
|
214
|
+
formatDevLine("Error when evaluating SSR module /src/App.js:", bounded)
|
|
215
|
+
for (let i = 0; i < 60; i++) expect(formatDevLine(` frame ${i}`, bounded)).toBeNull()
|
|
216
|
+
expect(plain(formatDevLine("finally visible", bounded))).toBe(" • finally visible")
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("keeps the SSR report under --verbose", () => {
|
|
220
|
+
expect(
|
|
221
|
+
plain(formatDevLine("Error when evaluating SSR module /src/App.js:", fresh({ verbose: true })))
|
|
222
|
+
).toBe(" • Error when evaluating SSR module /src/App.js:")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("drops boilerplate the user cannot act on", () => {
|
|
226
|
+
for (const line of [
|
|
227
|
+
"Debugger attached.",
|
|
228
|
+
"For help, see: https://nodejs.org/en/docs/inspector",
|
|
229
|
+
"(Use `node --trace-warnings ...` to show where the warning was created)",
|
|
230
|
+
" code: 'ECONNREFUSED',",
|
|
231
|
+
"{",
|
|
232
|
+
"}",
|
|
233
|
+
"Caught exception: Error: boom",
|
|
234
|
+
"2",
|
|
235
|
+
]) {
|
|
236
|
+
expect(formatDevLine(line, fresh())).toBeNull()
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("hides Vite bookkeeping unless verbose", () => {
|
|
241
|
+
const line = "8:00:53 pm [vite] (client) Re-optimizing dependencies because lockfile has changed"
|
|
242
|
+
expect(formatDevLine(line, fresh())).toBeNull()
|
|
243
|
+
expect(plain(formatDevLine(line, fresh({ verbose: true })))).toBe(
|
|
244
|
+
" • client Re-optimizing dependencies because lockfile has changed"
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("rewords reloads and shows one line for both channels", () => {
|
|
249
|
+
vi.spyOn(Date, "now").mockReturnValue(1000)
|
|
250
|
+
const state = fresh()
|
|
251
|
+
expect(plain(formatDevLine("8:00:53 pm [vite] (client) page reload src/App.js", state))).toBe(
|
|
252
|
+
" ↻ src/App.js"
|
|
253
|
+
)
|
|
254
|
+
expect(formatDevLine("8:00:53 pm [vite] (ssr) page reload src/App.js", state)).toBeNull()
|
|
255
|
+
expect(plain(formatDevLine("8:00:54 pm [vite] (client) hmr update /src/a.css (x2)", state))).toBe(
|
|
256
|
+
" ↻ /src/a.css (x2)"
|
|
257
|
+
)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("shortens unknown routes and a taken HMR port", () => {
|
|
261
|
+
expect(plain(formatDevLine('No routes matched location "/nope"', fresh()))).toBe(
|
|
262
|
+
" ▲ No route matches /nope"
|
|
263
|
+
)
|
|
264
|
+
expect(plain(formatDevLine("WebSocket server error: Port 24678 is already in use", fresh()))).toBe(
|
|
265
|
+
" ▲ Live reload is off: port 24678 is used by another dev server"
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("shows stack frames once the banner has printed", () => {
|
|
270
|
+
expect(plain(formatDevLine(" at handler (/app/server.js:10:5)", fresh({ flushed: true })))).toBe(
|
|
271
|
+
" at handler (/app/server.js:10:5)"
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("reports an unhandled rejection without an empty object", () => {
|
|
276
|
+
const state = fresh()
|
|
277
|
+
expect(plain(formatDevLine("unhandledRejection in Catalyst {}", state))).toBe(
|
|
278
|
+
" ✗ Unhandled promise rejection"
|
|
279
|
+
)
|
|
280
|
+
expect(state.lastError).toBe("Unhandled promise rejection")
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("keeps the inspector URL", () => {
|
|
284
|
+
expect(plain(formatDevLine("Debugger listening on ws://127.0.0.1:9229/abc", fresh()))).toBe(
|
|
285
|
+
" Inspect ws://127.0.0.1:9229/abc"
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it("collapses Node warnings to one line each", () => {
|
|
290
|
+
const typeless = "(node:123) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///x.js"
|
|
291
|
+
expect(formatDevLine(typeless, fresh())).toBeNull()
|
|
292
|
+
expect(plain(formatDevLine(typeless, fresh({ verbose: true })))).toBe(
|
|
293
|
+
' ▲ Add "type": "module" to package.json to silence a Node reparse warning'
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const state = fresh()
|
|
297
|
+
const experimental = "(node:1) [EXPERIMENTAL_FEATURE] Warning: Fetch is experimental."
|
|
298
|
+
expect(plain(formatDevLine(experimental, state))).toBe(" ▲ Fetch is experimental.")
|
|
299
|
+
expect(
|
|
300
|
+
formatDevLine("(node:2) [EXPERIMENTAL_FEATURE] Warning: Fetch is experimental.", state)
|
|
301
|
+
).toBeNull()
|
|
302
|
+
expect(plain(formatDevLine("(node:3) Warning: uncoded advisory", state))).toBe(" ▲ uncoded advisory")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("keeps a Vite notice's channel and passes anything else through dimmed", () => {
|
|
306
|
+
expect(plain(formatDevLine("8:00:53 pm [vite] (ssr) something happened", fresh()))).toBe(
|
|
307
|
+
" • ssr something happened"
|
|
308
|
+
)
|
|
309
|
+
expect(plain(formatDevLine("hello from the app", fresh()))).toBe(" • hello from the app")
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe("createLineReader", () => {
|
|
314
|
+
it("holds a partial line until its newline or a flush", () => {
|
|
315
|
+
const seen: string[] = []
|
|
316
|
+
const reader = createLineReader((line) => seen.push(line))
|
|
317
|
+
reader.push("one\ntw")
|
|
318
|
+
expect(seen).toEqual(["one"])
|
|
319
|
+
reader.push("o\nthree")
|
|
320
|
+
expect(seen).toEqual(["one", "two"])
|
|
321
|
+
reader.flush()
|
|
322
|
+
reader.flush()
|
|
323
|
+
expect(seen).toEqual(["one", "two", "three"])
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe("wireServerOutput after the banner", () => {
|
|
328
|
+
let logged: string[]
|
|
329
|
+
|
|
330
|
+
beforeEach(() => {
|
|
331
|
+
vi.useFakeTimers()
|
|
332
|
+
logged = []
|
|
333
|
+
vi.spyOn(console, "log").mockImplementation((line: string) => {
|
|
334
|
+
logged.push(plain(String(line)) as string)
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
afterEach(() => {
|
|
338
|
+
vi.useRealTimers()
|
|
339
|
+
vi.restoreAllMocks()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("prints the next step after the notices, then live events as they happen", () => {
|
|
343
|
+
const child = fakeChild()
|
|
344
|
+
wireServerOutput(child)
|
|
345
|
+
child.stdout.write("8:00:53 pm [vite] (client) a startup notice\n")
|
|
346
|
+
child.stdout.write(`${BANNER_START}\n Local http://localhost:3005\n`)
|
|
347
|
+
child.stdout.write(`${NEXT_STEP}Run catalyst build next\n${BANNER_END}\n${BANNER_END}\n`)
|
|
348
|
+
vi.advanceTimersByTime(300)
|
|
349
|
+
expect(logged).toEqual([
|
|
350
|
+
" Local http://localhost:3005",
|
|
351
|
+
"\n Notices",
|
|
352
|
+
" • client a startup notice",
|
|
353
|
+
"\nRun catalyst build next\n",
|
|
354
|
+
])
|
|
355
|
+
|
|
356
|
+
child.stderr.write("Error: request failed\n")
|
|
357
|
+
expect(logged.at(-1)).toBe(" ✗ request failed")
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it("flushes a trailing partial line when the server exits", () => {
|
|
361
|
+
const child = fakeChild()
|
|
362
|
+
const { flushRemaining } = wireServerOutput(child)
|
|
363
|
+
child.stderr.write("Error: no newline at the end")
|
|
364
|
+
expect(logged).toEqual([])
|
|
365
|
+
flushRemaining()
|
|
366
|
+
expect(logged).toEqual([" ✗ no newline at the end"])
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe("superviseServer (catalyst start)", () => {
|
|
371
|
+
let stderr: string
|
|
372
|
+
let logged: string[]
|
|
373
|
+
let added: { signal: "SIGTERM" | "SIGHUP"; listener: () => void }[]
|
|
374
|
+
let exit: ReturnType<typeof vi.fn>
|
|
375
|
+
|
|
376
|
+
const supervise = (child: ReturnType<typeof fakeChild>) => {
|
|
377
|
+
const before = {
|
|
378
|
+
SIGTERM: process.listeners("SIGTERM"),
|
|
379
|
+
SIGHUP: process.listeners("SIGHUP"),
|
|
380
|
+
}
|
|
381
|
+
const state = superviseServer(child, { scope: "dev server", label: "dev server" })
|
|
382
|
+
for (const signal of ["SIGTERM", "SIGHUP"] as const) {
|
|
383
|
+
for (const listener of process.listeners(signal)) {
|
|
384
|
+
if (!before[signal].includes(listener)) {
|
|
385
|
+
added.push({ signal, listener: listener as () => void })
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return state
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
beforeEach(() => {
|
|
393
|
+
stderr = ""
|
|
394
|
+
logged = []
|
|
395
|
+
added = []
|
|
396
|
+
// Thrown, as the real exit would stop execution there.
|
|
397
|
+
exit = vi.fn((code?: number) => {
|
|
398
|
+
throw new Error(`exit ${code}`)
|
|
399
|
+
})
|
|
400
|
+
vi.spyOn(process, "exit").mockImplementation(exit as never)
|
|
401
|
+
vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string) => {
|
|
402
|
+
stderr += plain(String(chunk))
|
|
403
|
+
return true
|
|
404
|
+
}) as never)
|
|
405
|
+
vi.spyOn(console, "log").mockImplementation((line: string) => {
|
|
406
|
+
logged.push(plain(String(line)) as string)
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
afterEach(() => {
|
|
410
|
+
vi.restoreAllMocks()
|
|
411
|
+
for (const { signal, listener } of added) process.removeListener(signal, listener)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it("forwards SIGTERM and SIGHUP to the child", () => {
|
|
415
|
+
const child = fakeChild()
|
|
416
|
+
supervise(child)
|
|
417
|
+
expect(added.map((entry) => entry.signal)).toEqual(["SIGTERM", "SIGHUP"])
|
|
418
|
+
for (const { listener } of added) listener()
|
|
419
|
+
expect(child.kill).toHaveBeenCalledWith("SIGTERM")
|
|
420
|
+
expect(child.kill).toHaveBeenCalledWith("SIGHUP")
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it("reports a spawn failure and exits 1", () => {
|
|
424
|
+
const child = fakeChild()
|
|
425
|
+
supervise(child)
|
|
426
|
+
expect(() => child.emit("error", new Error("spawn node ENOENT"))).toThrow("exit 1")
|
|
427
|
+
expect(stderr).toMatch(/Could not start the dev server: spawn node ENOENT/)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it("treats Ctrl-C as a clean stop", () => {
|
|
431
|
+
const child = fakeChild()
|
|
432
|
+
supervise(child)
|
|
433
|
+
expect(() => child.emit("close", null, "SIGINT")).toThrow("exit 0")
|
|
434
|
+
expect(stderr).toBe("")
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it("exits 0 when the server exits cleanly", () => {
|
|
438
|
+
const child = fakeChild()
|
|
439
|
+
supervise(child)
|
|
440
|
+
expect(() => child.emit("close", 0, null)).toThrow("exit 0")
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it("renders the last error once, as a diagnostic with its hint", () => {
|
|
444
|
+
const child = fakeChild()
|
|
445
|
+
supervise(child)
|
|
446
|
+
child.stderr.write("Error: listen EADDRINUSE: address already in use :::3005\n")
|
|
447
|
+
expect(() => child.emit("close", 1, null)).toThrow("exit 1")
|
|
448
|
+
expect(stderr).toMatch(/listen EADDRINUSE: address already in use :::3005/)
|
|
449
|
+
expect(stderr).toMatch(/Something is already on/)
|
|
450
|
+
// The one-line form is dropped, so the error is not said twice.
|
|
451
|
+
expect(logged.join("\n")).not.toMatch(/EADDRINUSE/)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it("still says something when a crash printed nothing", () => {
|
|
455
|
+
const child = fakeChild()
|
|
456
|
+
supervise(child)
|
|
457
|
+
expect(() => child.emit("close", null, "SIGKILL")).toThrow("exit 1")
|
|
458
|
+
expect(stderr).toMatch(/The server exited unexpectedly \(code null\)/)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it("adds nothing when the server already printed its own diagnostic", () => {
|
|
462
|
+
const child = fakeChild()
|
|
463
|
+
const state = supervise(child)
|
|
464
|
+
child.stderr.write("\n error dev server\n Could not load config\n")
|
|
465
|
+
expect(() => child.emit("close", 2, null)).toThrow("exit 2")
|
|
466
|
+
expect(state.sawDiagnostic).toBe(true)
|
|
467
|
+
expect(stderr).toBe("")
|
|
468
|
+
expect(logged.join("\n")).toMatch(/error dev server[\s\S]*Could not load config/)
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
describe("ensurePortFree", () => {
|
|
473
|
+
const net: typeof import("node:net") = load("node:net")
|
|
474
|
+
const childProcess: typeof import("node:child_process") = load("node:child_process")
|
|
475
|
+
let stderr: string
|
|
476
|
+
let exit: ReturnType<typeof vi.spyOn>
|
|
477
|
+
|
|
478
|
+
// A server whose listen() reports the given outcome, with no socket opened.
|
|
479
|
+
const fakeServer = (outcome: { error?: { code: string } }) => {
|
|
480
|
+
const server = Object.assign(new EventEmitter(), {
|
|
481
|
+
listen: vi.fn(() => {
|
|
482
|
+
if (outcome.error) server.emit("error", outcome.error)
|
|
483
|
+
else server.emit("listening")
|
|
484
|
+
}),
|
|
485
|
+
close: vi.fn((done: () => void) => done()),
|
|
486
|
+
})
|
|
487
|
+
vi.spyOn(net, "createServer").mockReturnValue(server as unknown as import("node:net").Server)
|
|
488
|
+
return server
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
beforeEach(() => {
|
|
492
|
+
stderr = ""
|
|
493
|
+
exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never)
|
|
494
|
+
vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string) => {
|
|
495
|
+
stderr += plain(String(chunk))
|
|
496
|
+
return true
|
|
497
|
+
}) as never)
|
|
498
|
+
})
|
|
499
|
+
afterEach(() => {
|
|
500
|
+
vi.restoreAllMocks()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it("skips the probe for a missing or invalid port", async () => {
|
|
504
|
+
const createServer = vi.spyOn(net, "createServer")
|
|
505
|
+
await ensurePortFree({ port: undefined, scope: "server" })
|
|
506
|
+
await ensurePortFree({ port: "abc", scope: "server" })
|
|
507
|
+
await ensurePortFree({ port: 0, scope: "server" })
|
|
508
|
+
expect(createServer).not.toHaveBeenCalled()
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it("resolves once a free port has been probed and released", async () => {
|
|
512
|
+
const server = fakeServer({})
|
|
513
|
+
await ensurePortFree({ port: "3005", host: "127.0.0.1", scope: "server" })
|
|
514
|
+
expect(server.listen).toHaveBeenCalledWith({ port: 3005, host: "127.0.0.1" })
|
|
515
|
+
expect(server.close).toHaveBeenCalled()
|
|
516
|
+
expect(exit).not.toHaveBeenCalled()
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it("lets the server report errors other than a taken port", async () => {
|
|
520
|
+
fakeServer({ error: { code: "EACCES" } })
|
|
521
|
+
await ensurePortFree({ port: 80, scope: "server" })
|
|
522
|
+
expect(exit).not.toHaveBeenCalled()
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it("names the process holding a taken port", () => {
|
|
526
|
+
fakeServer({ error: { code: "EADDRINUSE" } })
|
|
527
|
+
const lsof = vi.spyOn(childProcess, "execFileSync").mockReturnValue("p4242\ncnode\n")
|
|
528
|
+
void ensurePortFree({ port: 3005, scope: "dev server" })
|
|
529
|
+
expect(lsof).toHaveBeenCalledWith("lsof", expect.arrayContaining(["-iTCP:3005"]), expect.anything())
|
|
530
|
+
expect(stderr).toMatch(/Port 3005 is already in use by node \(pid 4242\)/)
|
|
531
|
+
expect(stderr).toMatch(/kill 4242/)
|
|
532
|
+
expect(exit).toHaveBeenCalledWith(1)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it("falls back to a generic owner and hint", () => {
|
|
536
|
+
fakeServer({ error: { code: "EADDRINUSE" } })
|
|
537
|
+
vi.spyOn(childProcess, "execFileSync").mockReturnValue("p77\n")
|
|
538
|
+
void ensurePortFree({ port: 3005, scope: "server" })
|
|
539
|
+
expect(stderr).toMatch(/in use by another process \(pid 77\)/)
|
|
540
|
+
|
|
541
|
+
stderr = ""
|
|
542
|
+
vi.spyOn(childProcess, "execFileSync").mockImplementation(() => {
|
|
543
|
+
throw new Error("lsof: not found")
|
|
544
|
+
})
|
|
545
|
+
void ensurePortFree({ port: 3006, scope: "server" })
|
|
546
|
+
expect(stderr).toMatch(/Port 3006 is already in use\n/)
|
|
547
|
+
expect(stderr).toMatch(/Stop whatever is using it/)
|
|
548
|
+
})
|
|
549
|
+
})
|