@polderlabs/bizar 5.1.0 → 5.3.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 → MobileChat-OidrxXR9.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-OidrxXR9.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-B91gVFii.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-B91gVFii.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-Bd8piSb2.js} +159 -134
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-3G6mOhiK.css} +1 -1
- package/bizar-dash/dist/assets/main-6YUZY968.js +19 -0
- package/bizar-dash/dist/assets/main-6YUZY968.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-CSsp30lr.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-CSsp30lr.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js → mobile-DtwK7DOV.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile-DtwK7DOV.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-DEkxEvOO.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-DEkxEvOO.js.map} +1 -1
- package/bizar-dash/dist/index.html +5 -5
- package/bizar-dash/dist/mobile.html +3 -3
- 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-store.mjs +55 -0
- package/bizar-dash/src/server/eval.mjs +63 -0
- package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
- package/bizar-dash/src/server/plugins/store.mjs +4 -0
- package/bizar-dash/src/server/routes/eval.mjs +42 -2
- package/bizar-dash/src/server/routes/plugins.mjs +55 -7
- 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/schedules-runner.mjs +61 -0
- package/bizar-dash/src/server/server.mjs +13 -0
- 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/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
- package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -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/ScheduleTemplateCard.tsx +1 -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 +9 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
- package/bizar-dash/src/web/styles/main.css +684 -0
- package/bizar-dash/src/web/views/Doctor.tsx +3 -3
- package/bizar-dash/src/web/views/Eval.tsx +372 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +353 -0
- package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
- package/bizar-dash/src/web/views/Plugins.tsx +134 -0
- package/bizar-dash/src/web/views/Schedules.tsx +1 -1
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/a11y/components.test.tsx +149 -0
- package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
- package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval-csv.test.mjs +67 -0
- package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/plugins-permissions.test.mjs +525 -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/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +0 -1
- package/bizar-dash/dist/assets/main-8feQWXiF.js +0 -19
- package/bizar-dash/dist/assets/main-8feQWXiF.js.map +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// tests/components/marketplace-plugin-card.test.tsx — MarketplacePluginCard and InstallConfirmDialog unit tests.
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { ModalProvider } from '../../src/web/components/Modal';
|
|
7
|
+
import { MarketplacePluginCard } from '../../src/web/components/MarketplacePluginCard';
|
|
8
|
+
import { InstallConfirmDialog } from '../../src/web/components/InstallConfirmDialog';
|
|
9
|
+
import { PluginPermissions } from '../../src/web/components/PluginPermissions';
|
|
10
|
+
|
|
11
|
+
const defaultPlugin = {
|
|
12
|
+
id: 'test-marketplace-plugin',
|
|
13
|
+
name: 'Test Marketplace Plugin',
|
|
14
|
+
version: '2.1.0',
|
|
15
|
+
author: 'TestAuthor',
|
|
16
|
+
description: 'A test plugin for the marketplace',
|
|
17
|
+
category: 'Utilities',
|
|
18
|
+
tags: ['testing', 'utility'],
|
|
19
|
+
permissions: ['net', 'fs'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('MarketplacePluginCard', () => {
|
|
23
|
+
it('renders name, version, description', () => {
|
|
24
|
+
render(
|
|
25
|
+
<MarketplacePluginCard
|
|
26
|
+
plugin={defaultPlugin}
|
|
27
|
+
onInstall={vi.fn()}
|
|
28
|
+
onView={vi.fn()}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByText('Test Marketplace Plugin')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByText('v2.1.0 · Utilities')).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByText('A test plugin for the marketplace')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('shows tags as chips', () => {
|
|
37
|
+
render(
|
|
38
|
+
<MarketplacePluginCard
|
|
39
|
+
plugin={defaultPlugin}
|
|
40
|
+
onInstall={vi.fn()}
|
|
41
|
+
onView={vi.fn()}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText('testing')).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByText('utility')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows permissions', () => {
|
|
49
|
+
render(
|
|
50
|
+
<MarketplacePluginCard
|
|
51
|
+
plugin={defaultPlugin}
|
|
52
|
+
onInstall={vi.fn()}
|
|
53
|
+
onView={vi.fn()}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
// PluginPermissions renders "Network access" for 'net' and "Filesystem" for 'fs'
|
|
57
|
+
expect(screen.getByText('Network access')).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByText('Filesystem')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('calls onInstall when Install clicked', async () => {
|
|
62
|
+
const onInstall = vi.fn();
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
render(
|
|
65
|
+
<MarketplacePluginCard
|
|
66
|
+
plugin={defaultPlugin}
|
|
67
|
+
onInstall={onInstall}
|
|
68
|
+
onView={vi.fn()}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
await user.click(screen.getByRole('button', { name: 'Install' }));
|
|
72
|
+
expect(onInstall).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('calls onView when Details clicked', async () => {
|
|
76
|
+
const onView = vi.fn();
|
|
77
|
+
const user = userEvent.setup();
|
|
78
|
+
render(
|
|
79
|
+
<MarketplacePluginCard
|
|
80
|
+
plugin={defaultPlugin}
|
|
81
|
+
onInstall={vi.fn()}
|
|
82
|
+
onView={onView}
|
|
83
|
+
/>,
|
|
84
|
+
);
|
|
85
|
+
await user.click(screen.getByRole('button', { name: 'Details' }));
|
|
86
|
+
expect(onView).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function InstallConfirmDialogTestHarness({ plugin, installing = false }: {
|
|
91
|
+
plugin: typeof defaultPlugin;
|
|
92
|
+
installing?: boolean;
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<ModalProvider>
|
|
96
|
+
<InstallConfirmDialog
|
|
97
|
+
plugin={plugin}
|
|
98
|
+
onConfirm={vi.fn()}
|
|
99
|
+
onCancel={vi.fn()}
|
|
100
|
+
installing={installing}
|
|
101
|
+
/>
|
|
102
|
+
</ModalProvider>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('InstallConfirmDialog', () => {
|
|
107
|
+
it('renders plugin name and description', async () => {
|
|
108
|
+
render(<InstallConfirmDialogTestHarness plugin={defaultPlugin} />);
|
|
109
|
+
// The dialog content is rendered in a modal, check for the title
|
|
110
|
+
expect(await screen.findByText('Install Test Marketplace Plugin?')).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByText('A test plugin for the marketplace')).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('shows permissions prominently', async () => {
|
|
115
|
+
render(<InstallConfirmDialogTestHarness plugin={defaultPlugin} />);
|
|
116
|
+
expect(await screen.findByText('Network access')).toBeInTheDocument();
|
|
117
|
+
expect(screen.getByText('Filesystem')).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('disables buttons during install', async () => {
|
|
121
|
+
render(<InstallConfirmDialogTestHarness plugin={defaultPlugin} installing={true} />);
|
|
122
|
+
expect(await screen.findByRole('button', { name: 'Installing...' })).toBeDisabled();
|
|
123
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('PluginPermissions', () => {
|
|
128
|
+
it('renders each permission as a chip', () => {
|
|
129
|
+
render(<PluginPermissions permissions={['net', 'fs']} />);
|
|
130
|
+
expect(screen.getByText('Network access')).toBeInTheDocument();
|
|
131
|
+
expect(screen.getByText('Filesystem')).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('shows unknown permission with default label', () => {
|
|
135
|
+
render(<PluginPermissions permissions={['unknown_perm']} />);
|
|
136
|
+
expect(screen.getByText('unknown_perm')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('handles empty permissions array', () => {
|
|
140
|
+
const { container } = render(<PluginPermissions permissions={[]} />);
|
|
141
|
+
expect(container.querySelector('.plugin-permissions')).toBeInTheDocument();
|
|
142
|
+
expect(container.querySelectorAll('.permission-chip')).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -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,67 @@
|
|
|
1
|
+
// tests/eval-csv.test.mjs — v5.3.0
|
|
2
|
+
// Tests for CSV export functionality in eval.mjs
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { toCSV } from '../src/server/eval.mjs';
|
|
7
|
+
|
|
8
|
+
describe('CSV export', () => {
|
|
9
|
+
it('produces valid CSV with headers', () => {
|
|
10
|
+
const csv = toCSV([
|
|
11
|
+
{ fixtureId: 'test-1', name: 'Test', ok: true, latencyMs: 100, checks: [] },
|
|
12
|
+
]);
|
|
13
|
+
assert.ok(csv.split('\n')[0].includes('fixture_id'));
|
|
14
|
+
assert.ok(csv.split('\n')[0].includes('name'));
|
|
15
|
+
assert.ok(csv.split('\n')[0].includes('ok'));
|
|
16
|
+
assert.ok(csv.split('\n')[0].includes('pass_count'));
|
|
17
|
+
assert.ok(csv.split('\n')[0].includes('fail_count'));
|
|
18
|
+
assert.ok(csv.split('\n')[0].includes('latency_ms'));
|
|
19
|
+
assert.ok(csv.split('\n')[0].includes('tokens'));
|
|
20
|
+
assert.ok(csv.split('\n')[0].includes('timestamp'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('escapes commas in values', () => {
|
|
24
|
+
const csv = toCSV([{ fixtureId: 'a,b', ok: true, checks: [] }]);
|
|
25
|
+
assert.ok(csv.includes('"a,b"'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('escapes quotes in values', () => {
|
|
29
|
+
const csv = toCSV([{ fixtureId: 'a"b', ok: true, checks: [] }]);
|
|
30
|
+
assert.ok(csv.includes('"a""b"'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('handles newlines in values', () => {
|
|
34
|
+
const csv = toCSV([{ fixtureId: 'a\nb', ok: true, checks: [] }]);
|
|
35
|
+
assert.ok(csv.includes('"a\nb"'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('outputs ok as 1 or 0', () => {
|
|
39
|
+
const csvPass = toCSV([{ fixtureId: 'p', ok: true, checks: [] }]);
|
|
40
|
+
const csvFail = toCSV([{ fixtureId: 'f', ok: false, checks: [] }]);
|
|
41
|
+
assert.ok(csvPass.match(/,1,/));
|
|
42
|
+
assert.ok(csvFail.match(/,0,/));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('counts pass/fail checks correctly', () => {
|
|
46
|
+
const csv = toCSV([
|
|
47
|
+
{
|
|
48
|
+
fixtureId: 'c1',
|
|
49
|
+
ok: false,
|
|
50
|
+
checks: [
|
|
51
|
+
{ pass: true },
|
|
52
|
+
{ pass: false, message: 'failed check' },
|
|
53
|
+
{ pass: true },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
// pass_count=2, fail_count=1
|
|
58
|
+
const row = csv.split('\n')[1];
|
|
59
|
+
assert.ok(row.match(/c1.*,2,1/));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns empty string for missing optional fields', () => {
|
|
63
|
+
const csv = toCSV([{ fixtureId: 'minimal' }]);
|
|
64
|
+
// Should not throw and should have all 9 columns
|
|
65
|
+
assert.strictEqual(csv.split('\n')[0].split(',').length, 9);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// tests/eval-scheduled.test.mjs — v5.3.0
|
|
2
|
+
// Tests for eval schedule CRUD
|
|
3
|
+
|
|
4
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { rmSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
describe('Eval schedule CRUD', () => {
|
|
10
|
+
const originalConfigDir = process.env.BIZAR_EVAL_CONFIG_DIR;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Use a unique config dir per test to avoid cross-test pollution
|
|
14
|
+
const testId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
15
|
+
process.env.BIZAR_EVAL_CONFIG_DIR = `/tmp/bizar-eval-config-${testId}`;
|
|
16
|
+
mkdirSync(process.env.BIZAR_EVAL_CONFIG_DIR, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.env.BIZAR_EVAL_CONFIG_DIR = originalConfigDir;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('registerEvalSchedule returns a schedule with id', async () => {
|
|
24
|
+
// Dynamic import ensures env var is set before module initializes
|
|
25
|
+
const mod = await import(`../src/server/eval-store.mjs?v=${Date.now()}`);
|
|
26
|
+
const { registerEvalSchedule } = mod;
|
|
27
|
+
const schedule = registerEvalSchedule({
|
|
28
|
+
name: 'Daily check',
|
|
29
|
+
suitePath: './fixtures',
|
|
30
|
+
cron: '0 8 * * *',
|
|
31
|
+
agent: 'thor',
|
|
32
|
+
});
|
|
33
|
+
assert.ok(schedule.id);
|
|
34
|
+
assert.strictEqual(schedule.name, 'Daily check');
|
|
35
|
+
assert.strictEqual(schedule.suitePath, './fixtures');
|
|
36
|
+
assert.strictEqual(schedule.cron, '0 8 * * *');
|
|
37
|
+
assert.strictEqual(schedule.agent, 'thor');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('listEvalSchedules returns all registered schedules', async () => {
|
|
41
|
+
const mod = await import(`../src/server/eval-store.mjs?v=${Date.now()}-${Math.random()}`);
|
|
42
|
+
const { registerEvalSchedule, listEvalSchedules } = mod;
|
|
43
|
+
registerEvalSchedule({ name: 's1', suitePath: './a', cron: '0 9 * * *' });
|
|
44
|
+
registerEvalSchedule({ name: 's2', suitePath: './b', cron: '0 10 * * *' });
|
|
45
|
+
const schedules = listEvalSchedules();
|
|
46
|
+
assert.strictEqual(schedules.length, 2);
|
|
47
|
+
assert.ok(schedules.find((s) => s.name === 's1'));
|
|
48
|
+
assert.ok(schedules.find((s) => s.name === 's2'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('deleteEvalSchedule removes a schedule by id', async () => {
|
|
52
|
+
const mod = await import(`../src/server/eval-store.mjs?v=${Date.now()}`);
|
|
53
|
+
const { registerEvalSchedule, listEvalSchedules, deleteEvalSchedule } = mod;
|
|
54
|
+
const sched = registerEvalSchedule({ name: 'to-delete', suitePath: './x', cron: '0 9 * * *' });
|
|
55
|
+
const result = deleteEvalSchedule(sched.id);
|
|
56
|
+
assert.strictEqual(result.ok, true);
|
|
57
|
+
const remaining = listEvalSchedules();
|
|
58
|
+
assert.strictEqual(remaining.find((s) => s.id === sched.id), undefined);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('deleteEvalSchedule returns ok:false for unknown id', async () => {
|
|
62
|
+
const mod = await import(`../src/server/eval-store.mjs?v=${Date.now()}`);
|
|
63
|
+
const { deleteEvalSchedule } = mod;
|
|
64
|
+
const result = deleteEvalSchedule('does-not-exist');
|
|
65
|
+
assert.strictEqual(result.ok, false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('schedule ids are unique across multiple registrations', async () => {
|
|
69
|
+
const mod = await import(`../src/server/eval-store.mjs?v=${Date.now()}`);
|
|
70
|
+
const { registerEvalSchedule, listEvalSchedules } = mod;
|
|
71
|
+
const ids = new Set();
|
|
72
|
+
for (let i = 0; i < 10; i++) {
|
|
73
|
+
ids.add(registerEvalSchedule({ name: `s${i}`, suitePath: '.', cron: '* * * * *' }).id);
|
|
74
|
+
}
|
|
75
|
+
assert.strictEqual(ids.size, 10);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('schedules runner eval-run action', () => {
|
|
80
|
+
it('schedulesRunner module loads and exposes tick', async () => {
|
|
81
|
+
const { schedulesRunner } = await import('../src/server/schedules-runner.mjs');
|
|
82
|
+
assert.strictEqual(typeof schedulesRunner.tick, 'function');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -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
|
+
});
|