@polderlabs/bizar 5.0.2 → 5.1.0
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/bizar-dash/dist/assets/MobileChat-BVojK0n5.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-BVojK0n5.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-Clz0NR6Y.js} +20 -25
- package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +1 -0
- package/bizar-dash/dist/assets/{main-C1cpttnv.js → main-8feQWXiF.js} +10 -10
- package/bizar-dash/dist/assets/main-8feQWXiF.js.map +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-D2pc-iNh.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile-D2pc-iNh.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-IaZ47uKC.js +2 -0
- package/bizar-dash/dist/assets/mobile-IaZ47uKC.js.map +1 -0
- package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
- package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js.map +1 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
- package/bizar-dash/dist/index.html +7 -5
- package/bizar-dash/dist/mobile.html +4 -5
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/eval.mjs +93 -3
- package/bizar-dash/src/server/metrics.mjs +75 -0
- package/bizar-dash/src/server/otel.mjs +141 -14
- package/bizar-dash/src/server/plugins/registry.mjs +145 -43
- package/bizar-dash/src/server/routes/chat.mjs +46 -9
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
- package/bizar-dash/src/server/routes/plugins.mjs +123 -16
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +44 -15
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/package.json +1 -1
- package/templates/eval-fixtures/citation.json +16 -0
- package/templates/eval-fixtures/code-review.json +16 -0
- package/templates/eval-fixtures/concise-output.json +16 -0
- package/templates/eval-fixtures/context-window.json +16 -0
- package/templates/eval-fixtures/error-recovery.json +16 -0
- package/templates/eval-fixtures/json-output.json +20 -0
- package/templates/eval-fixtures/multi-language.json +16 -0
- package/templates/eval-fixtures/safe-paths.json +16 -0
- package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
- package/templates/eval-fixtures/unicode-handling.json +16 -0
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/eval/fixtures-extra.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Tests for expanded fixture set (10 new fixtures).
|
|
5
|
+
*
|
|
6
|
+
* Verifies:
|
|
7
|
+
* - All 15 fixtures load without errors
|
|
8
|
+
* - All fixtures have valid JSON and required fields
|
|
9
|
+
* - runFixtureById correctly resolves and runs fixtures
|
|
10
|
+
* - New expectation types (toolCallsMin, toolSequence, minItems) work
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import assert from 'node:assert';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const EVAL = await import('../../src/server/eval.mjs');
|
|
20
|
+
|
|
21
|
+
const FIXTURES_DIR = join(__dirname, '../../../templates/eval-fixtures');
|
|
22
|
+
|
|
23
|
+
const REQUIRED_FIELDS = ['id', 'name', 'description', 'agent', 'prompt', 'expected', 'tags'];
|
|
24
|
+
const REQUIRED_EXPECTED_FIELDS = ['contains', 'notContains', 'regex', 'jsonSchema', 'maxTokens', 'maxLatencyMs'];
|
|
25
|
+
|
|
26
|
+
// Expected fixture IDs - the new 10 fixtures
|
|
27
|
+
const NEW_FIXTURE_IDS = [
|
|
28
|
+
'tool-call-multi-step',
|
|
29
|
+
'error-recovery',
|
|
30
|
+
'context-window',
|
|
31
|
+
'json-output',
|
|
32
|
+
'code-review',
|
|
33
|
+
'unicode-handling',
|
|
34
|
+
'multi-language',
|
|
35
|
+
'safe-paths',
|
|
36
|
+
'concise-output',
|
|
37
|
+
'citation',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
describe('eval fixtures (extra 10)', () => {
|
|
41
|
+
|
|
42
|
+
describe('loadFixtures loads all 15 fixtures', () => {
|
|
43
|
+
it('loads at least 15 fixtures from the directory', async () => {
|
|
44
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
45
|
+
assert.ok(fixtures.length >= 15, `Expected at least 15 fixtures, got ${fixtures.length}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('loads all expected fixture IDs', async () => {
|
|
49
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
50
|
+
const ids = fixtures.map((f) => f.id);
|
|
51
|
+
for (const expectedId of NEW_FIXTURE_IDS) {
|
|
52
|
+
assert.ok(
|
|
53
|
+
ids.includes(expectedId),
|
|
54
|
+
`Expected fixture ID "${expectedId}" not found in: ${ids.join(', ')}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('each new fixture validates correctly', () => {
|
|
61
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
62
|
+
const newFixtures = fixtures.filter((f) =>
|
|
63
|
+
NEW_FIXTURE_IDS.includes(f.id)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
for (const fixture of newFixtures) {
|
|
67
|
+
it(`fixture "${fixture.id}" has valid JSON structure`, () => {
|
|
68
|
+
// Required fields
|
|
69
|
+
for (const field of REQUIRED_FIELDS) {
|
|
70
|
+
assert.ok(
|
|
71
|
+
fixture[field] !== undefined && fixture[field] !== null,
|
|
72
|
+
`fixture "${fixture.id}" missing required field: ${field}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// expected must be an object
|
|
77
|
+
assert.ok(
|
|
78
|
+
typeof fixture.expected === 'object',
|
|
79
|
+
`fixture "${fixture.id}".expected must be an object`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// tags must be an array
|
|
83
|
+
assert.ok(
|
|
84
|
+
Array.isArray(fixture.tags),
|
|
85
|
+
`fixture "${fixture.id}".tags must be an array`,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it(`fixture "${fixture.id}" has at least one check type`, () => {
|
|
90
|
+
const expected = fixture.expected;
|
|
91
|
+
const hasCheckType = [
|
|
92
|
+
expected.contains,
|
|
93
|
+
expected.notContains,
|
|
94
|
+
expected.regex,
|
|
95
|
+
expected.jsonSchema,
|
|
96
|
+
expected.maxTokens,
|
|
97
|
+
expected.maxLatencyMs,
|
|
98
|
+
expected.toolCallsMin,
|
|
99
|
+
expected.toolSequence,
|
|
100
|
+
].some((v) => v !== undefined && v !== null);
|
|
101
|
+
assert.ok(hasCheckType, `fixture "${fixture.id}" expected must have at least one check type`);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('runFixtureById', () => {
|
|
107
|
+
it('resolves and runs a fixture by id', async () => {
|
|
108
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
109
|
+
const mockLlmCall = async () => ({
|
|
110
|
+
content: 'ok',
|
|
111
|
+
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
|
112
|
+
});
|
|
113
|
+
const result = await EVAL.runFixtureById('latency-bounds', fixtures, {
|
|
114
|
+
llmCall: mockLlmCall,
|
|
115
|
+
timeoutMs: 5000,
|
|
116
|
+
});
|
|
117
|
+
assert.strictEqual(result.fixtureId, 'latency-bounds');
|
|
118
|
+
assert.ok(result.checks.length > 0, 'should have run checks');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws for unknown fixture id', async () => {
|
|
122
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
123
|
+
const mockLlmCall = async () => ({ content: 'ok', usage: { totalTokens: 0 } });
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
() => EVAL.runFixtureById('nonexistent-fixture', fixtures, { llmCall: mockLlmCall }),
|
|
126
|
+
/Fixture not found/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('toolCallsMin check', () => {
|
|
132
|
+
it('passes when tool calls meet minimum', async () => {
|
|
133
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
134
|
+
const mockLlmCall = async () => ({
|
|
135
|
+
content: 'found it',
|
|
136
|
+
usage: { totalTokens: 100 },
|
|
137
|
+
});
|
|
138
|
+
const result = await EVAL.runFixtureById('tool-call-multi-step', fixtures, {
|
|
139
|
+
llmCall: mockLlmCall,
|
|
140
|
+
timeoutMs: 5000,
|
|
141
|
+
toolCalls: ['search', 'read', 'search'],
|
|
142
|
+
});
|
|
143
|
+
// The toolCallsMin check should be in the results
|
|
144
|
+
const toolCallsMinCheck = result.checks.find((c) => c.kind === 'toolCallsMin');
|
|
145
|
+
assert.ok(toolCallsMinCheck, 'should have a toolCallsMin check');
|
|
146
|
+
assert.ok(toolCallsMinCheck.ok, `toolCallsMin check should pass: ${toolCallsMinCheck.message}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('fails when tool calls are below minimum', async () => {
|
|
150
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
151
|
+
const mockLlmCall = async () => ({
|
|
152
|
+
content: 'found it',
|
|
153
|
+
usage: { totalTokens: 100 },
|
|
154
|
+
});
|
|
155
|
+
const result = await EVAL.runFixtureById('tool-call-multi-step', fixtures, {
|
|
156
|
+
llmCall: mockLlmCall,
|
|
157
|
+
timeoutMs: 5000,
|
|
158
|
+
toolCalls: ['search'], // only 1, requires 3
|
|
159
|
+
});
|
|
160
|
+
const toolCallsMinCheck = result.checks.find((c) => c.kind === 'toolCallsMin');
|
|
161
|
+
assert.ok(toolCallsMinCheck, 'should have a toolCallsMin check');
|
|
162
|
+
assert.ok(!toolCallsMinCheck.ok, 'toolCallsMin check should fail');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('toolSequence check', () => {
|
|
167
|
+
it('passes when tool sequence matches', async () => {
|
|
168
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
169
|
+
const mockLlmCall = async () => ({
|
|
170
|
+
content: 'found it',
|
|
171
|
+
usage: { totalTokens: 100 },
|
|
172
|
+
});
|
|
173
|
+
const result = await EVAL.runFixtureById('tool-call-multi-step', fixtures, {
|
|
174
|
+
llmCall: mockLlmCall,
|
|
175
|
+
timeoutMs: 5000,
|
|
176
|
+
toolCalls: ['search', 'read', 'search'],
|
|
177
|
+
});
|
|
178
|
+
const seqCheck = result.checks.find((c) => c.kind === 'toolSequence');
|
|
179
|
+
assert.ok(seqCheck, 'should have a toolSequence check');
|
|
180
|
+
assert.ok(seqCheck.ok, `toolSequence check should pass: ${seqCheck.message}`);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('fails when tool sequence does not match', async () => {
|
|
184
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
185
|
+
const mockLlmCall = async () => ({
|
|
186
|
+
content: 'found it',
|
|
187
|
+
usage: { totalTokens: 100 },
|
|
188
|
+
});
|
|
189
|
+
const result = await EVAL.runFixtureById('tool-call-multi-step', fixtures, {
|
|
190
|
+
llmCall: mockLlmCall,
|
|
191
|
+
timeoutMs: 5000,
|
|
192
|
+
toolCalls: ['read', 'search', 'search'], // wrong order
|
|
193
|
+
});
|
|
194
|
+
const seqCheck = result.checks.find((c) => c.kind === 'toolSequence');
|
|
195
|
+
assert.ok(seqCheck, 'should have a toolSequence check');
|
|
196
|
+
assert.ok(!seqCheck.ok, 'toolSequence check should fail');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('jsonSchema minItems check', () => {
|
|
201
|
+
it('passes when array has enough items', async () => {
|
|
202
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
203
|
+
const mockLlmCall = async () => ({
|
|
204
|
+
content: '["red", "green", "blue"]',
|
|
205
|
+
usage: { totalTokens: 50 },
|
|
206
|
+
});
|
|
207
|
+
const result = await EVAL.runFixtureById('json-output', fixtures, {
|
|
208
|
+
llmCall: mockLlmCall,
|
|
209
|
+
timeoutMs: 5000,
|
|
210
|
+
});
|
|
211
|
+
const jsonCheck = result.checks.find((c) => c.kind === 'jsonSchema');
|
|
212
|
+
assert.ok(jsonCheck, 'should have a jsonSchema check');
|
|
213
|
+
assert.ok(jsonCheck.ok, `jsonSchema check should pass: ${jsonCheck.message}`);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('fails when array has too few items', async () => {
|
|
217
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
218
|
+
const mockLlmCall = async () => ({
|
|
219
|
+
content: '["red"]',
|
|
220
|
+
usage: { totalTokens: 50 },
|
|
221
|
+
});
|
|
222
|
+
const result = await EVAL.runFixtureById('json-output', fixtures, {
|
|
223
|
+
llmCall: mockLlmCall,
|
|
224
|
+
timeoutMs: 5000,
|
|
225
|
+
});
|
|
226
|
+
const jsonCheck = result.checks.find((c) => c.kind === 'jsonSchema');
|
|
227
|
+
assert.ok(jsonCheck, 'should have a jsonSchema check');
|
|
228
|
+
assert.ok(!jsonCheck.ok, 'jsonSchema check should fail for short array');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('notContains check', () => {
|
|
233
|
+
it('passes when forbidden content is absent', async () => {
|
|
234
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
235
|
+
const mockLlmCall = async () => ({
|
|
236
|
+
content: 'The file was not found',
|
|
237
|
+
usage: { totalTokens: 50 },
|
|
238
|
+
});
|
|
239
|
+
const result = await EVAL.runFixtureById('error-recovery', fixtures, {
|
|
240
|
+
llmCall: mockLlmCall,
|
|
241
|
+
timeoutMs: 5000,
|
|
242
|
+
});
|
|
243
|
+
// Should not contain crash or panic
|
|
244
|
+
const notContainsChecks = result.checks.filter((c) => c.kind === 'notContains');
|
|
245
|
+
assert.ok(notContainsChecks.length > 0, 'should have notContains checks');
|
|
246
|
+
for (const check of notContainsChecks) {
|
|
247
|
+
assert.ok(check.ok, `notContains "${check.message}" should pass`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('fails when forbidden content is present', async () => {
|
|
252
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
253
|
+
const mockLlmCall = async () => ({
|
|
254
|
+
content: 'The system crashed and panicked',
|
|
255
|
+
usage: { totalTokens: 50 },
|
|
256
|
+
});
|
|
257
|
+
const result = await EVAL.runFixtureById('error-recovery', fixtures, {
|
|
258
|
+
llmCall: mockLlmCall,
|
|
259
|
+
timeoutMs: 5000,
|
|
260
|
+
});
|
|
261
|
+
const notContainsChecks = result.checks.filter((c) => c.kind === 'notContains');
|
|
262
|
+
assert.ok(notContainsChecks.length > 0, 'should have notContains checks');
|
|
263
|
+
assert.ok(!result.ok, 'overall result should fail');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('regex check', () => {
|
|
268
|
+
it('passes when regex matches', async () => {
|
|
269
|
+
const fixtures = EVAL.loadFixtures(FIXTURES_DIR);
|
|
270
|
+
const mockLlmCall = async () => ({
|
|
271
|
+
content: 'The function is at src/utils/add.ts:42',
|
|
272
|
+
usage: { totalTokens: 50 },
|
|
273
|
+
});
|
|
274
|
+
const result = await EVAL.runFixtureById('citation', fixtures, {
|
|
275
|
+
llmCall: mockLlmCall,
|
|
276
|
+
timeoutMs: 5000,
|
|
277
|
+
});
|
|
278
|
+
const regexChecks = result.checks.filter((c) => c.kind === 'regex');
|
|
279
|
+
assert.ok(regexChecks.length > 0, 'should have regex checks');
|
|
280
|
+
for (const check of regexChecks) {
|
|
281
|
+
assert.ok(check.ok, `regex "${check.message}" should pass`);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/otel-spans.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.1.0 — Tests for the OpenTelemetry span helpers added alongside
|
|
5
|
+
* the v4.9 → v5.1 OTLP trace expansion.
|
|
6
|
+
*
|
|
7
|
+
* Coverage:
|
|
8
|
+
* - `withSpan(name, fn, attrs)` — success path returns the callback
|
|
9
|
+
* value and does not throw; failure path records the exception
|
|
10
|
+
* and rethrows the original error (so `wrap()` can still emit a
|
|
11
|
+
* JSON envelope).
|
|
12
|
+
* - `setCommonAttributes(span, attrs)` — sets the four documented
|
|
13
|
+
* attribute keys when given, and silently skips falsy values so
|
|
14
|
+
* unauthenticated routes don't pollute spans with empty ids.
|
|
15
|
+
* - `getResourceAttributes()` — produces a non-empty map with the
|
|
16
|
+
* seven semantic-convention keys documented at the top of
|
|
17
|
+
* otel.mjs.
|
|
18
|
+
* - `recordTrace(name, attrs)` — increments the per-key counter and
|
|
19
|
+
* matches the brief's key shape (`name:jsonString`).
|
|
20
|
+
*
|
|
21
|
+
* The tests use the no-op tracer (no `initOtel` call) so the span
|
|
22
|
+
* methods are guaranteed to be no-ops and the round-trip semantics
|
|
23
|
+
* are exactly what route handlers will see when OTEL is disabled.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import test from 'node:test';
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
|
|
29
|
+
const OTEL_URL = '../src/server/otel.mjs';
|
|
30
|
+
const METRICS_URL = '../src/server/metrics.mjs';
|
|
31
|
+
|
|
32
|
+
async function loadOtel() {
|
|
33
|
+
// Fresh module each call so module-scoped SDK state is clean.
|
|
34
|
+
const url = `${OTEL_URL}?v=${Date.now()}-${Math.random()}`;
|
|
35
|
+
return import(url);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function loadMetrics() {
|
|
39
|
+
const url = `${METRICS_URL}?v=${Date.now()}-${Math.random()}`;
|
|
40
|
+
return import(url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('withSpan returns the callback value on success', async () => {
|
|
44
|
+
const otel = await loadOtel();
|
|
45
|
+
try {
|
|
46
|
+
// `withSpan(name, fn)` returns an Express-shaped handler that
|
|
47
|
+
// takes (req, res, …rest) and returns a Promise. Callers `wrap`
|
|
48
|
+
// it; here we invoke the returned function directly with a
|
|
49
|
+
// synthetic arg list so we can assert the await semantics.
|
|
50
|
+
const handler = otel.withSpan('test.success', async (span) => {
|
|
51
|
+
span.setAttribute('test.key', 'value');
|
|
52
|
+
return 42;
|
|
53
|
+
});
|
|
54
|
+
const result = await handler();
|
|
55
|
+
assert.equal(result, 42, 'withSpan handler must propagate the callback return value');
|
|
56
|
+
} finally {
|
|
57
|
+
await otel.shutdownOtel();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('withSpan re-throws when the callback throws (matches wrap() contract)', async () => {
|
|
62
|
+
const otel = await loadOtel();
|
|
63
|
+
try {
|
|
64
|
+
const handler = otel.withSpan('test.error', async () => {
|
|
65
|
+
throw new Error('boom');
|
|
66
|
+
});
|
|
67
|
+
await assert.rejects(handler(), /boom/);
|
|
68
|
+
} finally {
|
|
69
|
+
await otel.shutdownOtel();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('withSpan re-throws non-Error throwables (e.g. strings)', async () => {
|
|
74
|
+
const otel = await loadOtel();
|
|
75
|
+
try {
|
|
76
|
+
const handler = otel.withSpan('test.stringthrow', async () => {
|
|
77
|
+
// eslint-disable-next-line no-throw-literal
|
|
78
|
+
throw 'plain-string-error';
|
|
79
|
+
});
|
|
80
|
+
await assert.rejects(handler(), /plain-string-error/);
|
|
81
|
+
} finally {
|
|
82
|
+
await otel.shutdownOtel();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('withSpan works under no-op tracer when SDK not initialised', async () => {
|
|
87
|
+
const otel = await loadOtel();
|
|
88
|
+
try {
|
|
89
|
+
assert.equal(otel.isOtelEnabled(), false, 'must start with no SDK');
|
|
90
|
+
const result = await otel.withSpan('test.noop', async () => 'ok')();
|
|
91
|
+
assert.equal(result, 'ok', 'no-op tracer must not eat the return value');
|
|
92
|
+
// Calling again must still work — guards against a span leak
|
|
93
|
+
// where the no-op tracer throws on a second invocation.
|
|
94
|
+
const result2 = await otel.withSpan('test.noop2', async () => 'still-ok')();
|
|
95
|
+
assert.equal(result2, 'still-ok');
|
|
96
|
+
} finally {
|
|
97
|
+
await otel.shutdownOtel();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('withSpan forwards positional args to the handler (req, res shape)', async () => {
|
|
102
|
+
const otel = await loadOtel();
|
|
103
|
+
try {
|
|
104
|
+
const seen = [];
|
|
105
|
+
const handler = otel.withSpan('test.forward', async (span, req, res) => {
|
|
106
|
+
seen.push({ method: req?.method, url: req?.url, statusSet: !!res });
|
|
107
|
+
return 'ok';
|
|
108
|
+
});
|
|
109
|
+
await handler({ method: 'GET', url: '/api/foo' }, { writeHead() {} });
|
|
110
|
+
assert.equal(seen.length, 1);
|
|
111
|
+
assert.equal(seen[0].method, 'GET');
|
|
112
|
+
assert.equal(seen[0].url, '/api/foo');
|
|
113
|
+
assert.equal(seen[0].statusSet, true);
|
|
114
|
+
} finally {
|
|
115
|
+
await otel.shutdownOtel();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('setCommonAttributes sets all four keys when given', async () => {
|
|
120
|
+
const otel = await loadOtel();
|
|
121
|
+
try {
|
|
122
|
+
const calls = [];
|
|
123
|
+
const fakeSpan = {
|
|
124
|
+
setAttribute(key, value) {
|
|
125
|
+
calls.push({ key, value });
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
otel.setCommonAttributes(fakeSpan, {
|
|
129
|
+
userId: 'u_123',
|
|
130
|
+
workspaceId: 'w_456',
|
|
131
|
+
ip: '10.0.0.1',
|
|
132
|
+
userAgent: 'bizar-test/1.0',
|
|
133
|
+
});
|
|
134
|
+
const byKey = Object.fromEntries(calls.map((c) => [c.key, c.value]));
|
|
135
|
+
assert.equal(byKey['bizar.user.id'], 'u_123');
|
|
136
|
+
assert.equal(byKey['bizar.workspace.id'], 'w_456');
|
|
137
|
+
assert.equal(byKey['http.client_ip'], '10.0.0.1');
|
|
138
|
+
assert.equal(byKey['http.user_agent'], 'bizar-test/1.0');
|
|
139
|
+
} finally {
|
|
140
|
+
await otel.shutdownOtel();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('setCommonAttributes silently drops empty / undefined values', async () => {
|
|
145
|
+
const otel = await loadOtel();
|
|
146
|
+
try {
|
|
147
|
+
const calls = [];
|
|
148
|
+
const fakeSpan = {
|
|
149
|
+
setAttribute(key, value) {
|
|
150
|
+
calls.push({ key, value });
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
otel.setCommonAttributes(fakeSpan, {
|
|
154
|
+
userId: undefined,
|
|
155
|
+
workspaceId: null,
|
|
156
|
+
ip: '',
|
|
157
|
+
userAgent: '',
|
|
158
|
+
});
|
|
159
|
+
assert.equal(
|
|
160
|
+
calls.length,
|
|
161
|
+
0,
|
|
162
|
+
'all-empty inputs must skip every setAttribute call',
|
|
163
|
+
);
|
|
164
|
+
otel.setCommonAttributes(fakeSpan, {
|
|
165
|
+
userId: 'only-user',
|
|
166
|
+
});
|
|
167
|
+
assert.deepEqual(calls, [{ key: 'bizar.user.id', value: 'only-user' }]);
|
|
168
|
+
} finally {
|
|
169
|
+
await otel.shutdownOtel();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('setCommonAttributes tolerates a missing span (defensive)', async () => {
|
|
174
|
+
const otel = await loadOtel();
|
|
175
|
+
try {
|
|
176
|
+
// No throw, no error — route handlers down the call stack
|
|
177
|
+
// rely on this not throwing so a stray import won't crash.
|
|
178
|
+
otel.setCommonAttributes(null, { userId: 'x' });
|
|
179
|
+
otel.setCommonAttributes(undefined, { ip: '1.2.3.4' });
|
|
180
|
+
} finally {
|
|
181
|
+
await otel.shutdownOtel();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('getResourceAttributes includes the seven OTel semantic keys', async () => {
|
|
186
|
+
const otel = await loadOtel();
|
|
187
|
+
try {
|
|
188
|
+
const attrs = otel.getResourceAttributes();
|
|
189
|
+
const keys = Object.keys(attrs);
|
|
190
|
+
// We don't pin the literal attribute strings (semconv 1.41 uses
|
|
191
|
+
// "service.name", "deployment.environment" etc.) — instead assert
|
|
192
|
+
// the dashboard-specific payload has the right number of entries
|
|
193
|
+
// and that service.name is the canonical bizar-dash string.
|
|
194
|
+
assert.equal(keys.length, 7, 'should populate exactly 7 resource attributes');
|
|
195
|
+
const values = Object.values(attrs);
|
|
196
|
+
assert.ok(values.includes('bizar-dash'), 'service.name must be bizar-dash');
|
|
197
|
+
assert.ok(values.some((v) => v === process.pid), 'process.pid attribute must match process.pid');
|
|
198
|
+
assert.ok(values.some((v) => typeof v === 'string' && v.length > 0 && v !== 'bizar-dash'), 'must have at least one host/os string attribute');
|
|
199
|
+
} finally {
|
|
200
|
+
await otel.shutdownOtel();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('recordTrace increments a counter keyed by name + sorted JSON attribute set', async () => {
|
|
205
|
+
const m = await loadMetrics();
|
|
206
|
+
try {
|
|
207
|
+
m.resetTraceCounts();
|
|
208
|
+
assert.equal(m.recordTrace('alpha', { outcome: 'ok', code: '200' }), 1);
|
|
209
|
+
assert.equal(m.recordTrace('alpha', { outcome: 'ok', code: '200' }), 2);
|
|
210
|
+
assert.equal(m.recordTrace('alpha', { code: '200', outcome: 'ok' }), 3,
|
|
211
|
+
'same attributes in a different insertion order must collide on the same key');
|
|
212
|
+
assert.equal(m.recordTrace('alpha', { outcome: 'error', code: '500' }), 1,
|
|
213
|
+
'different attribute values must produce a different key');
|
|
214
|
+
// Empty attribute object should not collide with the 'undefined' key.
|
|
215
|
+
assert.equal(m.recordTrace('beta'), 1);
|
|
216
|
+
assert.equal(m.recordTrace('beta', {}), 2);
|
|
217
|
+
} finally {
|
|
218
|
+
m.resetTraceCounts();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('recordTrace drops undefined / null / empty attribute values', async () => {
|
|
223
|
+
const m = await loadMetrics();
|
|
224
|
+
try {
|
|
225
|
+
m.resetTraceCounts();
|
|
226
|
+
const r1 = m.recordTrace('dropper', { outcome: 'ok', code: undefined, ip: null, ua: '' });
|
|
227
|
+
const r2 = m.recordTrace('dropper', { outcome: 'ok' });
|
|
228
|
+
assert.equal(r1, 1, 'first call returns 1');
|
|
229
|
+
assert.equal(r2, 2, 'second call increments to 2 on the same key');
|
|
230
|
+
// Both calls must produce one shared entry, not two — that proves
|
|
231
|
+
// the undefined/null/empty values were dropped from the key.
|
|
232
|
+
const entryKeys = [...m.traceCountByName.keys()];
|
|
233
|
+
assert.equal(entryKeys.length, 1, 'should have exactly one composite key after dedup');
|
|
234
|
+
assert.match(entryKeys[0], /^dropper:\{"outcome":"ok"\}$/, 'key payload is just name + non-empty attribute set');
|
|
235
|
+
} finally {
|
|
236
|
+
m.resetTraceCounts();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('module exports the documented API surface', async () => {
|
|
241
|
+
const otel = await loadOtel();
|
|
242
|
+
try {
|
|
243
|
+
assert.equal(typeof otel.tracer, 'object');
|
|
244
|
+
assert.equal(typeof otel.initOtel, 'function');
|
|
245
|
+
assert.equal(typeof otel.shutdownOtel, 'function');
|
|
246
|
+
assert.equal(typeof otel.isOtelEnabled, 'function');
|
|
247
|
+
assert.equal(typeof otel.withSpan, 'function');
|
|
248
|
+
assert.equal(typeof otel.setCommonAttributes, 'function');
|
|
249
|
+
assert.equal(typeof otel.getResourceAttributes, 'function');
|
|
250
|
+
} finally {
|
|
251
|
+
await otel.shutdownOtel();
|
|
252
|
+
}
|
|
253
|
+
});
|