@polderlabs/bizar 5.2.0 → 5.3.1
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 → MobileChat-OidrxXR9.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js.map → MobileChat-OidrxXR9.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js → MobileSettings-B91gVFii.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js.map → MobileSettings-B91gVFii.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-DRDXfbBP.js → icons-Bd8piSb2.js} +18 -13
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
- package/bizar-dash/dist/assets/{main-xFpWMd32.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-j3rOZK6v.js → mobile-CSsp30lr.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js.map → mobile-CSsp30lr.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile--17fkfrl.js → mobile-DtwK7DOV.js} +1 -1
- package/bizar-dash/dist/assets/{mobile--17fkfrl.js.map → mobile-DtwK7DOV.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js → useSlashCommands-DEkxEvOO.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.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/auth.mjs +14 -1
- 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 +44 -4
- package/bizar-dash/src/server/routes/users.mjs +18 -0
- package/bizar-dash/src/server/schedules-runner.mjs +61 -0
- package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -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 +2 -2
- package/bizar-dash/src/web/components/PluginPermissions.tsx +1 -1
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
- package/bizar-dash/src/web/components/Topbar.tsx +3 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
- package/bizar-dash/src/web/styles/main.css +139 -0
- package/bizar-dash/src/web/views/Doctor.tsx +3 -3
- package/bizar-dash/src/web/views/Eval.tsx +225 -25
- package/bizar-dash/src/web/views/EvalReport.tsx +7 -3
- package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
- package/bizar-dash/src/web/views/Plugins.tsx +9 -3
- package/bizar-dash/src/web/views/Schedules.tsx +1 -1
- 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/components/marketplace-plugin-card.test.tsx +144 -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/plugins-permissions.test.mjs +525 -0
- package/cli/commands/dash.mjs +5 -0
- package/cli/commands/tailscale.mjs +8 -1
- package/package.json +1 -1
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +0 -1
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +0 -19
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +0 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// tests/a11y/forms.test.tsx — WCAG 2.1.1 / 3.3.1 form accessibility for Doctor and Schedules.
|
|
2
|
+
//
|
|
3
|
+
// Tests that all form inputs have accessible names (label, aria-label,
|
|
4
|
+
// or aria-labelledby), and that form errors and live regions are announced.
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import { Doctor } from '../../src/web/views/Doctor';
|
|
9
|
+
import { Schedules } from '../../src/web/views/Schedules';
|
|
10
|
+
import { ToastProvider } from '../../src/web/components/Toast';
|
|
11
|
+
import { ModalProvider } from '../../src/web/components/Modal';
|
|
12
|
+
|
|
13
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Assert every input/select/textarea in the container has an accessible name. */
|
|
16
|
+
function expectAllInputsAccessible(container: HTMLElement) {
|
|
17
|
+
const inputs = container.querySelectorAll('input, select, textarea');
|
|
18
|
+
const offenders: string[] = [];
|
|
19
|
+
inputs.forEach((el) => {
|
|
20
|
+
if ((el as HTMLInputElement).type === 'hidden') return;
|
|
21
|
+
const accessibleName =
|
|
22
|
+
el.getAttribute('aria-label') ||
|
|
23
|
+
el.getAttribute('aria-labelledby') ||
|
|
24
|
+
el.getAttribute('placeholder') ||
|
|
25
|
+
el.getAttribute('title');
|
|
26
|
+
let labelledByLabel = false;
|
|
27
|
+
if (el.id) {
|
|
28
|
+
labelledByLabel = !!container.querySelector(`label[for="${el.id}"]`);
|
|
29
|
+
}
|
|
30
|
+
if (!accessibleName && !labelledByLabel) {
|
|
31
|
+
offenders.push(
|
|
32
|
+
`<${el.tagName.toLowerCase()}${el.id ? ` id="${el.id}"` : ''}${el.getAttribute('type') ? ` type="${el.getAttribute('type')}"` : ''}> has no accessible name`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
expect(offenders, offenders.join('\n')).toEqual([]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Assert every button in the container has an accessible name. */
|
|
40
|
+
function expectAllButtonsAccessible(container: HTMLElement) {
|
|
41
|
+
const buttons = container.querySelectorAll('button');
|
|
42
|
+
const offenders: string[] = [];
|
|
43
|
+
buttons.forEach((el) => {
|
|
44
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
45
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
46
|
+
const text = (el.textContent || '').trim();
|
|
47
|
+
const title = el.getAttribute('title');
|
|
48
|
+
if (!ariaLabel && !labelledBy && !text && !title) {
|
|
49
|
+
offenders.push(`<button> has no accessible name`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
expect(offenders, offenders.join('\n')).toEqual([]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderWithProviders(ui: React.ReactNode) {
|
|
56
|
+
return render(
|
|
57
|
+
<ToastProvider>
|
|
58
|
+
<ModalProvider>{ui}</ModalProvider>
|
|
59
|
+
</ToastProvider>,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Doctor ─────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const FAKE_SNAPSHOT = {
|
|
66
|
+
bizarreVersion: '99.0.0',
|
|
67
|
+
platform: 'linux',
|
|
68
|
+
arch: 'x64',
|
|
69
|
+
uptime: 3600,
|
|
70
|
+
tasks: 0,
|
|
71
|
+
schedules: 0,
|
|
72
|
+
mods: 0,
|
|
73
|
+
providers: 0,
|
|
74
|
+
mcps: 0,
|
|
75
|
+
agents: 0,
|
|
76
|
+
projects: 0,
|
|
77
|
+
workspaces: 0,
|
|
78
|
+
voiceNotes: 0,
|
|
79
|
+
evalRuns: 0,
|
|
80
|
+
backups: 0,
|
|
81
|
+
activeProject: null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const FAKE_SETTINGS = {};
|
|
85
|
+
|
|
86
|
+
describe('Doctor — WCAG 3.3.1: form inputs have accessible names + aria-live', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
// Mock the API to avoid real network calls
|
|
90
|
+
vi.spyOn(require('../../src/web/lib/api'), 'api').mockImplementation(() => ({
|
|
91
|
+
get: vi.fn().mockResolvedValue({
|
|
92
|
+
health: { status: 'ok', issues: [] },
|
|
93
|
+
checks: { system: [], services: [], config: [], counts: [] },
|
|
94
|
+
bizarreVersion: '99.0.0',
|
|
95
|
+
platform: 'linux',
|
|
96
|
+
arch: 'x64',
|
|
97
|
+
uptime: 3600,
|
|
98
|
+
counts: {},
|
|
99
|
+
recentErrors: [],
|
|
100
|
+
}),
|
|
101
|
+
post: vi.fn().mockResolvedValue({ name: 'test', message: 'ok' }),
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('the "Run Health Check Now" button has aria-label="Refresh diagnostics"', async () => {
|
|
106
|
+
renderWithProviders(
|
|
107
|
+
<Doctor
|
|
108
|
+
snapshot={FAKE_SNAPSHOT}
|
|
109
|
+
settings={FAKE_SETTINGS}
|
|
110
|
+
activeTab="doctor"
|
|
111
|
+
setActiveTab={() => {}}
|
|
112
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
const btn = screen.getByRole('button', { name: /refresh diagnostics/i });
|
|
116
|
+
expect(btn).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('the auto-refresh toggle button has an accessible name', async () => {
|
|
120
|
+
renderWithProviders(
|
|
121
|
+
<Doctor
|
|
122
|
+
snapshot={FAKE_SNAPSHOT}
|
|
123
|
+
settings={FAKE_SETTINGS}
|
|
124
|
+
activeTab="doctor"
|
|
125
|
+
setActiveTab={() => {}}
|
|
126
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
127
|
+
/>,
|
|
128
|
+
);
|
|
129
|
+
// The button shows "Pause auto-refresh" or "Resume auto-refresh"
|
|
130
|
+
const btn = screen.getByRole('button', { name: /(pause|resume) auto-refresh/i });
|
|
131
|
+
expect(btn).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('header meta region has aria-live="polite"', async () => {
|
|
135
|
+
renderWithProviders(
|
|
136
|
+
<Doctor
|
|
137
|
+
snapshot={FAKE_SNAPSHOT}
|
|
138
|
+
settings={FAKE_SETTINGS}
|
|
139
|
+
activeTab="doctor"
|
|
140
|
+
setActiveTab={() => {}}
|
|
141
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
142
|
+
/>,
|
|
143
|
+
);
|
|
144
|
+
const liveRegion = document.querySelector('[aria-live="polite"]');
|
|
145
|
+
expect(liveRegion).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ─── Schedules ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('Schedules — WCAG 3.3.1: all form inputs have accessible names', () => {
|
|
152
|
+
it('the Refresh button has aria-label="Refresh schedules"', () => {
|
|
153
|
+
renderWithProviders(
|
|
154
|
+
<Schedules
|
|
155
|
+
snapshot={FAKE_SNAPSHOT}
|
|
156
|
+
settings={FAKE_SETTINGS}
|
|
157
|
+
activeTab="schedules"
|
|
158
|
+
setActiveTab={() => {}}
|
|
159
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
160
|
+
/>,
|
|
161
|
+
);
|
|
162
|
+
const btn = screen.getByRole('button', { name: /refresh schedules/i });
|
|
163
|
+
expect(btn).toBeInTheDocument();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('all visible inputs in the Schedules view have accessible names', () => {
|
|
167
|
+
const { container } = renderWithProviders(
|
|
168
|
+
<Schedules
|
|
169
|
+
snapshot={FAKE_SNAPSHOT}
|
|
170
|
+
settings={FAKE_SETTINGS}
|
|
171
|
+
activeTab="schedules"
|
|
172
|
+
setActiveTab={() => {}}
|
|
173
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
expectAllInputsAccessible(container);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('all visible buttons in the Schedules view have accessible names', () => {
|
|
180
|
+
const { container } = renderWithProviders(
|
|
181
|
+
<Schedules
|
|
182
|
+
snapshot={FAKE_SNAPSHOT}
|
|
183
|
+
settings={FAKE_SETTINGS}
|
|
184
|
+
activeTab="schedules"
|
|
185
|
+
setActiveTab={() => {}}
|
|
186
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
expectAllButtonsAccessible(container);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/a11y/navigation.test.tsx — WCAG 2.1.1 / 2.4.1 navigation accessibility.
|
|
2
|
+
//
|
|
3
|
+
// Tests that sidebar, tabs, and the Memory source rail all meet
|
|
4
|
+
// keyboard navigation, role, and aria attributes requirements.
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import { Memory } from '../../src/web/views/Memory';
|
|
9
|
+
import { Plugins } from '../../src/web/views/Plugins';
|
|
10
|
+
import { ToastProvider } from '../../src/web/components/Toast';
|
|
11
|
+
import { ModalProvider } from '../../src/web/components/Modal';
|
|
12
|
+
|
|
13
|
+
function renderWithProviders(ui: React.ReactNode) {
|
|
14
|
+
return render(
|
|
15
|
+
<ToastProvider>
|
|
16
|
+
<ModalProvider>{ui}</ModalProvider>
|
|
17
|
+
</ToastProvider>,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Memory tab ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('Memory — WCAG 2.4.1: nav has aria-label, tabs have role=tab + aria-selected', () => {
|
|
24
|
+
it('source rail has aria-label="Memory sources"', () => {
|
|
25
|
+
renderWithProviders(
|
|
26
|
+
<Memory
|
|
27
|
+
snapshot={{} as any}
|
|
28
|
+
settings={{} as any}
|
|
29
|
+
activeTab="memory"
|
|
30
|
+
setActiveTab={() => {}}
|
|
31
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
const nav = document.querySelector('[aria-label="Memory sources"]');
|
|
35
|
+
expect(nav).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('each source button has role="tab"', () => {
|
|
39
|
+
renderWithProviders(
|
|
40
|
+
<Memory
|
|
41
|
+
snapshot={{} as any}
|
|
42
|
+
settings={{} as any}
|
|
43
|
+
activeTab="memory"
|
|
44
|
+
setActiveTab={() => {}}
|
|
45
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
const tabs = screen.getAllByRole('tab');
|
|
49
|
+
expect(tabs.length).toBeGreaterThan(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('active tab has aria-selected="true"', () => {
|
|
53
|
+
renderWithProviders(
|
|
54
|
+
<Memory
|
|
55
|
+
snapshot={{} as any}
|
|
56
|
+
settings={{} as any}
|
|
57
|
+
activeTab="memory"
|
|
58
|
+
setActiveTab={() => {}}
|
|
59
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
const activeTab = screen.getByRole('tab', { selected: true });
|
|
63
|
+
expect(activeTab).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('inactive tabs have aria-selected="false"', () => {
|
|
67
|
+
renderWithProviders(
|
|
68
|
+
<Memory
|
|
69
|
+
snapshot={{} as any}
|
|
70
|
+
settings={{} as any}
|
|
71
|
+
activeTab="memory"
|
|
72
|
+
setActiveTab={() => {}}
|
|
73
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
const tabs = screen.getAllByRole('tab');
|
|
77
|
+
const unselected = tabs.filter((t) => !t.getAttribute('aria-selected') || t.getAttribute('aria-selected') === 'false');
|
|
78
|
+
expect(unselected.length).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('refresh button has aria-label="Refresh memory"', () => {
|
|
82
|
+
renderWithProviders(
|
|
83
|
+
<Memory
|
|
84
|
+
snapshot={{} as any}
|
|
85
|
+
settings={{} as any}
|
|
86
|
+
activeTab="memory"
|
|
87
|
+
setActiveTab={() => {}}
|
|
88
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
const btn = screen.getByRole('button', { name: /refresh memory/i });
|
|
92
|
+
expect(btn).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── Plugins ─────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('Plugins — WCAG 2.1.1: filter input has accessible name', () => {
|
|
99
|
+
it('the filter input has aria-label="Filter plugins"', () => {
|
|
100
|
+
renderWithProviders(
|
|
101
|
+
<Plugins
|
|
102
|
+
snapshot={{} as any}
|
|
103
|
+
settings={{} as any}
|
|
104
|
+
activeTab="plugins"
|
|
105
|
+
setActiveTab={() => {}}
|
|
106
|
+
refreshSnapshot={() => Promise.resolve()}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
const input = document.querySelector('input[type="search"]');
|
|
110
|
+
expect(input).toHaveAttribute('aria-label', 'Filter plugins');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -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,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
|
+
});
|