@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.
Files changed (56) 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/auth.mjs +14 -1
  20. package/bizar-dash/src/server/eval-store.mjs +55 -0
  21. package/bizar-dash/src/server/eval.mjs +63 -0
  22. package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
  23. package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
  24. package/bizar-dash/src/server/plugins/store.mjs +4 -0
  25. package/bizar-dash/src/server/routes/eval.mjs +42 -2
  26. package/bizar-dash/src/server/routes/plugins.mjs +44 -4
  27. package/bizar-dash/src/server/routes/users.mjs +18 -0
  28. package/bizar-dash/src/server/schedules-runner.mjs +61 -0
  29. package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -0
  30. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
  31. package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -0
  32. package/bizar-dash/src/web/components/PluginCard.tsx +2 -2
  33. package/bizar-dash/src/web/components/PluginPermissions.tsx +1 -1
  34. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
  35. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  36. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
  37. package/bizar-dash/src/web/styles/main.css +139 -0
  38. package/bizar-dash/src/web/views/Doctor.tsx +3 -3
  39. package/bizar-dash/src/web/views/Eval.tsx +225 -25
  40. package/bizar-dash/src/web/views/EvalReport.tsx +7 -3
  41. package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
  42. package/bizar-dash/src/web/views/Plugins.tsx +9 -3
  43. package/bizar-dash/src/web/views/Schedules.tsx +1 -1
  44. package/bizar-dash/tests/a11y/components.test.tsx +149 -0
  45. package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
  46. package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
  47. package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
  48. package/bizar-dash/tests/eval-csv.test.mjs +67 -0
  49. package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
  50. package/bizar-dash/tests/plugins-permissions.test.mjs +525 -0
  51. package/cli/commands/dash.mjs +5 -0
  52. package/cli/commands/tailscale.mjs +8 -1
  53. package/package.json +1 -1
  54. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +0 -1
  55. package/bizar-dash/dist/assets/main-D5Ditnrd.js +0 -19
  56. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +0 -1
@@ -10,7 +10,7 @@
10
10
  // overview pattern (counts at top, recent history, call to action).
11
11
 
12
12
  import React, { useCallback, useEffect, useRef, useState } from 'react';
13
- import { ClipboardCheck, ArrowRight, RefreshCw, ExternalLink } from 'lucide-react';
13
+ import { ClipboardCheck, ArrowRight, RefreshCw, ExternalLink, Clock, Plus, Trash2 } from 'lucide-react';
14
14
  import { Card, CardTitle, CardMeta } from '../components/Card';
15
15
  import { Button } from '../components/Button';
16
16
  import { EmptyState } from '../components/EmptyState';
@@ -21,6 +21,14 @@ import { api } from '../lib/api';
21
21
  import { cn } from '../lib/utils';
22
22
  import type { Settings, Snapshot } from '../lib/types';
23
23
 
24
+ type EvalSchedule = {
25
+ id: string;
26
+ name: string;
27
+ suitePath: string;
28
+ cron: string;
29
+ agent: string;
30
+ };
31
+
24
32
  type Props = {
25
33
  snapshot: Snapshot;
26
34
  settings: Settings;
@@ -31,10 +39,103 @@ type Props = {
31
39
 
32
40
  const RECENT_RUNS_LIMIT = 8;
33
41
 
42
+ function AddScheduleForm({
43
+ onAdded,
44
+ onCancel,
45
+ }: {
46
+ onAdded: (schedule: EvalSchedule) => void;
47
+ onCancel: () => void;
48
+ }) {
49
+ const toast = useToast();
50
+ const [name, setName] = useState('');
51
+ const [suitePath, setSuitePath] = useState('');
52
+ const [cron, setCron] = useState('');
53
+ const [agent, setAgent] = useState('thor');
54
+ const [submitting, setSubmitting] = useState(false);
55
+
56
+ const handleSubmit = async (e: React.FormEvent) => {
57
+ e.preventDefault();
58
+ if (!name || !suitePath || !cron) {
59
+ toast.error('name, suitePath, and cron are required');
60
+ return;
61
+ }
62
+ setSubmitting(true);
63
+ try {
64
+ const result = await api.post<EvalSchedule>('/eval/schedules', { name, suitePath, cron, agent });
65
+ onAdded(result);
66
+ toast.success('Schedule created');
67
+ } catch (err) {
68
+ toast.error(`Failed to create schedule: ${(err as Error).message}`);
69
+ } finally {
70
+ setSubmitting(false);
71
+ }
72
+ };
73
+
74
+ return (
75
+ <form className="eval-schedule-form" onSubmit={handleSubmit}>
76
+ <div className="form-row">
77
+ <label htmlFor="sched-name">Name</label>
78
+ <input
79
+ id="sched-name"
80
+ type="text"
81
+ value={name}
82
+ onChange={(e) => setName(e.target.value)}
83
+ placeholder="Daily fixture check"
84
+ required
85
+ />
86
+ </div>
87
+ <div className="form-row">
88
+ <label htmlFor="sched-suite">Suite path</label>
89
+ <input
90
+ id="sched-suite"
91
+ type="text"
92
+ value={suitePath}
93
+ onChange={(e) => setSuitePath(e.target.value)}
94
+ placeholder="./fixtures"
95
+ required
96
+ />
97
+ </div>
98
+ <div className="form-row">
99
+ <label htmlFor="sched-cron">Cron</label>
100
+ <input
101
+ id="sched-cron"
102
+ type="text"
103
+ value={cron}
104
+ onChange={(e) => setCron(e.target.value)}
105
+ placeholder="0 8 * * *"
106
+ required
107
+ />
108
+ </div>
109
+ <div className="form-row">
110
+ <label htmlFor="sched-agent">Agent</label>
111
+ <input
112
+ id="sched-agent"
113
+ type="text"
114
+ value={agent}
115
+ onChange={(e) => setAgent(e.target.value)}
116
+ placeholder="thor"
117
+ />
118
+ </div>
119
+ <div className="form-actions">
120
+ <Button type="submit" variant="primary" size="sm" disabled={submitting}>
121
+ {submitting ? 'Creating…' : 'Create schedule'}
122
+ </Button>
123
+ <Button type="button" variant="ghost" size="sm" onClick={onCancel}>
124
+ Cancel
125
+ </Button>
126
+ </div>
127
+ </form>
128
+ );
129
+ }
130
+
34
131
  function EvalInner({ setActiveTab }: Props) {
35
132
  const toast = useToast();
36
133
  const [runs, setRuns] = useState<EvalRunSummary[]>([]);
37
134
  const [loading, setLoading] = useState(true);
135
+ const [subTab, setSubTab] = useState<'runs' | 'schedules'>('runs');
136
+ const [schedules, setSchedules] = useState<EvalSchedule[]>([]);
137
+ const [loadingSchedules, setLoadingSchedules] = useState(false);
138
+ const [showAddSchedule, setShowAddSchedule] = useState(false);
38
139
  const cancelledRef = useRef(false);
39
140
 
40
141
  const load = useCallback(async () => {
@@ -51,11 +152,36 @@ function EvalInner({ setActiveTab }: Props) {
51
152
  }
52
153
  }, [toast]);
53
154
 
155
+ const loadSchedules = useCallback(async () => {
156
+ setLoadingSchedules(true);
157
+ try {
158
+ const r = await api.get<{ schedules: EvalSchedule[] }>('/eval/schedules');
159
+ if (!cancelledRef.current) setSchedules(r.schedules || []);
160
+ } catch (err) {
161
+ if (!cancelledRef.current) {
162
+ toast.error(`Failed to load eval schedules: ${(err as Error).message}`);
163
+ }
164
+ } finally {
165
+ if (!cancelledRef.current) setLoadingSchedules(false);
166
+ }
167
+ }, [toast]);
168
+
54
169
  useEffect(() => {
55
170
  cancelledRef.current = false;
56
171
  load();
172
+ if (subTab === 'schedules') loadSchedules();
57
173
  return () => { cancelledRef.current = true; };
58
- }, [load]);
174
+ }, [load, loadSchedules, subTab]);
175
+
176
+ const deleteSchedule = async (id: string) => {
177
+ try {
178
+ await api.del(`/eval/schedules/${id}`);
179
+ setSchedules((prev) => prev.filter((s) => s.id !== id));
180
+ toast.success('Schedule deleted');
181
+ } catch (err) {
182
+ toast.error(`Failed to delete schedule: ${(err as Error).message}`);
183
+ }
184
+ };
59
185
 
60
186
  const recent = runs.slice(0, RECENT_RUNS_LIMIT);
61
187
  const totals = runs.reduce(
@@ -139,30 +265,104 @@ function EvalInner({ setActiveTab }: Props) {
139
265
  </div>
140
266
 
141
267
  <div className="eval-recent-section">
142
- <h2 className="eval-section-title">Recent runs</h2>
143
- {loading && recent.length === 0 ? (
144
- <div className="eval-runs-loading">
145
- <Spinner size="sm" />
146
- <span className="muted">Loading runs…</span>
147
- </div>
148
- ) : recent.length === 0 ? (
149
- <EmptyState
150
- icon={<ClipboardCheck size={28} />}
151
- title="No eval runs yet"
152
- message={
153
- <>
154
- Run a suite via the CLI:{' '}
155
- <code className="mono">bizar eval run ./path/to/fixtures</code>.
156
- Results will appear here.
157
- </>
158
- }
159
- />
268
+ <div className="eval-sub-tabs">
269
+ <button
270
+ type="button"
271
+ className={cn('eval-sub-tab', subTab === 'runs' ? 'is-active' : '')}
272
+ onClick={() => setSubTab('runs')}
273
+ >
274
+ <ClipboardCheck size={12} aria-hidden /> Runs
275
+ </button>
276
+ <button
277
+ type="button"
278
+ className={cn('eval-sub-tab', subTab === 'schedules' ? 'is-active' : '')}
279
+ onClick={() => { setSubTab('schedules'); if (schedules.length === 0) loadSchedules(); }}
280
+ >
281
+ <Clock size={12} aria-hidden /> Schedules
282
+ </button>
283
+ </div>
284
+
285
+ {subTab === 'runs' ? (
286
+ <>
287
+ <h2 className="eval-section-title">Recent runs</h2>
288
+ {loading && recent.length === 0 ? (
289
+ <div className="eval-runs-loading">
290
+ <Spinner size="sm" />
291
+ <span className="muted">Loading runs…</span>
292
+ </div>
293
+ ) : recent.length === 0 ? (
294
+ <EmptyState
295
+ icon={<ClipboardCheck size={28} />}
296
+ title="No eval runs yet"
297
+ message={
298
+ <>
299
+ Run a suite via the CLI:{' '}
300
+ <code className="mono">bizar eval run ./path/to/fixtures</code>.
301
+ Results will appear here.
302
+ </>
303
+ }
304
+ />
305
+ ) : (
306
+ <div className="eval-runs-list">
307
+ {recent.map((r) => (
308
+ <EvalRunCard key={r.id} run={r} />
309
+ ))}
310
+ </div>
311
+ )}
312
+ </>
160
313
  ) : (
161
- <div className="eval-runs-list">
162
- {recent.map((r) => (
163
- <EvalRunCard key={r.id} run={r} />
164
- ))}
165
- </div>
314
+ <>
315
+ <h2 className="eval-section-title">Schedules</h2>
316
+ {loadingSchedules ? (
317
+ <div className="eval-runs-loading">
318
+ <Spinner size="sm" />
319
+ <span className="muted">Loading schedules…</span>
320
+ </div>
321
+ ) : schedules.length === 0 ? (
322
+ <EmptyState
323
+ icon={<Clock size={28} />}
324
+ title="No eval schedules"
325
+ message="Schedules let you run eval suites automatically on a cron. Add one below."
326
+ />
327
+ ) : (
328
+ <div className="eval-schedules-list">
329
+ {schedules.map((s) => (
330
+ <Card key={s.id} className="eval-schedule-card">
331
+ <div className="eval-schedule-row">
332
+ <div className="eval-schedule-info">
333
+ <CardTitle>{s.name}</CardTitle>
334
+ <CardMeta className="muted">
335
+ {s.cron} · {s.suitePath} · agent:{s.agent || 'thor'}
336
+ </CardMeta>
337
+ </div>
338
+ <Button
339
+ variant="ghost"
340
+ size="sm"
341
+ onClick={() => deleteSchedule(s.id)}
342
+ title="Delete schedule"
343
+ >
344
+ <Trash2 size={12} aria-hidden />
345
+ </Button>
346
+ </div>
347
+ </Card>
348
+ ))}
349
+ </div>
350
+ )}
351
+ <div className="eval-schedule-add">
352
+ <AddScheduleForm
353
+ onAdded={(newSchedule) => {
354
+ setSchedules((prev) => [...prev, newSchedule]);
355
+ setShowAddSchedule(false);
356
+ }}
357
+ onCancel={() => setShowAddSchedule(false)}
358
+ />
359
+ {!showAddSchedule && (
360
+ <Button variant="secondary" size="sm" onClick={() => setShowAddSchedule(true)}>
361
+ <Plus size={12} aria-hidden /> Add schedule
362
+ </Button>
363
+ )}
364
+ </div>
365
+ </>
166
366
  )}
167
367
  </div>
168
368
  </div>
@@ -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
+ });