@polderlabs/bizar 5.2.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.
Files changed (52) hide show
  1. package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js → MobileChat-OidrxXR9.js} +1 -1
  2. package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js.map → MobileChat-OidrxXR9.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js → MobileSettings-B91gVFii.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js.map → MobileSettings-B91gVFii.js.map} +1 -1
  5. package/bizar-dash/dist/assets/{icons-DRDXfbBP.js → icons-Bd8piSb2.js} +18 -13
  6. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-xFpWMd32.css → main-3G6mOhiK.css} +1 -1
  8. package/bizar-dash/dist/assets/main-6YUZY968.js +19 -0
  9. package/bizar-dash/dist/assets/main-6YUZY968.js.map +1 -0
  10. package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js → mobile-CSsp30lr.js} +2 -2
  11. package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js.map → mobile-CSsp30lr.js.map} +1 -1
  12. package/bizar-dash/dist/assets/{mobile--17fkfrl.js → mobile-DtwK7DOV.js} +1 -1
  13. package/bizar-dash/dist/assets/{mobile--17fkfrl.js.map → mobile-DtwK7DOV.js.map} +1 -1
  14. package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js → useSlashCommands-DEkxEvOO.js} +2 -2
  15. package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js.map → useSlashCommands-DEkxEvOO.js.map} +1 -1
  16. package/bizar-dash/dist/index.html +5 -5
  17. package/bizar-dash/dist/mobile.html +3 -3
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/src/server/eval-store.mjs +55 -0
  20. package/bizar-dash/src/server/eval.mjs +63 -0
  21. package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
  22. package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
  23. package/bizar-dash/src/server/plugins/store.mjs +4 -0
  24. package/bizar-dash/src/server/routes/eval.mjs +42 -2
  25. package/bizar-dash/src/server/routes/plugins.mjs +44 -4
  26. package/bizar-dash/src/server/schedules-runner.mjs +61 -0
  27. package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -0
  28. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
  29. package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -0
  30. package/bizar-dash/src/web/components/PluginCard.tsx +2 -2
  31. package/bizar-dash/src/web/components/PluginPermissions.tsx +1 -1
  32. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
  33. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  34. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
  35. package/bizar-dash/src/web/styles/main.css +139 -0
  36. package/bizar-dash/src/web/views/Doctor.tsx +3 -3
  37. package/bizar-dash/src/web/views/Eval.tsx +225 -25
  38. package/bizar-dash/src/web/views/EvalReport.tsx +7 -3
  39. package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
  40. package/bizar-dash/src/web/views/Plugins.tsx +9 -3
  41. package/bizar-dash/src/web/views/Schedules.tsx +1 -1
  42. package/bizar-dash/tests/a11y/components.test.tsx +149 -0
  43. package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
  44. package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
  45. package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
  46. package/bizar-dash/tests/eval-csv.test.mjs +67 -0
  47. package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
  48. package/bizar-dash/tests/plugins-permissions.test.mjs +525 -0
  49. package/package.json +1 -1
  50. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +0 -1
  51. package/bizar-dash/dist/assets/main-D5Ditnrd.js +0 -19
  52. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +0 -1
@@ -29,6 +29,7 @@ import {
29
29
  type EvalRunWithResults,
30
30
  type EvalFixtureResult,
31
31
  } from '../components/EvalDiff';
32
+ import { EvalExportButton } from '../components/EvalExportButton';
32
33
  import { api } from '../lib/api';
33
34
  import { cn, formatTime } from '../lib/utils';
34
35
  import type { Settings, Snapshot } from '../lib/types';
@@ -68,9 +69,12 @@ function RunDetail({
68
69
  <CardTitle>
69
70
  <ClipboardCheck size={14} aria-hidden /> {run.id}
70
71
  </CardTitle>
71
- <Button variant="ghost" size="sm" onClick={onClearSelection} title="Close detail">
72
- <X size={12} /> Close
73
- </Button>
72
+ <div className="eval-run-detail-actions">
73
+ <EvalExportButton runId={run.id} />
74
+ <Button variant="ghost" size="sm" onClick={onClearSelection} title="Close detail">
75
+ <X size={12} /> Close
76
+ </Button>
77
+ </div>
74
78
  </div>
75
79
  <CardMeta>
76
80
  {formatTime(run.startedAt)}
@@ -0,0 +1,135 @@
1
+ // src/web/views/Marketplace.tsx — browse and install plugins from the marketplace registry.
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { Store } from 'lucide-react';
5
+ import { MarketplacePluginCard, type MarketplacePlugin } from '../components/MarketplacePluginCard';
6
+ import { InstallConfirmDialog } from '../components/InstallConfirmDialog';
7
+ import { EmptyState } from '../components/EmptyState';
8
+ import { Spinner } from '../components/Spinner';
9
+ import { useToast } from '../components/Toast';
10
+ import { api } from '../lib/api';
11
+
12
+ type RegistryResponse = {
13
+ plugins: MarketplacePlugin[];
14
+ };
15
+
16
+ export function Marketplace() {
17
+ const toast = useToast();
18
+ const [registry, setRegistry] = useState<RegistryResponse | null>(null);
19
+ const [search, setSearch] = useState('');
20
+ const [category, setCategory] = useState<string | null>(null);
21
+ const [installing, setInstalling] = useState<MarketplacePlugin | null>(null);
22
+ const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
23
+ const [loading, setLoading] = useState(true);
24
+
25
+ useEffect(() => {
26
+ api.get<RegistryResponse>('/plugins/registry')
27
+ .then((r) => setRegistry(r))
28
+ .catch((err) => toast.error(`Failed to load marketplace: ${err.message}`))
29
+ .finally(() => setLoading(false));
30
+ }, [toast]);
31
+
32
+ const install = async (plugin: MarketplacePlugin) => {
33
+ setInstalling(plugin);
34
+ try {
35
+ await api.post('/plugins/install', { pluginId: plugin.id });
36
+ toast.success(`${plugin.name} installed`);
37
+ setInstalling(null);
38
+ setSelected(null);
39
+ } catch (err) {
40
+ toast.error(`Install failed: ${(err as Error).message}`);
41
+ setInstalling(null);
42
+ }
43
+ };
44
+
45
+ const plugins = registry?.plugins ?? [];
46
+ const filtered = plugins.filter(
47
+ (p) =>
48
+ (!search || p.name.toLowerCase().includes(search.toLowerCase())) &&
49
+ (!category || p.category === category),
50
+ );
51
+ const categories = [...new Set(plugins.map((p) => p.category))];
52
+
53
+ if (loading) {
54
+ return (
55
+ <div className="view-loading">
56
+ <Spinner size="lg" />
57
+ </div>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div className="view view-marketplace">
63
+ <header className="view-header">
64
+ <div className="view-header-text">
65
+ <h2 className="view-title">
66
+ <Store size={18} aria-hidden />
67
+ Plugin Marketplace
68
+ </h2>
69
+ <p className="view-subtitle">
70
+ Discover and install plugins from the community registry.
71
+ </p>
72
+ </div>
73
+ </header>
74
+
75
+ <div className="marketplace-search-row">
76
+ <input
77
+ className="input"
78
+ type="search"
79
+ placeholder="Search plugins..."
80
+ value={search}
81
+ onChange={(e) => setSearch(e.target.value)}
82
+ style={{ maxWidth: 320 }}
83
+ />
84
+ </div>
85
+
86
+ <div className="category-chips">
87
+ <button
88
+ type="button"
89
+ onClick={() => setCategory(null)}
90
+ className={!category ? 'active' : ''}
91
+ >
92
+ All
93
+ </button>
94
+ {categories.map((c) => (
95
+ <button
96
+ key={c}
97
+ type="button"
98
+ onClick={() => setCategory(c)}
99
+ className={category === c ? 'active' : ''}
100
+ >
101
+ {c}
102
+ </button>
103
+ ))}
104
+ </div>
105
+
106
+ {filtered.length === 0 ? (
107
+ <EmptyState
108
+ icon={<Store size={32} />}
109
+ title="No plugins found"
110
+ message={search ? `No plugins match "${search}".` : 'The marketplace is empty.'}
111
+ />
112
+ ) : (
113
+ <div className="marketplace-grid">
114
+ {filtered.map((p) => (
115
+ <MarketplacePluginCard
116
+ key={p.id}
117
+ plugin={p}
118
+ onInstall={() => setSelected(p)}
119
+ onView={() => setSelected(p)}
120
+ />
121
+ ))}
122
+ </div>
123
+ )}
124
+
125
+ {selected && (
126
+ <InstallConfirmDialog
127
+ plugin={selected}
128
+ onConfirm={() => install(selected)}
129
+ onCancel={() => setSelected(null)}
130
+ installing={installing?.id === selected.id || false}
131
+ />
132
+ )}
133
+ </div>
134
+ );
135
+ }
@@ -1,10 +1,11 @@
1
1
  // src/views/Plugins.tsx — manage installed plugins and their permissions.
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { Puzzle } from 'lucide-react';
4
+ import { Puzzle, Store } from 'lucide-react';
5
5
  import { PluginCard, type InstalledPlugin } from '../components/PluginCard';
6
6
  import { EmptyState } from '../components/EmptyState';
7
7
  import { Spinner } from '../components/Spinner';
8
+ import { Button } from '../components/Button';
8
9
  import { useToast } from '../components/Toast';
9
10
  import { api } from '../lib/api';
10
11
  import type { Settings, Snapshot } from '../lib/types';
@@ -17,7 +18,7 @@ type Props = {
17
18
  refreshSnapshot: () => Promise<void>;
18
19
  };
19
20
 
20
- export function Plugins(_props: Props) {
21
+ export function Plugins({ setActiveTab }: Props) {
21
22
  const toast = useToast();
22
23
  const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
23
24
  const [loading, setLoading] = useState(true);
@@ -90,13 +91,17 @@ export function Plugins(_props: Props) {
90
91
  Manage plugins installed via the marketplace.
91
92
  </p>
92
93
  </div>
94
+ <Button variant="secondary" size="sm" onClick={() => setActiveTab('marketplace')}>
95
+ <Store size={14} aria-hidden />
96
+ Browse Marketplace
97
+ </Button>
93
98
  </header>
94
99
 
95
100
  {plugins.length === 0 ? (
96
101
  <EmptyState
97
102
  icon={<Puzzle size={32} />}
98
103
  title="No plugins installed"
99
- message="Install plugins from the marketplace using the CLI."
104
+ message="Browse the marketplace to discover and install plugins."
100
105
  />
101
106
  ) : (
102
107
  <>
@@ -107,6 +112,7 @@ export function Plugins(_props: Props) {
107
112
  placeholder="Filter plugins…"
108
113
  value={filter}
109
114
  onChange={(e) => setFilter(e.target.value)}
115
+ aria-label="Filter plugins"
110
116
  style={{ maxWidth: 320 }}
111
117
  />
112
118
  </div>
@@ -372,7 +372,7 @@ export function Schedules({ snapshot, refreshSnapshot }: Props) {
372
372
  <div className="view-actions">
373
373
  {activeSection === 'schedules' && (
374
374
  <>
375
- <Button variant="secondary" size="sm" onClick={reload}>
375
+ <Button variant="secondary" size="sm" onClick={reload} aria-label="Refresh schedules">
376
376
  <RefreshCw size={14} /> Refresh
377
377
  </Button>
378
378
  <Button variant="primary" size="sm" onClick={() => openEditor()}>
@@ -0,0 +1,149 @@
1
+ // tests/a11y/components.test.tsx — WCAG 2.2 AA accessibility for interactive components.
2
+ //
3
+ // Tests Toggle role/switch behavior, PluginCard button accessible names,
4
+ // PluginPermissions container label, and semantic structure.
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { render, screen } from '@testing-library/react';
8
+ import { Toggle } from '../../src/web/components/Toggle';
9
+ import { PluginCard } from '../../src/web/components/PluginCard';
10
+ import { PluginPermissions } from '../../src/web/components/PluginPermissions';
11
+ import { ScheduleTemplateCard } from '../../src/web/components/ScheduleTemplateCard';
12
+
13
+ // ─── Helpers ────────────────────────────────────────────────────────────────
14
+
15
+ /** Assert every button in the container has an accessible name. */
16
+ function expectAllButtonsAccessible(container: HTMLElement) {
17
+ const buttons = container.querySelectorAll('button');
18
+ const offenders: string[] = [];
19
+ buttons.forEach((el) => {
20
+ const ariaLabel = el.getAttribute('aria-label');
21
+ const labelledBy = el.getAttribute('aria-labelledby');
22
+ const text = (el.textContent || '').trim();
23
+ const title = el.getAttribute('title');
24
+ if (!ariaLabel && !labelledBy && !text && !title) {
25
+ offenders.push(`<button> has no accessible name`);
26
+ }
27
+ });
28
+ expect(offenders, offenders.join('\n')).toEqual([]);
29
+ }
30
+
31
+ // ─── Toggle ────────────────────────────────────────────────────────────────
32
+
33
+ describe('Toggle — WCAG 4.4.2: role=switch + aria-checked', () => {
34
+ it('has role="switch" on the input element', () => {
35
+ render(<Toggle checked={false} onChange={() => {}} />);
36
+ const input = document.querySelector('input[type="checkbox"]');
37
+ expect(input).toHaveAttribute('role', 'switch');
38
+ });
39
+
40
+ it('has aria-checked matching the checked prop', () => {
41
+ const { rerender } = render(<Toggle checked={true} onChange={() => {}} />);
42
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
43
+ rerender(<Toggle checked={false} onChange={() => {}} />);
44
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
45
+ });
46
+
47
+ it('has a visible focus indicator via :focus-visible', () => {
48
+ const { container } = render(<Toggle checked={false} onChange={() => {}} />);
49
+ const input = container.querySelector('input');
50
+ expect(input).toBeInTheDocument();
51
+ });
52
+ });
53
+
54
+ // ─── PluginCard ─────────────────────────────────────────────────────────────
55
+
56
+ const FAKE_PLUGIN = {
57
+ id: 'test-plugin',
58
+ name: 'Test Plugin',
59
+ version: '1.0.0',
60
+ description: 'A test plugin',
61
+ permissions: ['net', 'fs'],
62
+ enabled: true,
63
+ invocations: 0,
64
+ lastInvokedAt: null,
65
+ };
66
+
67
+ describe('PluginCard — WCAG 2.1.1: all buttons have accessible names', () => {
68
+ it('the toggle has an accessible name via aria-label', () => {
69
+ render(
70
+ <PluginCard
71
+ plugin={FAKE_PLUGIN}
72
+ onToggle={() => {}}
73
+ onUninstall={() => {}}
74
+ onConfigure={() => {}}
75
+ />,
76
+ );
77
+ const toggle = screen.getByRole('switch');
78
+ expect(toggle).toHaveAttribute('aria-label', 'Toggle Test Plugin enabled');
79
+ });
80
+
81
+ it('the Configure button has an aria-label', () => {
82
+ render(
83
+ <PluginCard
84
+ plugin={FAKE_PLUGIN}
85
+ onToggle={() => {}}
86
+ onUninstall={() => {}}
87
+ onConfigure={() => {}}
88
+ />,
89
+ );
90
+ const configureBtn = screen.getByRole('button', { name: /configure test plugin/i });
91
+ expect(configureBtn).toBeInTheDocument();
92
+ });
93
+
94
+ it('the Uninstall button has an aria-label', () => {
95
+ render(
96
+ <PluginCard
97
+ plugin={FAKE_PLUGIN}
98
+ onToggle={() => {}}
99
+ onUninstall={() => {}}
100
+ onConfigure={() => {}}
101
+ />,
102
+ );
103
+ const uninstallBtn = screen.getByRole('button', { name: /uninstall test plugin/i });
104
+ expect(uninstallBtn).toBeInTheDocument();
105
+ });
106
+
107
+ it('all buttons inside the card have accessible names', () => {
108
+ const { container } = render(
109
+ <PluginCard
110
+ plugin={FAKE_PLUGIN}
111
+ onToggle={() => {}}
112
+ onUninstall={() => {}}
113
+ onConfigure={() => {}}
114
+ />,
115
+ );
116
+ expectAllButtonsAccessible(container);
117
+ });
118
+ });
119
+
120
+ // ─── PluginPermissions ──────────────────────────────────────────────────────
121
+
122
+ describe('PluginPermissions — WCAG 1.3.1: container has aria-label', () => {
123
+ it('the permissions container has aria-label="Required permissions"', () => {
124
+ render(<PluginPermissions permissions={['net', 'fs']} />);
125
+ const container = document.querySelector('.plugin-permissions');
126
+ expect(container).toHaveAttribute('aria-label', 'Required permissions');
127
+ });
128
+ });
129
+
130
+ // ─── ScheduleTemplateCard ────────────────────────────────────────────────────
131
+
132
+ describe('ScheduleTemplateCard — WCAG 2.1.1: button has accessible name', () => {
133
+ it('the Use this template button has an aria-label', () => {
134
+ render(
135
+ <ScheduleTemplateCard
136
+ template={{
137
+ id: 't1',
138
+ name: 'Weekly Review',
139
+ description: 'Run weekly review',
140
+ type: 'cron',
141
+ action: { type: 'agent', target: 'weekly-reviewer' },
142
+ }}
143
+ onUse={() => {}}
144
+ />,
145
+ );
146
+ const btn = screen.getByRole('button', { name: /use template: weekly review/i });
147
+ expect(btn).toBeInTheDocument();
148
+ });
149
+ });
@@ -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
+ });