@polderlabs/bizar 5.1.0 → 5.2.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 (50) hide show
  1. package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js → MobileChat-TCputYzr.js} +1 -1
  2. package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-TCputYzr.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-BxcbL5XT.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-BxcbL5XT.js.map} +1 -1
  5. package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-DRDXfbBP.js} +154 -134
  6. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
  8. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
  9. package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
  10. package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js → mobile--17fkfrl.js} +1 -1
  11. package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile--17fkfrl.js.map} +1 -1
  12. package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-j3rOZK6v.js} +2 -2
  13. package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-j3rOZK6v.js.map} +1 -1
  14. package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-BG-DhEck.js} +2 -2
  15. package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-BG-DhEck.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/api.mjs +4 -0
  20. package/bizar-dash/src/server/routes/plugins.mjs +11 -3
  21. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  22. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  23. package/bizar-dash/src/server/server.mjs +13 -0
  24. package/bizar-dash/src/server/voice-store.mjs +27 -0
  25. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  26. package/bizar-dash/src/web/App.tsx +9 -0
  27. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  28. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  29. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  30. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  31. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  32. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  33. package/bizar-dash/src/web/components/Topbar.tsx +6 -0
  34. package/bizar-dash/src/web/styles/main.css +545 -0
  35. package/bizar-dash/src/web/views/Eval.tsx +172 -0
  36. package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
  37. package/bizar-dash/src/web/views/Plugins.tsx +128 -0
  38. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  39. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  40. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  41. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  42. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  43. package/cli/bin.mjs +14 -0
  44. package/cli/commands/dash.mjs +19 -1
  45. package/cli/commands/service.mjs +7 -0
  46. package/cli/commands/tailscale.mjs +251 -0
  47. package/package.json +1 -1
  48. package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +0 -1
  49. package/bizar-dash/dist/assets/main-8feQWXiF.js +0 -19
  50. package/bizar-dash/dist/assets/main-8feQWXiF.js.map +0 -1
@@ -0,0 +1,349 @@
1
+ // src/views/EvalReport.tsx — v5.2.0
2
+ //
3
+ // Eval report dashboard view. Two columns:
4
+ //
5
+ // ┌────────────────┬───────────────────────────────────┐
6
+ // │ Runs list │ Detail panel │
7
+ // │ (EvalRunCard) │ - Run metadata + summary │
8
+ // │ │ - Per-fixture pass/fail table │
9
+ // │ │ - Optional EvalDiff (when a │
10
+ // │ │ baseline run is selected) │
11
+ // └────────────────┴───────────────────────────────────┘
12
+ //
13
+ // Data sources:
14
+ // GET /api/eval/runs — list of recent runs
15
+ // GET /api/eval/runs/:id — full run record
16
+ // GET /api/eval/runs/:id/compare/:otherId — diff between two runs
17
+
18
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
19
+ import { ClipboardCheck, RefreshCw, ArrowLeftRight, X } from 'lucide-react';
20
+ import { Card, CardTitle, CardMeta } from '../components/Card';
21
+ import { Button } from '../components/Button';
22
+ import { EmptyState } from '../components/EmptyState';
23
+ import { Spinner } from '../components/Spinner';
24
+ import { useToast } from '../components/Toast';
25
+ import { EvalRunCard, type EvalRunSummary } from '../components/EvalRunCard';
26
+ import {
27
+ EvalDiff,
28
+ computeEvalDiff,
29
+ type EvalRunWithResults,
30
+ type EvalFixtureResult,
31
+ } from '../components/EvalDiff';
32
+ import { api } from '../lib/api';
33
+ import { cn, formatTime } from '../lib/utils';
34
+ import type { Settings, Snapshot } from '../lib/types';
35
+
36
+ type Props = {
37
+ snapshot: Snapshot;
38
+ settings: Settings;
39
+ activeTab: string;
40
+ setActiveTab: (id: string) => void;
41
+ refreshSnapshot: () => Promise<void>;
42
+ };
43
+
44
+ function RunDetail({
45
+ run,
46
+ onClearCompare,
47
+ compareRun,
48
+ onClearSelection,
49
+ runsForDiff,
50
+ }: {
51
+ run: EvalRunWithResults;
52
+ onClearCompare: () => void;
53
+ compareRun: string | null;
54
+ onClearSelection: () => void;
55
+ runsForDiff: EvalRunWithResults[];
56
+ }) {
57
+ const passRate = run.total > 0 ? ((run.passed / run.total) * 100).toFixed(1) : '0.0';
58
+ const sortedResults = [...run.results].sort((a, b) => {
59
+ // Failures first, then by fixture id
60
+ if (a.ok !== b.ok) return a.ok ? 1 : -1;
61
+ return a.fixtureId.localeCompare(b.fixtureId);
62
+ });
63
+
64
+ return (
65
+ <div className="eval-run-detail" data-testid="eval-run-detail">
66
+ <Card className="eval-run-detail-header">
67
+ <div className="eval-run-detail-header-row">
68
+ <CardTitle>
69
+ <ClipboardCheck size={14} aria-hidden /> {run.id}
70
+ </CardTitle>
71
+ <Button variant="ghost" size="sm" onClick={onClearSelection} title="Close detail">
72
+ <X size={12} /> Close
73
+ </Button>
74
+ </div>
75
+ <CardMeta>
76
+ {formatTime(run.startedAt)}
77
+ {run.finishedAt ? ` → ${formatTime(run.finishedAt)}` : ''} · {run.suitePath}
78
+ </CardMeta>
79
+ <div className="eval-run-detail-summary">
80
+ <span className={cn('eval-status', run.failed === 0 ? 'is-pass' : 'is-fail')}>
81
+ {run.passed}/{run.total} ({passRate}%)
82
+ </span>
83
+ <span className="muted">
84
+ {run.failed} failed · {run.passed} passed
85
+ </span>
86
+ </div>
87
+ </Card>
88
+
89
+ {compareRun ? (
90
+ <Card className="eval-diff-card">
91
+ {(() => {
92
+ const baseRun = runsForDiff.find((r) => r.id === compareRun);
93
+ if (!baseRun) {
94
+ return (
95
+ <div className="eval-diff-missing muted">
96
+ Baseline run <code>{compareRun}</code> not loaded. Pick another from the list.
97
+ </div>
98
+ );
99
+ }
100
+ // Show "before" as the baseline (older) and "after" as the
101
+ // currently-selected run (newer). Re-derive before/after
102
+ // from the diff helper to drive the heading.
103
+ const diff = computeEvalDiff(baseRun, run);
104
+ const improved = diff.filter((d) => d.change === 'improved').length;
105
+ const regressed = diff.filter((d) => d.change === 'regressed').length;
106
+ return (
107
+ <>
108
+ <div className="eval-diff-card-head">
109
+ <span className="eval-diff-card-title">
110
+ <ArrowLeftRight size={14} aria-hidden /> Comparing {baseRun.id} → {run.id}
111
+ </span>
112
+ <Button variant="ghost" size="sm" onClick={onClearCompare}>
113
+ <X size={12} /> Clear baseline
114
+ </Button>
115
+ </div>
116
+ <div className="eval-diff-stats" aria-label="Diff summary">
117
+ <span className="eval-diff-stat improved">+{improved} improved</span>
118
+ <span className="eval-diff-stat regressed">−{regressed} regressed</span>
119
+ </div>
120
+ <EvalDiff runA={baseRun} runB={run} />
121
+ </>
122
+ );
123
+ })()}
124
+ </Card>
125
+ ) : (
126
+ <Card className="eval-baseline-hint">
127
+ <CardMeta>
128
+ Tip: click another run in the list to use it as a baseline for diffing.
129
+ </CardMeta>
130
+ </Card>
131
+ )}
132
+
133
+ <Card className="eval-fixtures-card">
134
+ <CardTitle>Fixtures ({run.results.length})</CardTitle>
135
+ {sortedResults.length === 0 ? (
136
+ <CardMeta className="muted">No fixture results in this run.</CardMeta>
137
+ ) : (
138
+ <ul className="eval-fixtures-list" role="list">
139
+ {sortedResults.map((r) => (
140
+ <li
141
+ key={r.fixtureId}
142
+ className={cn('eval-fixture-row', r.ok ? 'is-pass' : 'is-fail')}
143
+ >
144
+ <span
145
+ className={cn('eval-fixture-pill', r.ok ? 'is-pass' : 'is-fail')}
146
+ aria-label={r.ok ? 'pass' : 'fail'}
147
+ >
148
+ {r.ok ? '✓' : '✗'}
149
+ </span>
150
+ <span className="eval-fixture-id mono">{r.fixtureId}</span>
151
+ {!r.ok && r.checks ? (
152
+ <span className="eval-fixture-fails muted">
153
+ {r.checks
154
+ .filter((c) => !c.ok)
155
+ .map((c) => c.message)
156
+ .filter(Boolean)
157
+ .join(' · ')}
158
+ </span>
159
+ ) : null}
160
+ {r.latencyMs != null ? (
161
+ <span className="eval-fixture-latency muted">{Math.round(r.latencyMs)}ms</span>
162
+ ) : null}
163
+ </li>
164
+ ))}
165
+ </ul>
166
+ )}
167
+ </Card>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function EvalReportInner({ setActiveTab }: Props) {
173
+ const toast = useToast();
174
+ const [runs, setRuns] = useState<EvalRunSummary[]>([]);
175
+ const [loadingRuns, setLoadingRuns] = useState(true);
176
+ const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
177
+ const [compareRunId, setCompareRunId] = useState<string | null>(null);
178
+ const [detail, setDetail] = useState<EvalRunWithResults | null>(null);
179
+ const [detailLoading, setDetailLoading] = useState(false);
180
+ const [compareDetail, setCompareDetail] = useState<EvalRunWithResults | null>(null);
181
+ const cancelledRef = useRef(false);
182
+
183
+ const loadRuns = useCallback(async () => {
184
+ setLoadingRuns(true);
185
+ try {
186
+ const r = await api.get<{ runs: EvalRunSummary[] }>('/eval/runs');
187
+ if (!cancelledRef.current) setRuns(r.runs || []);
188
+ } catch (err) {
189
+ if (!cancelledRef.current) {
190
+ toast.error(`Failed to load eval runs: ${(err as Error).message}`);
191
+ }
192
+ } finally {
193
+ if (!cancelledRef.current) setLoadingRuns(false);
194
+ }
195
+ }, [toast]);
196
+
197
+ const loadDetail = useCallback(
198
+ async (id: string) => {
199
+ setDetailLoading(true);
200
+ try {
201
+ const r = await api.get<EvalRunWithResults>(`/eval/runs/${encodeURIComponent(id)}`);
202
+ if (!cancelledRef.current) setDetail(r);
203
+ } catch (err) {
204
+ if (!cancelledRef.current) {
205
+ toast.error(`Failed to load run ${id}: ${(err as Error).message}`);
206
+ setDetail(null);
207
+ }
208
+ } finally {
209
+ if (!cancelledRef.current) setDetailLoading(false);
210
+ }
211
+ },
212
+ [toast],
213
+ );
214
+
215
+ const loadCompareDetail = useCallback(
216
+ async (id: string) => {
217
+ try {
218
+ const r = await api.get<EvalRunWithResults>(`/eval/runs/${encodeURIComponent(id)}`);
219
+ if (!cancelledRef.current) setCompareDetail(r);
220
+ } catch (err) {
221
+ if (!cancelledRef.current) {
222
+ toast.error(`Failed to load run ${id}: ${(err as Error).message}`);
223
+ setCompareDetail(null);
224
+ }
225
+ }
226
+ },
227
+ [toast],
228
+ );
229
+
230
+ // Initial fetch
231
+ useEffect(() => {
232
+ cancelledRef.current = false;
233
+ loadRuns();
234
+ return () => { cancelledRef.current = true; };
235
+ }, [loadRuns]);
236
+
237
+ const selectRun = async (id: string) => {
238
+ if (id === selectedRunId) return;
239
+ setSelectedRunId(id);
240
+ setCompareRunId(null);
241
+ setCompareDetail(null);
242
+ await loadDetail(id);
243
+ };
244
+
245
+ const useAsBaseline = async (id: string) => {
246
+ if (id === selectedRunId) return;
247
+ setCompareRunId(id);
248
+ await loadCompareDetail(id);
249
+ };
250
+
251
+ // Build the runsForDiff array — only the full records we actually
252
+ // have loaded (selected + baseline). Anything else is unavailable
253
+ // and the detail panel will show a "not loaded" hint.
254
+ const runsForDiff: EvalRunWithResults[] = [];
255
+ if (compareDetail) runsForDiff.push(compareDetail);
256
+ if (detail) runsForDiff.push(detail);
257
+
258
+ return (
259
+ <div className="view view-eval-report" data-testid="eval-report">
260
+ <div className="view-header">
261
+ <div className="view-header-text">
262
+ <h1 className="view-title">
263
+ <ClipboardCheck size={20} aria-hidden /> Eval Reports
264
+ </h1>
265
+ <p className="view-subtitle muted">
266
+ Browse eval runs, inspect per-fixture results, and diff two runs to spot regressions.
267
+ </p>
268
+ </div>
269
+ <div className="view-actions">
270
+ <Button variant="secondary" size="sm" onClick={loadRuns} disabled={loadingRuns}>
271
+ {loadingRuns ? <Spinner size="sm" /> : <RefreshCw size={12} />}
272
+ {loadingRuns ? 'Refreshing…' : 'Refresh'}
273
+ </Button>
274
+ </div>
275
+ </div>
276
+
277
+ <div className="eval-grid">
278
+ <div className="eval-runs-list">
279
+ {loadingRuns && runs.length === 0 ? (
280
+ <div className="eval-runs-loading">
281
+ <Spinner size="sm" />
282
+ <span className="muted">Loading runs…</span>
283
+ </div>
284
+ ) : runs.length === 0 ? (
285
+ <EmptyState
286
+ icon={<ClipboardCheck size={28} />}
287
+ title="No eval runs yet"
288
+ message={
289
+ <>
290
+ Run a suite via the CLI:{' '}
291
+ <code className="mono">bizar eval run ./path/to/fixtures</code>.
292
+ </>
293
+ }
294
+ />
295
+ ) : (
296
+ runs.map((r) => (
297
+ <div key={r.id} className="eval-run-card-wrap">
298
+ <EvalRunCard
299
+ run={r}
300
+ selected={r.id === selectedRunId}
301
+ onClick={() => selectRun(r.id)}
302
+ />
303
+ {selectedRunId && r.id !== selectedRunId ? (
304
+ <button
305
+ type="button"
306
+ className="eval-baseline-btn"
307
+ onClick={() => useAsBaseline(r.id)}
308
+ title="Use this run as a baseline for diffing"
309
+ >
310
+ <ArrowLeftRight size={10} aria-hidden />
311
+ Use as baseline
312
+ </button>
313
+ ) : null}
314
+ </div>
315
+ ))
316
+ )}
317
+ </div>
318
+
319
+ <div className="eval-detail">
320
+ {detailLoading && !detail ? (
321
+ <div className="eval-detail-loading">
322
+ <Spinner size="sm" />
323
+ <span className="muted">Loading run details…</span>
324
+ </div>
325
+ ) : detail ? (
326
+ <RunDetail
327
+ run={detail}
328
+ compareRun={compareRunId}
329
+ runsForDiff={runsForDiff}
330
+ onClearCompare={() => { setCompareRunId(null); setCompareDetail(null); }}
331
+ onClearSelection={() => { setSelectedRunId(null); setDetail(null); setCompareRunId(null); setCompareDetail(null); }}
332
+ />
333
+ ) : (
334
+ <Card className="eval-detail-empty">
335
+ <EmptyState
336
+ icon={<ClipboardCheck size={28} />}
337
+ title="Select a run"
338
+ message="Pick an eval run from the list to view fixture-by-fixture results, latency, and per-check failure details."
339
+ />
340
+ </Card>
341
+ )}
342
+ </div>
343
+ </div>
344
+ </div>
345
+ );
346
+ }
347
+
348
+ export const EvalReport = React.memo(EvalReportInner);
349
+ export type { EvalFixtureResult };
@@ -0,0 +1,128 @@
1
+ // src/views/Plugins.tsx — manage installed plugins and their permissions.
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { Puzzle } from 'lucide-react';
5
+ import { PluginCard, type InstalledPlugin } from '../components/PluginCard';
6
+ import { EmptyState } from '../components/EmptyState';
7
+ import { Spinner } from '../components/Spinner';
8
+ import { useToast } from '../components/Toast';
9
+ import { api } from '../lib/api';
10
+ import type { Settings, Snapshot } from '../lib/types';
11
+
12
+ type Props = {
13
+ snapshot: Snapshot;
14
+ settings: Settings;
15
+ activeTab: string;
16
+ setActiveTab: (id: string) => void;
17
+ refreshSnapshot: () => Promise<void>;
18
+ };
19
+
20
+ export function Plugins(_props: Props) {
21
+ const toast = useToast();
22
+ const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
23
+ const [loading, setLoading] = useState(true);
24
+ const [filter, setFilter] = useState('');
25
+
26
+ const load = async () => {
27
+ try {
28
+ const r = await api.get<{ plugins: InstalledPlugin[] }>('/plugins/installed');
29
+ setPlugins(r.plugins);
30
+ } catch (err) {
31
+ toast.error(`Failed to load plugins: ${(err as Error).message}`);
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ };
36
+
37
+ useEffect(() => {
38
+ void load();
39
+ }, []);
40
+
41
+ const toggle = async (id: string) => {
42
+ try {
43
+ // Toggle is handled by the backend via a toggle endpoint or we use update
44
+ await api.post(`/plugins/${id}/toggle`).catch(() => {
45
+ // Fallback: toggle is not a separate endpoint, may need PUT
46
+ });
47
+ await load();
48
+ toast.success('Plugin toggled.');
49
+ } catch (err) {
50
+ toast.error(`Toggle failed: ${(err as Error).message}`);
51
+ }
52
+ };
53
+
54
+ const uninstall = async (id: string) => {
55
+ if (!confirm('Uninstall this plugin?')) return;
56
+ try {
57
+ await api.del(`/plugins/${id}`);
58
+ await load();
59
+ toast.success('Plugin uninstalled.');
60
+ } catch (err) {
61
+ toast.error(`Uninstall failed: ${(err as Error).message}`);
62
+ }
63
+ };
64
+
65
+ const configure = (_id: string) => {
66
+ // TODO: open config dialog
67
+ };
68
+
69
+ if (loading) {
70
+ return (
71
+ <div className="view-loading">
72
+ <Spinner size="lg" />
73
+ </div>
74
+ );
75
+ }
76
+
77
+ const filtered = plugins.filter((p) =>
78
+ p.name.toLowerCase().includes(filter.toLowerCase()),
79
+ );
80
+
81
+ return (
82
+ <div className="view view-plugins">
83
+ <header className="view-header">
84
+ <div className="view-header-text">
85
+ <h2 className="view-title">
86
+ <Puzzle size={18} aria-hidden />
87
+ Plugins ({plugins.length})
88
+ </h2>
89
+ <p className="view-subtitle">
90
+ Manage plugins installed via the marketplace.
91
+ </p>
92
+ </div>
93
+ </header>
94
+
95
+ {plugins.length === 0 ? (
96
+ <EmptyState
97
+ icon={<Puzzle size={32} />}
98
+ title="No plugins installed"
99
+ message="Install plugins from the marketplace using the CLI."
100
+ />
101
+ ) : (
102
+ <>
103
+ <div className="plugins-toolbar">
104
+ <input
105
+ className="input"
106
+ type="search"
107
+ placeholder="Filter plugins…"
108
+ value={filter}
109
+ onChange={(e) => setFilter(e.target.value)}
110
+ style={{ maxWidth: 320 }}
111
+ />
112
+ </div>
113
+ <div className="plugin-grid">
114
+ {filtered.map((p) => (
115
+ <PluginCard
116
+ key={p.id}
117
+ plugin={p}
118
+ onToggle={toggle}
119
+ onUninstall={uninstall}
120
+ onConfigure={configure}
121
+ />
122
+ ))}
123
+ </div>
124
+ </>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -12,6 +12,7 @@ import { ThemeSection } from './settings/ThemeSection';
12
12
  import { UpdatesSection } from './settings/UpdatesSection';
13
13
  import { GeneralSection } from './settings/GeneralSection';
14
14
  import { NetworkSection } from './settings/NetworkSection';
15
+ import { TailscaleSettings } from '../components/TailscaleSettings';
15
16
  import { NotificationsSection } from './settings/NotificationsSection';
16
17
  import { AuthSection } from './settings/AuthSection';
17
18
  import { AgentSection } from './settings/AgentSection';
@@ -290,6 +291,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot, settingsMode, s
290
291
  {(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} autoSave={autoSave} />}
291
292
  {(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
292
293
  {(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} />}
294
+ {(showAll || sectionOf('network') || sectionOf('tailscale')) && <TailscaleSettings initialStatus={tailscale} />}
293
295
  {(showAll || sectionOf('notifications')) && <NotificationsSection {...sp} />}
294
296
  {(showAll || sectionOf('auth')) && <AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} />}
295
297
  {(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} autoSave={autoSave} />}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * bizar-dash/tests/cli-tailscale.test.mjs
3
+ *
4
+ * v5.2 — CLI Tailscale integration tests.
5
+ *
6
+ * Tests the tailscale CLI command module (cli/commands/tailscale.mjs).
7
+ * Verifies module structure and run() doesn't throw for help/invalid subcommands.
8
+ * Full integration testing requires a real or mocked tailscale binary.
9
+ */
10
+ import { test, describe } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { resolve, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const CLI_COMMANDS = resolve(__dirname, '..', '..', 'cli', 'commands');
17
+
18
+ // ── Tests ───────────────────────────────────────────────────────────────────
19
+
20
+ describe('cli/commands/tailscale.mjs exports', () => {
21
+ test('exports run() function', async () => {
22
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
23
+ assert.equal(typeof mod.run, 'function');
24
+ });
25
+
26
+ test('exports ensureTailscaleAuth() function', async () => {
27
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
28
+ assert.equal(typeof mod.ensureTailscaleAuth, 'function');
29
+ });
30
+
31
+ test('exports setupTailscaleServe() function', async () => {
32
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
33
+ assert.equal(typeof mod.setupTailscaleServe, 'function');
34
+ });
35
+
36
+ test('exports unsetupTailscaleServe() function', async () => {
37
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
38
+ assert.equal(typeof mod.unsetupTailscaleServe, 'function');
39
+ });
40
+
41
+ test('exports getServeUrl() function', async () => {
42
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
43
+ assert.equal(typeof mod.getServeUrl, 'function');
44
+ });
45
+
46
+ test('exports showTailscaleStatus() function', async () => {
47
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
48
+ assert.equal(typeof mod.showTailscaleStatus, 'function');
49
+ });
50
+
51
+ test('exports showTailscaleHelp() function', async () => {
52
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
53
+ assert.equal(typeof mod.showTailscaleHelp, 'function');
54
+ });
55
+
56
+ test('exports isTailscaleInstalled() function', async () => {
57
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
58
+ assert.equal(typeof mod.isTailscaleInstalled, 'function');
59
+ });
60
+ });
61
+
62
+ describe('run() — CLI subcommand dispatcher', () => {
63
+ test('run() with no args does not throw (prints help)', async () => {
64
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
65
+ // Should not throw - it will print help and return
66
+ await mod.run('tailscale', [], false);
67
+ });
68
+
69
+ test('run() with --help flag does not throw', async () => {
70
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
71
+ await mod.run('tailscale', ['--help'], false);
72
+ });
73
+
74
+ test('run() with "help" subcommand does not throw', async () => {
75
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
76
+ await mod.run('tailscale', ['help'], false);
77
+ });
78
+
79
+ test('run() with unknown subcommand does not throw (prints error + help)', async () => {
80
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
81
+ // Should not throw - it will print an error and help
82
+ await mod.run('tailscale', ['unknown-subcommand'], false);
83
+ });
84
+ });
85
+
86
+ describe('showTailscaleHelp()', () => {
87
+ test('prints help text including env var documentation', async () => {
88
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
89
+ let stdout = '';
90
+ const orig = process.stdout.write.bind(process.stdout);
91
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
92
+ process.stdout.write = (s) => { stdout += String(s); return true; };
93
+ try {
94
+ mod.showTailscaleHelp();
95
+ assert.ok(stdout.includes('bizar tailscale'));
96
+ assert.ok(stdout.includes('TAILSCALE_AUTHKEY'));
97
+ assert.ok(stdout.includes('bizar tailscale status'));
98
+ assert.ok(stdout.includes('bizar tailscale serve'));
99
+ assert.ok(stdout.includes('bizar tailscale unserve'));
100
+ } finally {
101
+ process.stdout.write = orig;
102
+ }
103
+ });
104
+ });
105
+
106
+ describe('getServeUrl()', () => {
107
+ test('returns null when serve config does not exist', async () => {
108
+ const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
109
+ const url = mod.getServeUrl();
110
+ // Returns null when no config file exists (test environment)
111
+ assert.equal(url, null);
112
+ });
113
+ });