@polderlabs/bizar 5.0.2 → 5.2.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-TCputYzr.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
- 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--17fkfrl.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.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-BG-DhEck.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.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 +8 -6
- 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/api.mjs +4 -0
- 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 +131 -16
- package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
- package/bizar-dash/src/server/routes/voice.mjs +42 -6
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +57 -15
- package/bizar-dash/src/server/voice-store.mjs +27 -0
- package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
- package/bizar-dash/src/web/App.tsx +9 -0
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
- package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
- package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
- package/bizar-dash/src/web/components/Toggle.tsx +31 -0
- package/bizar-dash/src/web/components/Topbar.tsx +6 -0
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/src/web/styles/main.css +545 -0
- package/bizar-dash/src/web/views/Eval.tsx +172 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
- package/bizar-dash/src/web/views/Plugins.tsx +128 -0
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
- package/cli/bin.mjs +14 -0
- package/cli/commands/dash.mjs +19 -1
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/tailscale.mjs +251 -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 +0 -19
- 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,95 @@
|
|
|
1
|
+
// tests/components/plugin-permissions.test.tsx — PluginPermissions and PluginCard unit tests.
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { PluginPermissions } from '../../src/web/components/PluginPermissions';
|
|
6
|
+
import { PluginCard } from '../../src/web/components/PluginCard';
|
|
7
|
+
|
|
8
|
+
describe('PluginPermissions', () => {
|
|
9
|
+
it('renders each permission as a chip', () => {
|
|
10
|
+
render(<PluginPermissions permissions={['net', 'fs']} />);
|
|
11
|
+
expect(screen.getByText('Network access')).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText('Filesystem')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('shows unknown permission with default label', () => {
|
|
16
|
+
render(<PluginPermissions permissions={['unknown_perm']} />);
|
|
17
|
+
expect(screen.getByText('unknown_perm')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('handles empty permissions array', () => {
|
|
21
|
+
const { container } = render(<PluginPermissions permissions={[]} />);
|
|
22
|
+
expect(container.querySelector('.plugin-permissions')).toBeInTheDocument();
|
|
23
|
+
expect(container.querySelectorAll('.permission-chip')).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('PluginCard', () => {
|
|
28
|
+
const defaultPlugin = {
|
|
29
|
+
id: 'test-plugin',
|
|
30
|
+
name: 'Test Plugin',
|
|
31
|
+
version: '1.0.0',
|
|
32
|
+
description: 'A test plugin',
|
|
33
|
+
permissions: ['net', 'fs'],
|
|
34
|
+
invocations: 42,
|
|
35
|
+
lastInvokedAt: '2026-07-05T10:00:00Z',
|
|
36
|
+
enabled: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it('renders name, version, description', () => {
|
|
40
|
+
render(
|
|
41
|
+
<PluginCard
|
|
42
|
+
plugin={defaultPlugin}
|
|
43
|
+
onToggle={vi.fn()}
|
|
44
|
+
onUninstall={vi.fn()}
|
|
45
|
+
onConfigure={vi.fn()}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByText('Test Plugin')).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText('v1.0.0 · test-plugin')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('A test plugin')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('calls onToggle when toggle clicked', async () => {
|
|
54
|
+
const onToggle = vi.fn();
|
|
55
|
+
render(
|
|
56
|
+
<PluginCard
|
|
57
|
+
plugin={defaultPlugin}
|
|
58
|
+
onToggle={onToggle}
|
|
59
|
+
onUninstall={vi.fn()}
|
|
60
|
+
onConfigure={vi.fn()}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
const toggle = screen.getByRole('switch');
|
|
64
|
+
toggle.click();
|
|
65
|
+
expect(onToggle).toHaveBeenCalledWith('test-plugin');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('calls onUninstall when uninstall clicked', () => {
|
|
69
|
+
const onUninstall = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<PluginCard
|
|
72
|
+
plugin={defaultPlugin}
|
|
73
|
+
onToggle={vi.fn()}
|
|
74
|
+
onUninstall={onUninstall}
|
|
75
|
+
onConfigure={vi.fn()}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
screen.getByText('Uninstall').click();
|
|
79
|
+
expect(onUninstall).toHaveBeenCalledWith('test-plugin');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('calls onConfigure when configure clicked', () => {
|
|
83
|
+
const onConfigure = vi.fn();
|
|
84
|
+
render(
|
|
85
|
+
<PluginCard
|
|
86
|
+
plugin={defaultPlugin}
|
|
87
|
+
onToggle={vi.fn()}
|
|
88
|
+
onUninstall={vi.fn()}
|
|
89
|
+
onConfigure={onConfigure}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
screen.getByText('Configure').click();
|
|
93
|
+
expect(onConfigure).toHaveBeenCalledWith('test-plugin');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -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,220 @@
|
|
|
1
|
+
// tests/eval-web-ui.test.tsx — v5.2.0
|
|
2
|
+
//
|
|
3
|
+
// Tests for the eval framework web UI components:
|
|
4
|
+
// - EvalRunCard: pass/total rendering, status pill (pass/warn/fail)
|
|
5
|
+
// - EvalDiff: categorize fixtures as improved/regressed/unchanged
|
|
6
|
+
//
|
|
7
|
+
// We render with @testing-library/react in jsdom and assert on the
|
|
8
|
+
// status pill text + class list.
|
|
9
|
+
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { EvalRunCard, type EvalRunSummary } from '../src/web/components/EvalRunCard';
|
|
13
|
+
import { EvalDiff, computeEvalDiff, type EvalRunWithResults } from '../src/web/components/EvalDiff';
|
|
14
|
+
|
|
15
|
+
const BASE_RUN: EvalRunSummary = {
|
|
16
|
+
id: 'run_2026-07-05_a1b2',
|
|
17
|
+
startedAt: '2026-07-05T10:00:00.000Z',
|
|
18
|
+
finishedAt: '2026-07-05T10:01:00.000Z',
|
|
19
|
+
suitePath: '/tmp/fixtures',
|
|
20
|
+
total: 10,
|
|
21
|
+
passed: 10,
|
|
22
|
+
failed: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('EvalRunCard', () => {
|
|
26
|
+
it('renders the run id and suite path', () => {
|
|
27
|
+
render(<EvalRunCard run={BASE_RUN} />);
|
|
28
|
+
expect(screen.getByText(BASE_RUN.id)).toBeInTheDocument();
|
|
29
|
+
// The card meta includes the suite path. The format helper prepends
|
|
30
|
+
// a formatted timestamp, so we look for the suite path fragment.
|
|
31
|
+
expect(screen.getByText(/fixtures/)).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders the pass count and total in the status pill', () => {
|
|
35
|
+
render(<EvalRunCard run={BASE_RUN} />);
|
|
36
|
+
expect(screen.getByText('10/10 (100.0%)')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses the "pass" status when failed === 0', () => {
|
|
40
|
+
const { container } = render(<EvalRunCard run={BASE_RUN} />);
|
|
41
|
+
const pill = container.querySelector('.eval-status');
|
|
42
|
+
expect(pill).toBeInTheDocument();
|
|
43
|
+
expect(pill).toHaveClass('is-pass');
|
|
44
|
+
expect(pill).not.toHaveClass('is-warn');
|
|
45
|
+
expect(pill).not.toHaveClass('is-fail');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses the "warn" status when pass rate is between 0.8 and 1.0', () => {
|
|
49
|
+
const run: EvalRunSummary = { ...BASE_RUN, total: 10, passed: 9, failed: 1 };
|
|
50
|
+
const { container } = render(<EvalRunCard run={run} />);
|
|
51
|
+
const pill = container.querySelector('.eval-status');
|
|
52
|
+
expect(pill).toHaveClass('is-warn');
|
|
53
|
+
expect(screen.getByText('9/10 (90.0%)')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('uses the "fail" status when pass rate is at or below 0.8', () => {
|
|
57
|
+
const run: EvalRunSummary = { ...BASE_RUN, total: 10, passed: 7, failed: 3 };
|
|
58
|
+
const { container } = render(<EvalRunCard run={run} />);
|
|
59
|
+
const pill = container.querySelector('.eval-status');
|
|
60
|
+
expect(pill).toHaveClass('is-fail');
|
|
61
|
+
expect(screen.getByText('7/10 (70.0%)')).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('uses the "fail" status when there is any failure at all (10/10a vs 8/10b)', () => {
|
|
65
|
+
// pass rate exactly 0.8 → still "fail" (the brief says strictly > 0.8)
|
|
66
|
+
const run: EvalRunSummary = { ...BASE_RUN, total: 10, passed: 8, failed: 2 };
|
|
67
|
+
const { container } = render(<EvalRunCard run={run} />);
|
|
68
|
+
expect(container.querySelector('.eval-status')).toHaveClass('is-fail');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('renders 0/0 (0.0%) without crashing when total is zero', () => {
|
|
72
|
+
const run: EvalRunSummary = { ...BASE_RUN, total: 0, passed: 0, failed: 0 };
|
|
73
|
+
render(<EvalRunCard run={run} />);
|
|
74
|
+
expect(screen.getByText('0/0 (0.0%)')).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('marks the card as selected when selected=true', () => {
|
|
78
|
+
const { container } = render(<EvalRunCard run={BASE_RUN} selected />);
|
|
79
|
+
const card = container.querySelector('.eval-run-card');
|
|
80
|
+
expect(card).toHaveClass('eval-run-card-selected');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('calls onClick when the card is clicked', () => {
|
|
84
|
+
let clicked = false;
|
|
85
|
+
const { container } = render(<EvalRunCard run={BASE_RUN} onClick={() => { clicked = true; }} />);
|
|
86
|
+
const card = container.querySelector('.eval-run-card') as HTMLElement;
|
|
87
|
+
card.click();
|
|
88
|
+
expect(clicked).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('EvalDiff — computeEvalDiff', () => {
|
|
93
|
+
const runA: EvalRunWithResults = {
|
|
94
|
+
id: 'run_a',
|
|
95
|
+
startedAt: '2026-07-01T00:00:00Z',
|
|
96
|
+
suitePath: '/tmp/fixtures',
|
|
97
|
+
total: 3,
|
|
98
|
+
passed: 2,
|
|
99
|
+
failed: 1,
|
|
100
|
+
results: [
|
|
101
|
+
{ fixtureId: 'f1', ok: false, latencyMs: 100 },
|
|
102
|
+
{ fixtureId: 'f2', ok: true, latencyMs: 120 },
|
|
103
|
+
{ fixtureId: 'f3', ok: true, latencyMs: 140 },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const runB: EvalRunWithResults = {
|
|
108
|
+
id: 'run_b',
|
|
109
|
+
startedAt: '2026-07-05T00:00:00Z',
|
|
110
|
+
suitePath: '/tmp/fixtures',
|
|
111
|
+
total: 3,
|
|
112
|
+
passed: 3,
|
|
113
|
+
failed: 0,
|
|
114
|
+
results: [
|
|
115
|
+
{ fixtureId: 'f1', ok: true, latencyMs: 110 },
|
|
116
|
+
{ fixtureId: 'f2', ok: true, latencyMs: 115 },
|
|
117
|
+
{ fixtureId: 'f3', ok: true, latencyMs: 150 },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const runC: EvalRunWithResults = {
|
|
122
|
+
...runB,
|
|
123
|
+
id: 'run_c',
|
|
124
|
+
results: [
|
|
125
|
+
{ fixtureId: 'f1', ok: false, latencyMs: 110 },
|
|
126
|
+
{ fixtureId: 'f2', ok: true, latencyMs: 115 },
|
|
127
|
+
{ fixtureId: 'f3', ok: true, latencyMs: 150 },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('identifies improvements (was failing, now passing)', () => {
|
|
132
|
+
const diff = computeEvalDiff(runA, runB);
|
|
133
|
+
const improved = diff.filter((d) => d.change === 'improved');
|
|
134
|
+
expect(improved).toHaveLength(1);
|
|
135
|
+
expect(improved[0].fixtureId).toBe('f1');
|
|
136
|
+
expect(improved[0].before).toBe(false);
|
|
137
|
+
expect(improved[0].after).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('identifies regressions (was passing, now failing)', () => {
|
|
141
|
+
const diff = computeEvalDiff(runB, runC);
|
|
142
|
+
const regressed = diff.filter((d) => d.change === 'regressed');
|
|
143
|
+
expect(regressed).toHaveLength(1);
|
|
144
|
+
expect(regressed[0].fixtureId).toBe('f1');
|
|
145
|
+
expect(regressed[0].before).toBe(true);
|
|
146
|
+
expect(regressed[0].after).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('lists unchanged fixtures separately', () => {
|
|
150
|
+
const diff = computeEvalDiff(runA, runB);
|
|
151
|
+
const unchanged = diff.filter((d) => d.change === 'same');
|
|
152
|
+
expect(unchanged.map((d) => d.fixtureId).sort()).toEqual(['f2', 'f3']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns empty arrays for identical runs', () => {
|
|
156
|
+
const diff = computeEvalDiff(runA, runA);
|
|
157
|
+
const changed = diff.filter((d) => d.change !== 'same');
|
|
158
|
+
expect(changed).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('EvalDiff — rendering', () => {
|
|
163
|
+
const runA: EvalRunWithResults = {
|
|
164
|
+
id: 'run_a',
|
|
165
|
+
startedAt: '2026-07-01T00:00:00Z',
|
|
166
|
+
suitePath: '/tmp/fixtures',
|
|
167
|
+
total: 3,
|
|
168
|
+
passed: 2,
|
|
169
|
+
failed: 1,
|
|
170
|
+
results: [
|
|
171
|
+
{ fixtureId: 'f1', ok: false, latencyMs: 100 },
|
|
172
|
+
{ fixtureId: 'f2', ok: true, latencyMs: 120 },
|
|
173
|
+
{ fixtureId: 'f3', ok: true, latencyMs: 140 },
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const runB: EvalRunWithResults = {
|
|
178
|
+
id: 'run_b',
|
|
179
|
+
startedAt: '2026-07-05T00:00:00Z',
|
|
180
|
+
suitePath: '/tmp/fixtures',
|
|
181
|
+
total: 3,
|
|
182
|
+
passed: 3,
|
|
183
|
+
failed: 0,
|
|
184
|
+
results: [
|
|
185
|
+
{ fixtureId: 'f1', ok: true, latencyMs: 110 },
|
|
186
|
+
{ fixtureId: 'f2', ok: true, latencyMs: 115 },
|
|
187
|
+
{ fixtureId: 'f4', ok: true, latencyMs: 200 },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
it('renders the diff container with a heading', () => {
|
|
192
|
+
render(<EvalDiff runA={runA} runB={runB} />);
|
|
193
|
+
expect(screen.getByTestId('eval-diff')).toBeInTheDocument();
|
|
194
|
+
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Regression Analysis');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('renders the improved/regressed/unchanged counters', () => {
|
|
198
|
+
render(<EvalDiff runA={runA} runB={runB} />);
|
|
199
|
+
// f1: false → true = improved
|
|
200
|
+
// f2: true → true = unchanged
|
|
201
|
+
// f3: true → true (only in A) → removed
|
|
202
|
+
// f4: true (only in B) → added
|
|
203
|
+
expect(screen.getByText(/1 improved/)).toBeInTheDocument();
|
|
204
|
+
expect(screen.getByText(/0 regressed/)).toBeInTheDocument();
|
|
205
|
+
expect(screen.getByText(/1 unchanged/)).toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('renders improved and regressed rows with their fixture ids', () => {
|
|
209
|
+
render(<EvalDiff runA={runA} runB={runB} />);
|
|
210
|
+
const improvedRow = screen.getByText('f1').closest('.eval-diff-row');
|
|
211
|
+
expect(improvedRow).not.toBeNull();
|
|
212
|
+
expect(improvedRow).toHaveClass('is-improved');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('shows the empty state when no fixtures changed', () => {
|
|
216
|
+
// Pass the same run for both A and B — no changes.
|
|
217
|
+
render(<EvalDiff runA={runA} runB={runA} />);
|
|
218
|
+
expect(screen.getByText(/No fixture-level changes/)).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
});
|