@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
|
@@ -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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
<
|
|
72
|
-
<
|
|
73
|
-
|
|
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(
|
|
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="
|
|
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
|
+
});
|