catalyst-core 1.0.1 → 1.0.2-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. package/dist/scripts/cleanCache.js +0 -30
@@ -0,0 +1,556 @@
1
+ import { EventEmitter } from "events"
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
+
4
+ // otel.js logs through the app-wide `logger`, which only exists once the app
5
+ // calls configureLogger() from catalyst-core/logger. Apps that never did hit
6
+ // `ReferenceError: logger is not defined` inside init(), so the SDK never
7
+ // started and no traces were exported. These tests pin the console fallback.
8
+ //
9
+ // The @opentelemetry/* packages are optional and not installed in this
10
+ // package, so each import otel.js makes is replaced with a minimal fake.
11
+ // That is enough for init() to run its real control flow end to end. The
12
+ // fakes record what they were constructed with, so the tests can assert on
13
+ // the SDK configuration and the spans otel.js produces.
14
+
15
+ const fake = vi.hoisted(() => {
16
+ type Span = {
17
+ name: string
18
+ options?: any
19
+ parent?: any
20
+ attributes: Record<string, unknown>
21
+ exceptions: unknown[]
22
+ status?: { code: number; message: string }
23
+ ended: number
24
+ setAttribute: (key: string, value: unknown) => void
25
+ recordException: (error: unknown) => void
26
+ setStatus: (status: { code: number; message: string }) => void
27
+ end: () => void
28
+ }
29
+ const state = {
30
+ spans: [] as Span[],
31
+ tracerNames: [] as string[],
32
+ isBot: undefined as unknown,
33
+ activeContext: { id: "request-context" } as any,
34
+ gauges: [] as { name: string; options: any; callbacks: Function[] }[],
35
+ meterArgs: [] as unknown[][],
36
+ }
37
+ const makeSpan = (name: string, options?: any, parent?: any): Span => {
38
+ const span: Span = {
39
+ name,
40
+ options,
41
+ parent,
42
+ attributes: {},
43
+ exceptions: [],
44
+ ended: 0,
45
+ setAttribute: (key, value) => {
46
+ span.attributes[key] = value
47
+ },
48
+ recordException: (error) => {
49
+ span.exceptions.push(error)
50
+ },
51
+ setStatus: (status) => {
52
+ span.status = status
53
+ },
54
+ end: () => {
55
+ span.ended += 1
56
+ },
57
+ }
58
+ state.spans.push(span)
59
+ return span
60
+ }
61
+ const record = (kind: string) =>
62
+ class {
63
+ kind = kind
64
+ args: unknown[]
65
+ constructor(...args: unknown[]) {
66
+ this.args = args
67
+ }
68
+ }
69
+ return { state, makeSpan, record }
70
+ })
71
+
72
+ vi.mock("@opentelemetry/api", () => ({
73
+ metrics: {
74
+ getMeter: (...args: unknown[]) => {
75
+ fake.state.meterArgs.push(args)
76
+ return {
77
+ createObservableGauge: (name: string, options: any) => {
78
+ const gauge = { name, options, callbacks: [] as Function[] }
79
+ fake.state.gauges.push(gauge)
80
+ return { addCallback: (cb: Function) => gauge.callbacks.push(cb) }
81
+ },
82
+ }
83
+ },
84
+ },
85
+ trace: {
86
+ getTracer: (name: string) => {
87
+ fake.state.tracerNames.push(name)
88
+ return {
89
+ startActiveSpan: (spanName: string, fn: (span: unknown) => unknown) =>
90
+ fn(fake.makeSpan(spanName)),
91
+ startSpan: (spanName: string, options: unknown, parent: unknown) =>
92
+ fake.makeSpan(spanName, options, parent),
93
+ }
94
+ },
95
+ },
96
+ context: {
97
+ active: () => ({
98
+ ...fake.state.activeContext,
99
+ getValue: (key: symbol) => (key.description === "catalyst.is_bot" ? fake.state.isBot : undefined),
100
+ }),
101
+ },
102
+ createContextKey: (name: string) => Symbol(name),
103
+ }))
104
+ vi.mock("@opentelemetry/sdk-node", () => ({
105
+ NodeSDK: class {
106
+ config: any
107
+ started = false
108
+ shutdown = vi.fn(() => Promise.resolve())
109
+ constructor(config: any) {
110
+ this.config = config
111
+ }
112
+ start() {
113
+ this.started = true
114
+ }
115
+ },
116
+ }))
117
+ vi.mock("@opentelemetry/resources", () => ({ resourceFromAttributes: (attrs: object) => attrs }))
118
+ vi.mock("@opentelemetry/sdk-trace-node", () => ({
119
+ BatchSpanProcessor: fake.record("BatchSpanProcessor"),
120
+ TraceIdRatioBasedSampler: fake.record("TraceIdRatioBasedSampler"),
121
+ ParentBasedSampler: fake.record("ParentBasedSampler"),
122
+ }))
123
+ vi.mock("@opentelemetry/sdk-metrics", () => ({
124
+ PeriodicExportingMetricReader: fake.record("PeriodicExportingMetricReader"),
125
+ }))
126
+ vi.mock("@opentelemetry/exporter-trace-otlp-grpc", () => ({ OTLPTraceExporter: fake.record("trace-grpc") }))
127
+ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ OTLPTraceExporter: fake.record("trace-http") }))
128
+ vi.mock("@opentelemetry/exporter-metrics-otlp-grpc", () => ({
129
+ OTLPMetricExporter: fake.record("metric-grpc"),
130
+ }))
131
+ vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
132
+ OTLPMetricExporter: fake.record("metric-http"),
133
+ }))
134
+ vi.mock("@opentelemetry/auto-instrumentations-node", () => ({
135
+ getNodeAutoInstrumentations: () => ({ auto: true }),
136
+ }))
137
+ vi.mock("@opentelemetry/semantic-conventions", () => ({
138
+ default: {
139
+ ATTR_SERVICE_NAME: "service.name",
140
+ ATTR_SERVICE_VERSION: "service.version",
141
+ ATTR_DEPLOYMENT_ENVIRONMENT: "deployment.environment",
142
+ },
143
+ }))
144
+
145
+ import Otel, {
146
+ IS_BOT_KEY,
147
+ responseFlushMiddleware,
148
+ withObservability,
149
+ withSyncObservability,
150
+ } from "../../src/otel.js"
151
+
152
+ const g = globalThis as any
153
+
154
+ beforeEach(() => {
155
+ fake.state.spans.length = 0
156
+ fake.state.tracerNames.length = 0
157
+ fake.state.gauges.length = 0
158
+ fake.state.meterArgs.length = 0
159
+ fake.state.isBot = undefined
160
+ })
161
+
162
+ describe("otel init()", () => {
163
+ let originalEnv: NodeJS.ProcessEnv
164
+ let signalListeners: Record<string, Function[]>
165
+
166
+ // The SIGTERM/SIGINT handlers init() registered in this test.
167
+ const addedListener = (signal: "SIGTERM" | "SIGINT") =>
168
+ process.listeners(signal).find((listener) => !signalListeners[signal].includes(listener)) as Function
169
+
170
+ beforeEach(() => {
171
+ originalEnv = process.env
172
+ // Catalyst keeps config.json's real boolean here; assigning through the
173
+ // process.env setter would coerce it to the string "true".
174
+ process.env = { ...process.env, OTEL_ENABLE: true as any }
175
+ signalListeners = { SIGTERM: process.listeners("SIGTERM"), SIGINT: process.listeners("SIGINT") }
176
+ delete g.logger
177
+ vi.spyOn(console, "info").mockImplementation(() => {})
178
+ vi.spyOn(console, "error").mockImplementation(() => {})
179
+ })
180
+
181
+ afterEach(() => {
182
+ process.env = originalEnv
183
+ delete g.logger
184
+ // init() registers SIGTERM/SIGINT shutdown handlers; drop the ones it added.
185
+ for (const signal of ["SIGTERM", "SIGINT"] as const) {
186
+ for (const listener of process.listeners(signal)) {
187
+ if (!signalListeners[signal].includes(listener)) process.off(signal, listener as any)
188
+ }
189
+ }
190
+ vi.restoreAllMocks()
191
+ })
192
+
193
+ describe("logger fallback", () => {
194
+ it("starts and logs to console when the app never configured a logger", () => {
195
+ expect(g.logger).toBeUndefined()
196
+
197
+ const { sdk } = Otel.init()
198
+
199
+ expect(sdk).not.toBeNull()
200
+ expect(console.info).toHaveBeenCalledWith("✅ OpenTelemetry started successfully")
201
+ })
202
+
203
+ it("logs through the app logger when one is configured", () => {
204
+ g.logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn() }
205
+
206
+ Otel.init()
207
+
208
+ expect(g.logger.info).toHaveBeenCalledWith("✅ OpenTelemetry started successfully")
209
+ expect(console.info).not.toHaveBeenCalled()
210
+ })
211
+
212
+ it("still throws the original error, not a ReferenceError, when init fails", () => {
213
+ expect(() => Otel.init({ traceProtocol: "udp" })).toThrow(/Unsupported trace protocol: udp/)
214
+ expect(console.error).toHaveBeenCalledWith(
215
+ "❌ Failed to initialize OpenTelemetry:",
216
+ expect.any(Error)
217
+ )
218
+ })
219
+ })
220
+
221
+ it("does nothing unless OTEL_ENABLE is the boolean true", () => {
222
+ const before = process.listeners("SIGTERM").length
223
+ for (const value of [undefined, "true", false]) {
224
+ process.env = { ...originalEnv, OTEL_ENABLE: value as any }
225
+ expect(Otel.init()).toEqual({ sdk: null, meter: null })
226
+ }
227
+ expect(process.listeners("SIGTERM")).toHaveLength(before)
228
+ expect(console.info).not.toHaveBeenCalled()
229
+ })
230
+
231
+ it("defaults to a gRPC trace exporter on localhost with metrics off", () => {
232
+ const { sdk, meter } = Otel.init() as any
233
+
234
+ expect(sdk.started).toBe(true)
235
+ expect(meter).toBeNull()
236
+ expect(sdk.config.resource).toEqual({
237
+ "service.name": "catalyst-server",
238
+ "service.version": "1.0.0",
239
+ "deployment.environment": "development",
240
+ })
241
+ const [exporter, batchConfig] = sdk.config.spanProcessor.args
242
+ expect(exporter.kind).toBe("trace-grpc")
243
+ expect(exporter.args[0]).toEqual({
244
+ url: "http://localhost:4317",
245
+ headers: {},
246
+ credentials: undefined,
247
+ })
248
+ expect(batchConfig).toEqual({})
249
+ expect(sdk.config.instrumentations).toEqual([{ auto: true }])
250
+ expect(sdk.config.sampler.args[0].root.args).toEqual([1.0])
251
+ expect(sdk.config).not.toHaveProperty("metricReader")
252
+ expect(fake.state.gauges).toHaveLength(0)
253
+ expect(console.info).toHaveBeenCalledWith(
254
+ "📡 Creating gRPC trace exporter for URL: http://localhost:4317"
255
+ )
256
+ })
257
+
258
+ it("passes service identity, HTTP exporter options, sampling and instrumentations through", () => {
259
+ const instrumentations = [{ custom: true }]
260
+ const { sdk } = Otel.init({
261
+ serviceName: "pharmacy-web",
262
+ serviceVersion: "4.2.0",
263
+ environment: "production",
264
+ traceUrl: "https://collector/v1/traces",
265
+ traceProtocol: "HTTP",
266
+ traceHeaders: { authorization: "token" },
267
+ batchProcessorConfig: { maxQueueSize: 10 },
268
+ samplingRate: 0.25,
269
+ instrumentations,
270
+ }) as any
271
+
272
+ expect(sdk.config.resource["service.name"]).toBe("pharmacy-web")
273
+ expect(sdk.config.resource["service.version"]).toBe("4.2.0")
274
+ expect(sdk.config.resource["deployment.environment"]).toBe("production")
275
+ const [exporter, batchConfig] = sdk.config.spanProcessor.args
276
+ expect(exporter.kind).toBe("trace-http")
277
+ expect(exporter.args[0]).toEqual({
278
+ url: "https://collector/v1/traces",
279
+ headers: { authorization: "token" },
280
+ })
281
+ expect(batchConfig).toEqual({ maxQueueSize: 10 })
282
+ expect(sdk.config.sampler.args[0].root.args).toEqual([0.25])
283
+ expect(sdk.config.instrumentations).toBe(instrumentations)
284
+ })
285
+
286
+ it.each([
287
+ ["http", "metric-http", { url: "https://collector/v1/metrics", headers: { k: "v" } }],
288
+ [
289
+ "grpc",
290
+ "metric-grpc",
291
+ { url: "https://collector/v1/metrics", headers: { k: "v" }, credentials: "creds" },
292
+ ],
293
+ ])("enables %s metrics with a periodic reader and process gauges", (protocol, kind, options) => {
294
+ const { sdk, meter } = Otel.init({
295
+ serviceName: "svc",
296
+ serviceVersion: "2.0.0",
297
+ metricProtocol: protocol,
298
+ metricUrl: "https://collector/v1/metrics",
299
+ metricHeaders: { k: "v" },
300
+ exportIntervalMillis: 5000,
301
+ grpcCredentials: "creds",
302
+ }) as any
303
+
304
+ const readerOptions = sdk.config.metricReader.args[0]
305
+ expect(readerOptions.exportIntervalMillis).toBe(5000)
306
+ expect(readerOptions.exporter.kind).toBe(kind)
307
+ expect(readerOptions.exporter.args[0]).toEqual(options)
308
+ expect(meter).not.toBeNull()
309
+ expect(fake.state.meterArgs).toEqual([["svc", "2.0.0"]])
310
+ expect(fake.state.gauges.map((gauge) => gauge.name)).toEqual([
311
+ "process_cpu_usage_percent",
312
+ "process_memory_usage_bytes",
313
+ "process_memory_heap_used_bytes",
314
+ "process_memory_heap_total_bytes",
315
+ ])
316
+ })
317
+
318
+ it("reports CPU and memory from the gauge callbacks", () => {
319
+ Otel.init({ metricProtocol: "grpc" })
320
+ const observe = (name: string) => {
321
+ const result = { observe: vi.fn() }
322
+ const gauge = fake.state.gauges.find((entry) => entry.name === name)
323
+ gauge.callbacks.forEach((callback) => callback(result))
324
+ return result.observe.mock.calls
325
+ }
326
+
327
+ const cpu = observe("process_cpu_usage_percent")
328
+ expect(cpu).toHaveLength(1)
329
+ expect(typeof cpu[0][0]).toBe("number")
330
+
331
+ const memory = observe("process_memory_usage_bytes")
332
+ expect(memory.map((call) => call[1])).toEqual([
333
+ { type: "rss" },
334
+ { type: "external" },
335
+ { type: "arrayBuffers" },
336
+ ])
337
+ expect(memory[0][0]).toBeGreaterThan(0)
338
+
339
+ expect(observe("process_memory_heap_used_bytes")[0][0]).toBeGreaterThan(0)
340
+ expect(observe("process_memory_heap_total_bytes")[0][0]).toBeGreaterThan(0)
341
+ })
342
+
343
+ it("rejects an unsupported metric protocol", () => {
344
+ expect(() => Otel.init({ metricProtocol: "statsd" })).toThrow(/Unsupported metric protocol: statsd/)
345
+ })
346
+
347
+ it("shuts the SDK down and exits on SIGTERM and SIGINT", async () => {
348
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as any)
349
+ const { sdk } = Otel.init() as any
350
+
351
+ addedListener("SIGTERM")()
352
+ expect(console.info).toHaveBeenCalledWith(
353
+ "📡 Received SIGTERM, shutting down OpenTelemetry gracefully..."
354
+ )
355
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(1))
356
+ expect(sdk.shutdown).toHaveBeenCalledTimes(1)
357
+ expect(console.info).toHaveBeenCalledWith("✅ OpenTelemetry shutdown completed")
358
+
359
+ addedListener("SIGINT")()
360
+ expect(console.info).toHaveBeenCalledWith(
361
+ "📡 Received SIGINT, shutting down OpenTelemetry gracefully..."
362
+ )
363
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(2))
364
+ })
365
+
366
+ it("logs a failed shutdown and still exits", async () => {
367
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as any)
368
+ const { sdk } = Otel.init() as any
369
+ const failure = new Error("exporter unreachable")
370
+ sdk.shutdown.mockReturnValueOnce(Promise.reject(failure))
371
+
372
+ addedListener("SIGTERM")()
373
+
374
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(1))
375
+ expect(console.error).toHaveBeenCalledWith("❌ Error terminating OpenTelemetry:", failure)
376
+ })
377
+ })
378
+
379
+ describe("withSyncObservability", () => {
380
+ it("returns the result synchronously inside one span named after the function", () => {
381
+ function priceFor(id: string, qty: number) {
382
+ return `${id}x${qty}`
383
+ }
384
+ const wrapped = withSyncObservability("svc", priceFor, undefined)
385
+
386
+ expect(wrapped("sku", 2)).toBe("skux2")
387
+ expect(fake.state.tracerNames).toEqual(["svc"])
388
+ expect(fake.state.spans).toHaveLength(1)
389
+ expect(fake.state.spans[0]).toMatchObject({ name: "priceFor", ended: 1, attributes: {} })
390
+ })
391
+
392
+ it("prefers an explicit name and falls back to anonymousFunction", () => {
393
+ withSyncObservability("svc", () => 1, "custom.span")()
394
+ withSyncObservability("svc", [() => 1][0], undefined)()
395
+ expect(fake.state.spans.map((span) => span.name)).toEqual(["custom.span", "anonymousFunction"])
396
+ })
397
+
398
+ it("tags the span with the request's bot flag when it is known", () => {
399
+ fake.state.isBot = false
400
+ withSyncObservability("svc", () => 1, "render")()
401
+ expect(fake.state.spans[0].attributes).toEqual({ "http.response.is_bot": false })
402
+ expect(IS_BOT_KEY.description).toBe("catalyst.is_bot")
403
+ })
404
+
405
+ it("records the exception, marks the span as an error, and rethrows", () => {
406
+ const failure = new Error("render failed")
407
+ const wrapped = withSyncObservability(
408
+ "svc",
409
+ () => {
410
+ throw failure
411
+ },
412
+ undefined
413
+ )
414
+
415
+ expect(() => wrapped()).toThrow(failure)
416
+ const [span] = fake.state.spans
417
+ expect(span.exceptions).toEqual([failure])
418
+ expect(span.status).toEqual({ code: 2, message: "render failed" })
419
+ expect(span.ended).toBe(1)
420
+ })
421
+ })
422
+
423
+ describe("withObservability", () => {
424
+ it("awaits the wrapped function and ends the span after it settles", async () => {
425
+ fake.state.isBot = true
426
+ let resolve: (value: string) => void
427
+ const pending = new Promise<string>((r) => (resolve = r))
428
+ const wrapped = withObservability(
429
+ "svc",
430
+ async function fetchProducts(page: number) {
431
+ return `${await pending}:${page}`
432
+ },
433
+ undefined
434
+ )
435
+
436
+ const call = wrapped(3)
437
+ expect(fake.state.spans[0].ended).toBe(0)
438
+ resolve("products")
439
+
440
+ await expect(call).resolves.toBe("products:3")
441
+ expect(fake.state.spans[0]).toMatchObject({
442
+ name: "fetchProducts",
443
+ ended: 1,
444
+ attributes: { "http.response.is_bot": true },
445
+ })
446
+ })
447
+
448
+ it("records a rejection and rethrows it", async () => {
449
+ const failure = new Error("upstream 503")
450
+ const wrapped = withObservability(
451
+ "svc",
452
+ async () => {
453
+ throw failure
454
+ },
455
+ "serverFetcher"
456
+ )
457
+
458
+ await expect(wrapped()).rejects.toBe(failure)
459
+ const [span] = fake.state.spans
460
+ expect(span.name).toBe("serverFetcher")
461
+ expect(span.exceptions).toEqual([failure])
462
+ expect(span.status).toEqual({ code: 2, message: "upstream 503" })
463
+ expect(span.ended).toBe(1)
464
+ })
465
+ })
466
+
467
+ describe("responseFlushMiddleware", () => {
468
+ const makeRes = (isBot = false) => {
469
+ const res = new EventEmitter() as any
470
+ res.locals = { is_bot: isBot }
471
+ res.realEnd = vi.fn(function () {
472
+ return res
473
+ })
474
+ res.end = res.realEnd
475
+ return res
476
+ }
477
+ // Stands in for compression(): wraps res.end synchronously and calls the
478
+ // underlying end later, once the body has been compressed.
479
+ const compressionNext = (res: any) => () => {
480
+ const underlying = res.end
481
+ res.end = (...args: unknown[]) => {
482
+ res.flushCompressed = () => underlying.apply(res, args)
483
+ return res
484
+ }
485
+ }
486
+
487
+ it("without compression, times only the flush from res.end() to finish", () => {
488
+ const res = makeRes(true)
489
+ const next = vi.fn()
490
+ responseFlushMiddleware("svc")({}, res, next)
491
+ expect(next).toHaveBeenCalledTimes(1)
492
+
493
+ res.end("body")
494
+ expect(res.realEnd).toHaveBeenCalledWith("body")
495
+ expect(fake.state.spans).toHaveLength(1)
496
+ const [flush] = fake.state.spans
497
+ expect(flush.name).toBe("response.flush")
498
+ expect(flush.options).toEqual({ attributes: { "http.response.is_bot": true } })
499
+ expect(flush.parent).toMatchObject({ id: "request-context" })
500
+
501
+ res.emit("finish")
502
+ res.emit("close")
503
+ expect(flush.attributes).toEqual({ "http.response.end_event": "finish" })
504
+ expect(flush.ended).toBe(1)
505
+ })
506
+
507
+ it("with compression, times compression then egress as sibling spans", () => {
508
+ const res = makeRes()
509
+ responseFlushMiddleware("svc", "egress", "gzip")({}, res, compressionNext(res))
510
+
511
+ res.end("body")
512
+ expect(fake.state.spans.map((span) => span.name)).toEqual(["gzip"])
513
+ const [compress] = fake.state.spans
514
+ expect(compress.ended).toBe(0)
515
+ expect(res.realEnd).not.toHaveBeenCalled()
516
+
517
+ res.flushCompressed()
518
+ expect(compress.ended).toBe(1)
519
+ expect(res.realEnd).toHaveBeenCalledWith("body")
520
+ const flush = fake.state.spans[1]
521
+ expect(flush.name).toBe("egress")
522
+ expect(flush.parent).toBe(compress.parent)
523
+
524
+ res.emit("close")
525
+ expect(flush.attributes).toEqual({ "http.response.end_event": "close" })
526
+ expect(flush.ended).toBe(1)
527
+ expect(compress.attributes).toEqual({})
528
+ })
529
+
530
+ it("tags the compression span when the connection closes mid-compression", () => {
531
+ const res = makeRes()
532
+ responseFlushMiddleware("svc")({}, res, compressionNext(res))
533
+
534
+ res.end("body")
535
+ res.emit("close")
536
+ const [compress] = fake.state.spans
537
+ expect(compress.attributes).toEqual({ "http.response.end_event": "close" })
538
+ expect(compress.ended).toBe(1)
539
+
540
+ // Bytes arriving after the response ended open no further spans.
541
+ res.flushCompressed()
542
+ expect(fake.state.spans).toHaveLength(1)
543
+ })
544
+
545
+ it("emits no span when the client aborts before anything is written", () => {
546
+ const res = makeRes()
547
+ responseFlushMiddleware("svc")({}, res, compressionNext(res))
548
+
549
+ res.emit("close")
550
+ res.end()
551
+ res.flushCompressed()
552
+
553
+ expect(fake.state.spans).toHaveLength(0)
554
+ expect(res.realEnd).toHaveBeenCalledTimes(1)
555
+ })
556
+ })
@@ -197,29 +197,40 @@ describe("handleError output mode", () => {
197
197
  vi.resetModules()
198
198
  })
199
199
 
200
- it("uses the non-'default' formatter branch when CATALYST_OUTPUT_MODE is verbose", async () => {
200
+ it("uses the registry box under CATALYST_OUTPUT_MODE=verbose, even with the CLI renderer installed", async () => {
201
201
  const saved = process.env.CATALYST_OUTPUT_MODE
202
202
  process.env.CATALYST_OUTPUT_MODE = "verbose"
203
203
  vi.resetModules()
204
204
  vi.spyOn(console, "log").mockImplementation(() => {})
205
+ ;(globalThis as any).__catalystRenderRegistryError = () => "cli renderer"
205
206
  try {
206
- // outputMode is resolved at module load -> "verbose" -> the
207
- // else branch of handleError runs (no "Failed to start
208
- // server:" prefix).
207
+ // outputMode is resolved at module load -> "verbose".
209
208
  const mod = await import("../../src/server/utils/validator.js")
210
209
  mod.handleError(createError(ERROR_CODES.PREFLIGHT_GET_ROUTES_MISSING))
211
210
  const logged = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().join("\n")
212
- expect(logged).not.toContain("Failed to start server:")
211
+ expect(logged).toContain(`Code: ${ERROR_CODES.PREFLIGHT_GET_ROUTES_MISSING}`)
212
+ expect(logged).not.toContain("cli renderer")
213
213
  } finally {
214
+ delete (globalThis as any).__catalystRenderRegistryError
214
215
  if (saved === undefined) delete process.env.CATALYST_OUTPUT_MODE
215
216
  else process.env.CATALYST_OUTPUT_MODE = saved
216
217
  }
217
218
  })
218
219
 
219
- it("uses the 'default' branch (with prefix) otherwise", () => {
220
+ it("uses the CLI's renderer by default when the server installed one", () => {
221
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
222
+ ;(globalThis as any).__catalystRenderRegistryError = (e: any, scope: string) => `cli ${e.code} ${scope}`
223
+ try {
224
+ handleError(createError(ERROR_CODES.PREFLIGHT_GET_ROUTES_MISSING))
225
+ expect(logSpy.mock.calls.flat().join("\n")).toBe(`cli ${ERROR_CODES.PREFLIGHT_GET_ROUTES_MISSING} server`)
226
+ } finally {
227
+ delete (globalThis as any).__catalystRenderRegistryError
228
+ }
229
+ })
230
+
231
+ it("falls back to the registry format by default without a renderer (SSR bundle, tests)", () => {
220
232
  const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
221
233
  handleError(createError(ERROR_CODES.PREFLIGHT_GET_ROUTES_MISSING))
222
- const logged = logSpy.mock.calls.flat().join("\n")
223
- expect(logged).toContain("Failed to start server:")
234
+ expect(logSpy.mock.calls.flat().join("\n")).toContain(`[${ERROR_CODES.PREFLIGHT_GET_ROUTES_MISSING}]`)
224
235
  })
225
236
  })
package/vitest.config.ts CHANGED
@@ -76,6 +76,10 @@ export default defineConfig({
76
76
  include: [
77
77
  "test/**/*.test.ts",
78
78
  "src/web-router/**/*.server.test.{js,jsx}",
79
+ // WebMCP's registry/declarative helpers are pure logic
80
+ // (no DOM, no React rendering) -- same rationale as the
81
+ // rest of this project.
82
+ "src/webmcp/**/*.test.js",
79
83
  ],
80
84
  },
81
85
  },
@@ -1,3 +0,0 @@
1
- if (process.versions.node.split(".")[0] < 20) {
2
- console.error("\x1b[31m%s\x1b[0m", "use node version >=20")
3
- }
@@ -1,30 +0,0 @@
1
- const fs = require("fs")
2
- const path = require("path")
3
-
4
- function cleanCache() {
5
- const catalystCacheDir = path.join(process.cwd(), "node_modules/catalyst-core")
6
- const webpackCacheDir = path.join(catalystCacheDir, ".cache/webpack")
7
- const loadableStats = path.join(catalystCacheDir, "loadable-stats.json")
8
-
9
- let cleaned = false
10
-
11
- if (fs.existsSync(webpackCacheDir)) {
12
- fs.rmSync(webpackCacheDir, { recursive: true, force: true })
13
- console.log("[Catalyst] Removed webpack filesystem cache.")
14
- cleaned = true
15
- }
16
-
17
- if (fs.existsSync(loadableStats)) {
18
- fs.rmSync(loadableStats)
19
- console.log("[Catalyst] Removed loadable-stats.json.")
20
- cleaned = true
21
- }
22
-
23
- if (!cleaned) {
24
- console.log("[Catalyst] Nothing to clean — cache is already empty.")
25
- } else {
26
- console.log("[Catalyst] Clean complete. Next dev start will be a full rebuild.")
27
- }
28
- }
29
-
30
- cleanCache()