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,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
|
|
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"
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
})
|
|
@@ -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()
|