@polderlabs/bizar 4.8.0 → 4.9.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/index-DU61awG3.js +9 -0
- package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/otel.mjs +133 -0
- package/bizar-dash/src/server/routes/chat.mjs +246 -170
- package/bizar-dash/src/server/routes/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- package/bizar-dash/src/server/server.mjs +40 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
- package/bizar-dash/src/web/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +84 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +6 -1
- package/bizar-dash/src/web/views/Settings.tsx +96 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
- package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
- package/bizar-dash/tests/docker-build.test.mjs +96 -0
- package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/otel.test.mjs +188 -0
- package/cli/commands/dash.mjs +6 -0
- package/package.json +7 -1
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/lib/search-fuzzy.test.ts
|
|
3
|
+
*
|
|
4
|
+
* v4.9 — Unit tests for fuzzy search with typo tolerance.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
levenshtein,
|
|
9
|
+
scoreField,
|
|
10
|
+
fuzzySearch,
|
|
11
|
+
type SearchableItem,
|
|
12
|
+
} from '../../src/web/lib/search';
|
|
13
|
+
|
|
14
|
+
/* ─── Levenshtein ─── */
|
|
15
|
+
|
|
16
|
+
describe('levenshtein', () => {
|
|
17
|
+
it('returns 0 for identical strings', () => {
|
|
18
|
+
expect(levenshtein('hello', 'hello')).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns length for completely different strings', () => {
|
|
22
|
+
expect(levenshtein('abc', 'xyz')).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('counts single-character insertion', () => {
|
|
26
|
+
expect(levenshtein('cat', 'cats')).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('counts single-character deletion', () => {
|
|
30
|
+
expect(levenshtein('cats', 'cat')).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('counts single-character substitution', () => {
|
|
34
|
+
expect(levenshtein('cat', 'car')).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('handles empty strings', () => {
|
|
38
|
+
expect(levenshtein('', 'abc')).toBe(3);
|
|
39
|
+
expect(levenshtein('abc', '')).toBe(3);
|
|
40
|
+
expect(levenshtein('', '')).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles transposition as two edits', () => {
|
|
44
|
+
expect(levenshtein('ab', 'ba')).toBe(2);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/* ─── scoreField ─── */
|
|
49
|
+
|
|
50
|
+
describe('scoreField', () => {
|
|
51
|
+
const field = 'accent color';
|
|
52
|
+
|
|
53
|
+
it('exact match scores 100', () => {
|
|
54
|
+
expect(scoreField('accent color', field)).toBe(100);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('prefix match scores 90', () => {
|
|
58
|
+
expect(scoreField('acc', field)).toBe(90);
|
|
59
|
+
expect(scoreField('accent', field)).toBe(90); // starts with "accent"
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('word boundary match scores 80', () => {
|
|
63
|
+
// "color" is a separate word in "accent color"
|
|
64
|
+
expect(scoreField('color', field)).toBe(80);
|
|
65
|
+
// "colo" starts at "color" word boundary, not field start
|
|
66
|
+
expect(scoreField('colo', field)).toBe(80);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('typo within 2 chars scores 40', () => {
|
|
70
|
+
// "accent" vs "axcent" (one char off)
|
|
71
|
+
expect(scoreField('axcent', 'accent color')).toBe(40);
|
|
72
|
+
// "accent" vs "accant" (one char off)
|
|
73
|
+
const score = scoreField('accant', 'accent color');
|
|
74
|
+
expect(score).toBe(40);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('substring non-prefix, non-word-boundary match scores 30', () => {
|
|
78
|
+
// "cent" is not at a word boundary in "accent color"
|
|
79
|
+
expect(scoreField('cent', 'accent color')).toBe(30);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns 0 for no match', () => {
|
|
83
|
+
expect(scoreField('zzzzz', 'accent color')).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles case insensitivity via pre-lowered inputs', () => {
|
|
87
|
+
// Our callers lower case before passing — verify the scoring works
|
|
88
|
+
// "accent" is a prefix of "accent color" → scores 90
|
|
89
|
+
expect(scoreField('Accent'.toLowerCase(), 'Accent Color'.toLowerCase())).toBe(90);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/* ─── fuzzySearch ─── */
|
|
94
|
+
|
|
95
|
+
describe('fuzzySearch', () => {
|
|
96
|
+
const items: SearchableItem[] = [
|
|
97
|
+
{ key: 'theme.accent', label: 'Accent color', section: 'theme', value: '#8b5cf6' },
|
|
98
|
+
{ key: 'theme.fontFamily', label: 'Font family', section: 'theme' },
|
|
99
|
+
{ key: 'ui.layout', label: 'UI layout', section: 'layout', value: 'topnav' },
|
|
100
|
+
{ key: 'defaultAgent', label: 'Default agent', section: 'general', value: 'odin' },
|
|
101
|
+
{ key: 'notifications.onAgentComplete', label: 'Notify on agent complete', section: 'notifications', description: 'Show toast when an agent finishes' },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
it('returns empty array for empty query', () => {
|
|
105
|
+
expect(fuzzySearch('', items)).toEqual([]);
|
|
106
|
+
expect(fuzzySearch(' ', items)).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('finds exact match by label', () => {
|
|
110
|
+
const results = fuzzySearch('accent color', items);
|
|
111
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
112
|
+
expect(results[0].key).toBe('theme.accent');
|
|
113
|
+
expect(results[0].score).toBe(100);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('finds match by value', () => {
|
|
117
|
+
const results = fuzzySearch('#8b5cf6', items);
|
|
118
|
+
expect(results.some((r) => r.key === 'theme.accent')).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('finds match by description', () => {
|
|
122
|
+
const results = fuzzySearch('toast', items);
|
|
123
|
+
expect(results.some((r) => r.key === 'notifications.onAgentComplete')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('finds match via typo tolerance', () => {
|
|
127
|
+
const results = fuzzySearch('axcent', items);
|
|
128
|
+
expect(results.some((r) => r.key === 'theme.accent')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('sorts by score descending', () => {
|
|
132
|
+
const results = fuzzySearch('agent', items);
|
|
133
|
+
for (let i = 1; i < results.length; i++) {
|
|
134
|
+
expect(results[i].score).toBeLessThanOrEqual(results[i - 1].score);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns empty when nothing matches', () => {
|
|
139
|
+
const results = fuzzySearch('zzzznonexistent', items);
|
|
140
|
+
expect(results).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('sets matchedField and matchedTerm on results', () => {
|
|
144
|
+
const results = fuzzySearch('accent', items);
|
|
145
|
+
expect(results.length).toBeGreaterThan(0);
|
|
146
|
+
expect(results[0].matchedField).toBe('label');
|
|
147
|
+
expect(results[0].matchedTerm).toBe('Accent color');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-graph-view.test.tsx
|
|
3
|
+
*
|
|
4
|
+
* Tests for MemoryGraphView (SVG force-directed graph).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { MemoryGraphView, type GraphData } from '../src/web/views/memory/MemoryGraphView';
|
|
10
|
+
|
|
11
|
+
const EMPTY_GRAPH: GraphData = { nodes: [], edges: [] };
|
|
12
|
+
|
|
13
|
+
const THREE_NODE_GRAPH: GraphData = {
|
|
14
|
+
nodes: [
|
|
15
|
+
{ id: 'notes/alpha.md', label: 'Alpha Note', type: 'note', size: 1, group: 'notes' },
|
|
16
|
+
{ id: 'notes/beta.md', label: 'Beta Note', type: 'note', size: 2, group: 'notes' },
|
|
17
|
+
{ id: 'decisions/dec.md', label: 'Decision', type: 'note', size: 1, group: 'decisions' },
|
|
18
|
+
],
|
|
19
|
+
edges: [
|
|
20
|
+
{ source: 'notes/alpha.md', target: 'notes/beta.md', type: 'links_to', weight: 1 },
|
|
21
|
+
{ source: 'notes/beta.md', target: 'decisions/dec.md', type: 'links_to', weight: 1 },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('MemoryGraphView', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders SVG with empty graph', () => {
|
|
31
|
+
render(<MemoryGraphView data={EMPTY_GRAPH} />);
|
|
32
|
+
const svg = document.querySelector('.memory-graph-canvas');
|
|
33
|
+
expect(svg).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('renders nodes and edges', () => {
|
|
37
|
+
render(<MemoryGraphView data={THREE_NODE_GRAPH} />);
|
|
38
|
+
// SVG should have circles for nodes (one per node).
|
|
39
|
+
const circles = document.querySelectorAll('.memory-graph-node');
|
|
40
|
+
expect(circles.length).toBe(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('calls onNodeClick when a node is clicked', () => {
|
|
44
|
+
const onNodeClick = vi.fn();
|
|
45
|
+
render(<MemoryGraphView data={THREE_NODE_GRAPH} onNodeClick={onNodeClick} />);
|
|
46
|
+
const circles = document.querySelectorAll('.memory-graph-node');
|
|
47
|
+
// Click the first node circle group.
|
|
48
|
+
if (circles.length > 0) {
|
|
49
|
+
fireEvent.click(circles[0]);
|
|
50
|
+
}
|
|
51
|
+
expect(onNodeClick).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders filtered nodes when filter is applied via data prop', () => {
|
|
55
|
+
const filteredData = {
|
|
56
|
+
nodes: THREE_NODE_GRAPH.nodes.filter((n) => n.label.includes('Alpha')),
|
|
57
|
+
edges: [],
|
|
58
|
+
};
|
|
59
|
+
render(<MemoryGraphView data={filteredData} />);
|
|
60
|
+
const circles = document.querySelectorAll('.memory-graph-node');
|
|
61
|
+
expect(circles.length).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('node has correct type and group fields', () => {
|
|
65
|
+
render(<MemoryGraphView data={THREE_NODE_GRAPH} />);
|
|
66
|
+
const circles = document.querySelectorAll('.memory-graph-node');
|
|
67
|
+
expect(circles.length).toBeGreaterThan(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-graph.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for getObsidianLinkGraph in memory-store.mjs
|
|
5
|
+
* and getLightRAGGraph in memory-lightrag.mjs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
const TEST_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
|
|
15
|
+
|
|
16
|
+
describe('getObsidianLinkGraph', () => {
|
|
17
|
+
let projectRoot;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
projectRoot = join(tmpdir(), `bizar-graph-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
21
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
22
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function writeNote(relPath, body) {
|
|
30
|
+
const full = join(projectRoot, '.obsidian', relPath);
|
|
31
|
+
mkdirSync(join(projectRoot, '.obsidian', relPath.split('/').slice(0, -1).join('/') || '.'), { recursive: true });
|
|
32
|
+
const title = relPath.split('/').pop()?.replace(/\.md$/i, '') || relPath;
|
|
33
|
+
writeFileSync(full, `---\ntitle: ${title}\n---\n\n${body}`, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('extracts wikilinks and builds nodes + edges', () => {
|
|
37
|
+
writeNote('notes/alpha.md', 'This links to [[Beta Note]] and [[Gamma]].');
|
|
38
|
+
writeNote('notes/beta.md', 'Back to [[Alpha]].');
|
|
39
|
+
writeNote('notes/gamma.md', 'No links here.');
|
|
40
|
+
|
|
41
|
+
const { nodes, edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
|
|
42
|
+
|
|
43
|
+
// Should have 3 nodes.
|
|
44
|
+
assert.ok(nodes.length >= 3, `Expected >= 3 nodes, got ${nodes.length}`);
|
|
45
|
+
|
|
46
|
+
// Each node should have required fields.
|
|
47
|
+
for (const n of nodes) {
|
|
48
|
+
assert.ok('id' in n, 'node must have id');
|
|
49
|
+
assert.ok('label' in n, 'node must have label');
|
|
50
|
+
assert.ok('type' in n, 'node must have type');
|
|
51
|
+
assert.ok('size' in n, 'node must have size');
|
|
52
|
+
assert.ok('group' in n, 'node must have group');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Should have edges.
|
|
56
|
+
assert.ok(edges.length > 0, 'Expected at least 1 edge');
|
|
57
|
+
for (const e of edges) {
|
|
58
|
+
assert.ok('source' in e, 'edge must have source');
|
|
59
|
+
assert.ok('target' in e, 'edge must have target');
|
|
60
|
+
assert.ok('type' in e, 'edge must have type');
|
|
61
|
+
assert.ok('weight' in e, 'edge must have weight');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles empty vault gracefully', () => {
|
|
66
|
+
const { nodes, edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
|
|
67
|
+
assert.ok(Array.isArray(nodes), 'nodes must be an array');
|
|
68
|
+
assert.ok(Array.isArray(edges), 'edges must be an array');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resolves wikilink targets by basename', () => {
|
|
72
|
+
writeNote('foo.md', 'See [[Bar]] for details.');
|
|
73
|
+
writeNote('bar.md', 'Related to [[Foo]].');
|
|
74
|
+
const { edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
|
|
75
|
+
// Should have resolved [[Bar]] -> bar.md and [[Foo]] -> foo.md.
|
|
76
|
+
assert.ok(edges.length >= 1, 'Expected at least 1 edge from wikilink resolution');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('respects the limit parameter', () => {
|
|
80
|
+
for (let i = 0; i < 10; i++) {
|
|
81
|
+
writeNote(`note${i}.md`, i === 0 ? 'Links to [[Note1]].' : '');
|
|
82
|
+
}
|
|
83
|
+
const { nodes, edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 3 });
|
|
84
|
+
assert.ok(nodes.length <= 3, `Expected <= 3 nodes, got ${nodes.length}`);
|
|
85
|
+
assert.ok(edges.length <= 9, `Expected <= 9 edges, got ${edges.length}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('node label uses title frontmatter when available', () => {
|
|
89
|
+
writeNote('mydoc.md', 'Content.');
|
|
90
|
+
const { nodes } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
|
|
91
|
+
const mydoc = nodes.find((n) => n.id === 'mydoc.md');
|
|
92
|
+
assert.ok(mydoc, 'mydoc.md should be a node');
|
|
93
|
+
assert.strictEqual(mydoc.label, 'mydoc');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/otel.test.mjs — v4.9.0
|
|
3
|
+
*
|
|
4
|
+
* Tests for src/server/otel.mjs. The OpenTelemetry SDK and helpers
|
|
5
|
+
* must:
|
|
6
|
+
* - export a `tracer` that has `startActiveSpan` (works even before
|
|
7
|
+
* `initOtel()` is called, because the no-op global tracer covers
|
|
8
|
+
* the uninitialised case)
|
|
9
|
+
* - return an SDK instance from `initOtel()` and only initialise
|
|
10
|
+
* once across repeated calls
|
|
11
|
+
* - shut down cleanly via `shutdownOtel()`, even when the OTLP
|
|
12
|
+
* endpoint is unreachable
|
|
13
|
+
* - tolerate a malformed endpoint / thrown init error without
|
|
14
|
+
* crashing the importing module
|
|
15
|
+
*
|
|
16
|
+
* Each test loads the module via a fresh URL to defeat Node's ESM
|
|
17
|
+
* module cache; `otel.mjs` uses module-scoped state (the `sdk` and
|
|
18
|
+
* `shuttingDown` lets) and we want each test to see its own clean
|
|
19
|
+
* registry the same way `metrics.test.mjs` does.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import test from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
|
|
25
|
+
const OTEL_URL = '../src/server/otel.mjs';
|
|
26
|
+
|
|
27
|
+
async function loadOtel() {
|
|
28
|
+
const url = `${OTEL_URL}?v=${Date.now()}-${Math.random()}`;
|
|
29
|
+
return import(url);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('tracer is exported and has startActiveSpan', async () => {
|
|
33
|
+
const otel = await loadOtel();
|
|
34
|
+
try {
|
|
35
|
+
assert.equal(typeof otel.tracer, 'object');
|
|
36
|
+
assert.equal(typeof otel.tracer.startActiveSpan, 'function');
|
|
37
|
+
} finally {
|
|
38
|
+
await otel.shutdownOtel();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('initOtel() returns an SDK instance and is idempotent', async () => {
|
|
43
|
+
const otel = await loadOtel();
|
|
44
|
+
try {
|
|
45
|
+
// Before init: isOtelEnabled() is false (fresh module load).
|
|
46
|
+
assert.equal(otel.isOtelEnabled(), false);
|
|
47
|
+
const sdk1 = otel.initOtel({
|
|
48
|
+
// Use an endpoint that won't connect; the test only cares
|
|
49
|
+
// that the SDK object is returned, not that spans export.
|
|
50
|
+
endpoint: 'http://127.0.0.1:1/v1/traces',
|
|
51
|
+
});
|
|
52
|
+
assert.ok(sdk1, 'initOtel should return a non-null SDK instance');
|
|
53
|
+
assert.equal(otel.isOtelEnabled(), true);
|
|
54
|
+
|
|
55
|
+
const sdk2 = otel.initOtel({ endpoint: 'http://127.0.0.1:1/v1/traces' });
|
|
56
|
+
assert.equal(
|
|
57
|
+
sdk1,
|
|
58
|
+
sdk2,
|
|
59
|
+
'a second initOtel() call must return the cached SDK instance',
|
|
60
|
+
);
|
|
61
|
+
} finally {
|
|
62
|
+
await otel.shutdownOtel();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('shutdownOtel() is callable and resets the SDK cache', async () => {
|
|
67
|
+
const otel = await loadOtel();
|
|
68
|
+
try {
|
|
69
|
+
otel.initOtel({ endpoint: 'http://127.0.0.1:1/v1/traces' });
|
|
70
|
+
assert.equal(otel.isOtelEnabled(), true);
|
|
71
|
+
await otel.shutdownOtel();
|
|
72
|
+
assert.equal(otel.isOtelEnabled(), false);
|
|
73
|
+
|
|
74
|
+
// Calling shutdownOtel again must be a no-op (does not throw).
|
|
75
|
+
await otel.shutdownOtel();
|
|
76
|
+
assert.equal(otel.isOtelEnabled(), false);
|
|
77
|
+
} finally {
|
|
78
|
+
await otel.shutdownOtel();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('graceful degradation when OTLP endpoint is unreachable', async () => {
|
|
83
|
+
// Port 1 on loopback never accepts connections — the exporter will
|
|
84
|
+
// log batch-send errors internally but must not crash the module.
|
|
85
|
+
const otel = await loadOtel();
|
|
86
|
+
try {
|
|
87
|
+
const sdk = otel.initOtel({
|
|
88
|
+
endpoint: 'http://127.0.0.1:1/v1/traces',
|
|
89
|
+
});
|
|
90
|
+
assert.ok(sdk, 'initOtel must succeed even with an unreachable endpoint');
|
|
91
|
+
assert.equal(otel.isOtelEnabled(), true);
|
|
92
|
+
|
|
93
|
+
// Spans created on the live tracer must complete without errors.
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
otel.tracer.startActiveSpan('test.unreachable_endpoint', async (span) => {
|
|
96
|
+
try {
|
|
97
|
+
span.setAttribute('test.attr', 1);
|
|
98
|
+
span.setStatus({ code: 1 /* OK */ });
|
|
99
|
+
await Promise.resolve();
|
|
100
|
+
resolve();
|
|
101
|
+
} catch (e) {
|
|
102
|
+
reject(e);
|
|
103
|
+
} finally {
|
|
104
|
+
span.end();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await otel.shutdownOtel();
|
|
110
|
+
} finally {
|
|
111
|
+
await otel.shutdownOtel();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('spans can be created and ended without errors (no SDK initialised)', async () => {
|
|
116
|
+
// No initOtel call — exercises the no-op tracer path. Route
|
|
117
|
+
// handlers rely on this when OTEL is disabled (the default).
|
|
118
|
+
const otel = await loadOtel();
|
|
119
|
+
assert.equal(otel.isOtelEnabled(), false);
|
|
120
|
+
|
|
121
|
+
const result = await new Promise((resolve, reject) => {
|
|
122
|
+
otel.tracer.startActiveSpan('test.noop', (span) => {
|
|
123
|
+
try {
|
|
124
|
+
// Sanity-check that the no-op span still answers every
|
|
125
|
+
// method without throwing — that's the contract route
|
|
126
|
+
// handlers rely on.
|
|
127
|
+
span.setAttribute('a', 1);
|
|
128
|
+
span.setAttribute('b', 'hello');
|
|
129
|
+
span.addEvent('event-1');
|
|
130
|
+
span.setStatus({ code: 1 });
|
|
131
|
+
resolve('ok');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
reject(err);
|
|
134
|
+
} finally {
|
|
135
|
+
span.end();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
assert.equal(result, 'ok');
|
|
140
|
+
|
|
141
|
+
await otel.shutdownOtel();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('tracer.startActiveSpan callback can be async (returns a Promise)', async () => {
|
|
145
|
+
// The OpenTelemetry spec lets the callback return a Promise;
|
|
146
|
+
// the tracer awaits it. Make sure the no-op implementation
|
|
147
|
+
// (used when the SDK is disabled) honors this.
|
|
148
|
+
const otel = await loadOtel();
|
|
149
|
+
try {
|
|
150
|
+
let observed = null;
|
|
151
|
+
await otel.tracer.startActiveSpan('test.async', async (span) => {
|
|
152
|
+
try {
|
|
153
|
+
observed = await Promise.resolve('inside-span');
|
|
154
|
+
} finally {
|
|
155
|
+
span.end();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
assert.equal(observed, 'inside-span');
|
|
159
|
+
} finally {
|
|
160
|
+
await otel.shutdownOtel();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('initOtel accepts custom serviceName override', async () => {
|
|
165
|
+
const otel = await loadOtel();
|
|
166
|
+
try {
|
|
167
|
+
const sdk = otel.initOtel({
|
|
168
|
+
serviceName: 'bizar-dash-test',
|
|
169
|
+
endpoint: 'http://127.0.0.1:1/v1/traces',
|
|
170
|
+
});
|
|
171
|
+
assert.ok(sdk, 'initOtel with a custom serviceName should succeed');
|
|
172
|
+
await otel.shutdownOtel();
|
|
173
|
+
} finally {
|
|
174
|
+
await otel.shutdownOtel();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('module does not crash when imported (no init, no shutdown)', async () => {
|
|
179
|
+
// Defensive sanity check — every other test file in this repo
|
|
180
|
+
// transitively imports dozens of modules. If a syntax/import
|
|
181
|
+
// error sneaks into otel.mjs, this test catches it before the
|
|
182
|
+
// larger suite blows up somewhere unrelated.
|
|
183
|
+
const otel = await loadOtel();
|
|
184
|
+
assert.equal(typeof otel.initOtel, 'function');
|
|
185
|
+
assert.equal(typeof otel.shutdownOtel, 'function');
|
|
186
|
+
assert.equal(typeof otel.tracer, 'object');
|
|
187
|
+
await otel.shutdownOtel();
|
|
188
|
+
});
|
package/cli/commands/dash.mjs
CHANGED
|
@@ -42,6 +42,12 @@ export function showDashHelp() {
|
|
|
42
42
|
Note:
|
|
43
43
|
\`bizar dashboard\` is a deprecated alias for \`bizar dash\` and still
|
|
44
44
|
works, but new code should use \`bizar dash\`.
|
|
45
|
+
|
|
46
|
+
OpenTelemetry (optional, env vars):
|
|
47
|
+
BIZAR_OTEL=1 Enable OpenTelemetry tracing (off by default)
|
|
48
|
+
OTEL_ENABLED=1 Alias that also opts in
|
|
49
|
+
OTEL_EXPORTER_OTLP_ENDPOINT OTLP HTTP traces endpoint
|
|
50
|
+
(default http://localhost:4318/v1/traces)
|
|
45
51
|
`);
|
|
46
52
|
}
|
|
47
53
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -78,6 +78,12 @@
|
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@opencode-ai/plugin": "^1.17.7",
|
|
81
|
+
"@opentelemetry/api": "^1.9.0",
|
|
82
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
|
83
|
+
"@opentelemetry/resources": "^1.25.0",
|
|
84
|
+
"@opentelemetry/sdk-node": "^0.52.0",
|
|
85
|
+
"@opentelemetry/sdk-trace-node": "^1.25.0",
|
|
86
|
+
"@opentelemetry/semantic-conventions": "^1.25.0",
|
|
81
87
|
"@testing-library/jest-dom": "^6.9.1",
|
|
82
88
|
"@testing-library/react": "^16.3.2",
|
|
83
89
|
"@testing-library/user-event": "^14.6.1",
|