@polderlabs/bizar 4.5.0 → 4.5.2

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 (77) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
  4. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  6. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
  7. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
  8. package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
  9. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/skills/bizar/SKILL.md +20 -0
  13. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +11 -0
  15. package/bizar-dash/skills/obsidian/SKILL.md +13 -0
  16. package/bizar-dash/src/server/api.mjs +6 -0
  17. package/bizar-dash/src/server/headroom.mjs +647 -0
  18. package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
  19. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  20. package/bizar-dash/src/server/memory-store.mjs +151 -0
  21. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  22. package/bizar-dash/src/server/providers-store.mjs +73 -1
  23. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  24. package/bizar-dash/src/server/routes/chat.mjs +38 -0
  25. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  26. package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
  27. package/bizar-dash/src/server/routes/memory.mjs +439 -4
  28. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  29. package/bizar-dash/src/server/server.mjs +55 -37
  30. package/bizar-dash/src/server/watcher.mjs +2 -2
  31. package/bizar-dash/src/web/App.tsx +42 -6
  32. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  33. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  34. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  35. package/bizar-dash/src/web/components/Topbar.tsx +3 -1
  36. package/bizar-dash/src/web/lib/api.ts +12 -11
  37. package/bizar-dash/src/web/lib/types.ts +16 -0
  38. package/bizar-dash/src/web/main.tsx +1 -0
  39. package/bizar-dash/src/web/styles/chat.css +2 -0
  40. package/bizar-dash/src/web/styles/main.css +52 -23
  41. package/bizar-dash/src/web/styles/memory.css +955 -0
  42. package/bizar-dash/src/web/styles/tasks.css +2 -0
  43. package/bizar-dash/src/web/views/Memory.tsx +141 -0
  44. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  45. package/bizar-dash/src/web/views/Overview.tsx +7 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +32 -1
  47. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  48. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  49. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  50. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  51. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  52. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
  53. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  54. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  55. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  56. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  59. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  60. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  61. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  62. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  63. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  64. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  65. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  66. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  67. package/cli/artifact.mjs +11 -4
  68. package/cli/bin.mjs +211 -12
  69. package/cli/doctor.mjs +5 -3
  70. package/cli/memory.mjs +65 -21
  71. package/cli/provision.mjs +8 -2
  72. package/install.sh +1 -2
  73. package/package.json +3 -3
  74. package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
  75. package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
  76. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
  77. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +0 -1
@@ -0,0 +1,194 @@
1
+ // src/web/views/memory/SemanticSearchPanel.tsx — cross-source search (LightRAG + Obsidian).
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { Brain, FileText, Loader2, Search as SearchIcon, X } from 'lucide-react';
4
+ import { Button } from '../../components/Button';
5
+ import { Card, CardMeta, CardTitle } from '../../components/Card';
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 { cn } from '../../lib/utils';
11
+
12
+ type Result = {
13
+ source: 'lightrag' | 'obsidian';
14
+ relPath: string | null;
15
+ snippet: string;
16
+ score: number;
17
+ mtime: number | null;
18
+ raw?: unknown;
19
+ error?: string;
20
+ };
21
+
22
+ type Props = { refreshKey: number };
23
+
24
+ export function SemanticSearchPanel({ refreshKey }: Props) {
25
+ const toast = useToast();
26
+ const [q, setQ] = useState('');
27
+ const [sources, setSources] = useState<{ lightrag: boolean; obsidian: boolean }>({ lightrag: true, obsidian: true });
28
+ const [results, setResults] = useState<Result[] | null>(null);
29
+ const [loading, setLoading] = useState(false);
30
+ const [lastQuery, setLastQuery] = useState('');
31
+
32
+ const onSearch = async (query: string) => {
33
+ if (!query.trim()) return;
34
+ setLoading(true);
35
+ setLastQuery(query);
36
+ try {
37
+ const activeSources = [
38
+ ...(sources.lightrag ? ['lightrag' as const] : []),
39
+ ...(sources.obsidian ? ['obsidian' as const] : []),
40
+ ];
41
+ const r = await api.post<{ query: string; count: number; results: Result[] }>(
42
+ '/memory/semantic-search',
43
+ { query, limit: 20, sources: activeSources },
44
+ );
45
+ setResults(r.results || []);
46
+ } catch (err) {
47
+ toast.error(`Search failed: ${(err as Error).message}`);
48
+ setResults([]);
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ // Re-run when sources toggle (if there's a last query).
55
+ useEffect(() => {
56
+ if (lastQuery) onSearch(lastQuery);
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [sources]);
59
+
60
+ // Re-run when the panel is re-mounted.
61
+ useEffect(() => {
62
+ if (lastQuery) onSearch(lastQuery);
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [refreshKey]);
65
+
66
+ const grouped = useMemo(() => {
67
+ const obs = (results || []).filter((r) => r.source === 'obsidian');
68
+ const lr = (results || []).filter((r) => r.source === 'lightrag');
69
+ return { obs, lr };
70
+ }, [results]);
71
+
72
+ return (
73
+ <div className="memory-panel-content">
74
+ <Card variant="elevated">
75
+ <CardTitle>
76
+ <SearchIcon size={14} /> Semantic search
77
+ </CardTitle>
78
+ <CardMeta>Query both LightRAG (graph-based) and the Obsidian vault in one shot.</CardMeta>
79
+ <div className="memory-search-row">
80
+ <input
81
+ type="text"
82
+ className="input"
83
+ placeholder="e.g. how are notes indexed?"
84
+ value={q}
85
+ onChange={(e) => setQ(e.target.value)}
86
+ onKeyDown={(e) => {
87
+ if (e.key === 'Enter') onSearch(q);
88
+ }}
89
+ disabled={loading}
90
+ />
91
+ <Button variant="primary" onClick={() => onSearch(q)} disabled={loading || !q.trim()}>
92
+ {loading ? <Loader2 size={12} className="memory-spin" /> : <SearchIcon size={12} />}
93
+ Search
94
+ </Button>
95
+ </div>
96
+ <div className="memory-source-toggle-row">
97
+ <ToggleChip
98
+ active={sources.obsidian}
99
+ onToggle={() => setSources((s) => ({ ...s, obsidian: !s.obsidian }))}
100
+ label="Obsidian"
101
+ icon={<FileText size={11} />}
102
+ />
103
+ <ToggleChip
104
+ active={sources.lightrag}
105
+ onToggle={() => setSources((s) => ({ ...s, lightrag: !s.lightrag }))}
106
+ label="LightRAG"
107
+ icon={<Brain size={11} />}
108
+ />
109
+ {q && (
110
+ <button type="button" className="icon-btn" onClick={() => { setQ(''); setResults(null); setLastQuery(''); }} aria-label="Clear">
111
+ <X size={12} />
112
+ </button>
113
+ )}
114
+ </div>
115
+ </Card>
116
+
117
+ {loading && (
118
+ <div className="view-loading">
119
+ <Spinner size="md" />
120
+ </div>
121
+ )}
122
+
123
+ {!loading && results && results.length === 0 && (
124
+ <EmptyState
125
+ icon={<SearchIcon size={28} />}
126
+ title="No matches"
127
+ message={`Nothing in scope matched "${lastQuery}".`}
128
+ />
129
+ )}
130
+
131
+ {grouped.obs.length > 0 && (
132
+ <Card>
133
+ <CardTitle>
134
+ <FileText size={14} /> Obsidian ({grouped.obs.length})
135
+ </CardTitle>
136
+ <ul className="memory-search-result-list">
137
+ {grouped.obs.map((r, i) => (
138
+ <li key={`obs-${i}`} className="memory-search-result">
139
+ <div className="memory-search-result-head">
140
+ <code>{r.relPath}</code>
141
+ <span className="muted text-xs">score {r.score.toFixed(1)}</span>
142
+ </div>
143
+ <p className="muted text-sm">{r.snippet}</p>
144
+ </li>
145
+ ))}
146
+ </ul>
147
+ </Card>
148
+ )}
149
+
150
+ {grouped.lr.length > 0 && (
151
+ <Card>
152
+ <CardTitle>
153
+ <Brain size={14} /> LightRAG ({grouped.lr.length})
154
+ </CardTitle>
155
+ <div className="memory-search-result-list">
156
+ {grouped.lr.map((r, i) => (
157
+ <div key={`lr-${i}`} className="memory-search-result">
158
+ <div className="memory-search-result-head">
159
+ <span className="memory-source-pill"><Brain size={11} /> LightRAG</span>
160
+ {r.error && <span className="muted text-xs">error: {r.error}</span>}
161
+ </div>
162
+ <p className="text-sm">{r.snippet}</p>
163
+ </div>
164
+ ))}
165
+ </div>
166
+ </Card>
167
+ )}
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function ToggleChip({
173
+ active,
174
+ onToggle,
175
+ label,
176
+ icon,
177
+ }: {
178
+ active: boolean;
179
+ onToggle: () => void;
180
+ label: string;
181
+ icon: React.ReactNode;
182
+ }) {
183
+ return (
184
+ <button
185
+ type="button"
186
+ className={cn('memory-toggle-chip', active && 'memory-toggle-chip-active')}
187
+ onClick={onToggle}
188
+ aria-pressed={active}
189
+ >
190
+ {icon}
191
+ <span>{label}</span>
192
+ </button>
193
+ );
194
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tests/cli-bugfixes.test.mjs — regression tests for CLI + installer bug fixes.
3
+ *
4
+ * Covers:
5
+ * B1 — parseWithModsFlag exits with code 2 when no value follows --with-mods
6
+ * B7 — check-deps.mjs uses path.join() (Windows fix) + covers all deps
7
+ * B9 — --json flag works for `bizar doctor --json`
8
+ * B10 — --debug flag sets DEBUG env var
9
+ *
10
+ * Run with: node --test bizar-dash/tests/cli-bugfixes.test.mjs
11
+ */
12
+ import { describe, it, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { join } from 'node:path';
15
+
16
+ const REPO = join(process.cwd());
17
+
18
+ // ─── B1: parseWithModsFlag exits with code 2 on missing value ──────────────────
19
+
20
+ describe('B1 — parseWithModsFlag', () => {
21
+ it('exits with code 2 when --with-mods is the last arg', async () => {
22
+ // Import fresh each time to avoid module-level side effects
23
+ const { parseWithModsFlag } = await import('../../cli/bin.mjs');
24
+ let exitCode = null;
25
+ const origExit = process.exit;
26
+ // Mock process.exit to capture the code without terminating the test
27
+ try {
28
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
29
+ parseWithModsFlag(['install', '--with-mods']);
30
+ } catch (err) {
31
+ if (err.message.startsWith('exit:')) exitCode = parseInt(err.message.split(':')[1]);
32
+ } finally {
33
+ process.exit = origExit;
34
+ }
35
+ assert.strictEqual(exitCode, 2, 'should have called process.exit(2)');
36
+ });
37
+
38
+ it('exits with code 2 when next arg is another flag', async () => {
39
+ const { parseWithModsFlag } = await import('../../cli/bin.mjs');
40
+ let exitCode = null;
41
+ const origExit = process.exit;
42
+ try {
43
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
44
+ parseWithModsFlag(['install', '--with-mods', '--force']);
45
+ } catch (err) {
46
+ if (err.message.startsWith('exit:')) exitCode = parseInt(err.message.split(':')[1]);
47
+ } finally {
48
+ process.exit = origExit;
49
+ }
50
+ assert.strictEqual(exitCode, 2);
51
+ });
52
+
53
+ it('returns null when --with-mods is not present', async () => {
54
+ const { parseWithModsFlag } = await import('../../cli/bin.mjs');
55
+ let exitCode = null;
56
+ const origExit = process.exit;
57
+ try {
58
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
59
+ const result = parseWithModsFlag(['install', '--force']);
60
+ assert.strictEqual(result, null);
61
+ assert.strictEqual(exitCode, null);
62
+ } finally {
63
+ process.exit = origExit;
64
+ }
65
+ });
66
+
67
+ it('returns array of mod ids when value is provided', async () => {
68
+ const { parseWithModsFlag } = await import('../../cli/bin.mjs');
69
+ let exitCode = null;
70
+ const origExit = process.exit;
71
+ try {
72
+ process.exit = (code) => { exitCode = code; throw new Error('exit:' + code); };
73
+ const result = parseWithModsFlag(['install', '--with-mods', 'a,b,c']);
74
+ assert.deepStrictEqual(result, ['a', 'b', 'c']);
75
+ assert.strictEqual(exitCode, null);
76
+ } finally {
77
+ process.exit = origExit;
78
+ }
79
+ });
80
+ });
81
+
82
+ // ─── B7: check-deps.mjs cross-platform path joining ───────────────────────────
83
+
84
+ describe('B7 — check-deps.mjs which() uses path.join()', () => {
85
+ it('covers all required dependencies', async () => {
86
+ const { checkDeps } = await import('../../scripts/check-deps.mjs');
87
+ const result = await checkDeps({ strict: false });
88
+ const names = [...result.present, ...result.missing].map(d => d.name);
89
+ const required = ['node', 'bun', 'opencode', 'tmux', 'git', 'python3', 'pip', 'jq', 'gh', 'headroom', 'semble', 'skills'];
90
+ for (const dep of required) {
91
+ assert.ok(names.includes(dep), `expected ${dep} to be checked`);
92
+ }
93
+ });
94
+
95
+ it('which() uses join() from node:path for path construction', async () => {
96
+ const { readFileSync } = await import('node:fs');
97
+ const src = readFileSync(join(process.cwd(), 'scripts', 'check-deps.mjs'), 'utf8');
98
+ assert.ok(src.includes("import { join } from 'node:path'"), 'should import join from node:path');
99
+ assert.ok(src.includes('join(dir, cmd + ext)'), 'should use join() for path construction');
100
+ });
101
+ });
102
+
103
+ // ─── B9: --json flag for doctor ───────────────────────────────────────────────
104
+
105
+ describe('B9 — --json flag', () => {
106
+ it('doctor --json outputs valid JSON', async () => {
107
+ const { spawnSync } = await import('node:child_process');
108
+ const bin = join(REPO, 'cli', 'bin.mjs');
109
+ const result = spawnSync('node', [bin, 'doctor', '--json'], {
110
+ encoding: 'utf8',
111
+ timeout: 30000,
112
+ });
113
+ const out = result.stdout.trim();
114
+ // Should be parseable as JSON
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(out);
118
+ } catch {
119
+ assert.fail(`expected JSON output, got: ${out.slice(0, 200)}`);
120
+ return;
121
+ }
122
+ assert.ok('passed' in parsed, 'JSON should have passed field');
123
+ assert.ok('failed' in parsed, 'JSON should have failed field');
124
+ assert.ok('results' in parsed, 'JSON should have results field');
125
+ assert.ok(Array.isArray(parsed.results), 'results should be an array');
126
+ });
127
+ });
128
+
129
+ // ─── B10: --debug flag sets DEBUG env var ──────────────────────────────────────
130
+
131
+ describe('B10 — --debug flag', () => {
132
+ it('dbg() helper is defined in bin.mjs', async () => {
133
+ const { readFileSync } = await import('node:fs');
134
+ const src = readFileSync(join(REPO, 'cli', 'bin.mjs'), 'utf8');
135
+ assert.ok(src.includes('function dbg('), 'dbg() helper should be defined');
136
+ assert.ok(src.includes("process.env.DEBUG = 'bizar:*'"), '--debug should set DEBUG=bizar:*');
137
+ assert.ok(src.includes("process.env.BIZAR_DEBUG = '1'"), '--debug should set BIZAR_DEBUG=1');
138
+ });
139
+
140
+ it('--debug flag is accepted without error', async () => {
141
+ const { spawnSync } = await import('node:child_process');
142
+ const bin = join(REPO, 'cli', 'bin.mjs');
143
+ // Just verify --debug doesn't cause a parse error or unknown flag
144
+ const result = spawnSync('node', [bin, '--help', '--debug'], {
145
+ encoding: 'utf8',
146
+ timeout: 10000,
147
+ });
148
+ // --debug should not cause an error (help should print)
149
+ assert.ok(result.status === 0 || result.stdout.includes('bizar'), '--debug should not error');
150
+ });
151
+ });
@@ -0,0 +1,151 @@
1
+ // bizar-dash/tests/frontend-bugfixes.test.mjs
2
+ // Lightweight verification of dashboard web + build bug fixes.
3
+ // Run with: node --test bizar-dash/tests/frontend-bugfixes.test.mjs
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { resolve } from 'node:path';
7
+ import { describe, it } from 'node:test';
8
+
9
+ // import.meta.dirname = /.../BizarHarness/bizar-dash/tests
10
+ // So project root is up 2 levels
11
+ const ROOT = import.meta.dirname; // .../BizarHarness/bizar-dash/tests
12
+ const PROJECT = resolve(ROOT, '..', '..'); // .../BizarHarness/
13
+ const DASH = resolve(ROOT, '..'); // .../BizarHarness/bizar-dash/
14
+
15
+ function read(file) {
16
+ return readFileSync(resolve(PROJECT, file), 'utf-8');
17
+ }
18
+ function dash(file) {
19
+ return readFileSync(resolve(DASH, file), 'utf-8');
20
+ }
21
+
22
+ describe('W1: vite.config.ts sourcemap: hidden', () => {
23
+ it("sourcemap is 'hidden'", () => {
24
+ const content = read('vite.config.ts');
25
+ if (!content.includes("sourcemap: 'hidden'")) {
26
+ throw new Error("vite.config.ts missing sourcemap: 'hidden'");
27
+ }
28
+ if (content.includes('sourcemap: true')) {
29
+ throw new Error('vite.config.ts still contains sourcemap: true');
30
+ }
31
+ });
32
+ });
33
+
34
+ describe('W2: .npmignore excludes source maps and tests', () => {
35
+ it('excludes .map files from dist', () => {
36
+ const c = read('.npmignore');
37
+ if (!c.includes('bizar-dash/dist/**/*.map')) throw new Error('.npmignore missing bizar-dash/dist/**/*.map');
38
+ });
39
+ it('excludes **/__tests__/', () => {
40
+ const c = read('.npmignore');
41
+ if (!c.includes('**/__tests__/')) throw new Error('.npmignore missing **/__tests__/');
42
+ });
43
+ it('excludes **/*.test.mjs', () => {
44
+ const c = read('.npmignore');
45
+ if (!c.includes('**/*.test.mjs')) throw new Error('.npmignore missing **/*.test.mjs');
46
+ });
47
+ it('excludes **/*.test.ts', () => {
48
+ const c = read('.npmignore');
49
+ if (!c.includes('**/*.test.ts')) throw new Error('.npmignore missing **/*.test.ts');
50
+ });
51
+ it('excludes **/*.test.tsx', () => {
52
+ const c = read('.npmignore');
53
+ if (!c.includes('**/*.test.tsx')) throw new Error('.npmignore missing **/*.test.tsx');
54
+ });
55
+ });
56
+
57
+ describe('W3: Toast.tsx accessibility', () => {
58
+ it('ToastItem has role="alert"', () => {
59
+ const c = dash('src/web/components/Toast.tsx');
60
+ if (!c.includes('role="alert"')) throw new Error('Toast.tsx missing role="alert"');
61
+ });
62
+ it('ToastItem has aria-live="assertive"', () => {
63
+ const c = dash('src/web/components/Toast.tsx');
64
+ if (!c.includes('aria-live="assertive"')) throw new Error('Toast.tsx missing aria-live="assertive"');
65
+ });
66
+ it('ToastItem has aria-atomic="true"', () => {
67
+ const c = dash('src/web/components/Toast.tsx');
68
+ if (!c.includes('aria-atomic="true"')) throw new Error('Toast.tsx missing aria-atomic="true"');
69
+ });
70
+ });
71
+
72
+ describe('W4: App.tsx Suspense wrapper', () => {
73
+ it('Suspense imported from react', () => {
74
+ const c = dash('src/web/App.tsx');
75
+ if (!c.includes('Suspense')) throw new Error('App.tsx missing Suspense import');
76
+ });
77
+ it('renderedView wrapped in Suspense', () => {
78
+ const c = dash('src/web/App.tsx');
79
+ if (!c.includes('<Suspense')) throw new Error('App.tsx missing <Suspense> wrapper');
80
+ });
81
+ });
82
+
83
+ describe('W5: React.memo on view components', () => {
84
+ const views = [
85
+ ['Tasks', dash('src/web/views/Tasks.tsx')],
86
+ ['Settings', dash('src/web/views/Settings.tsx')],
87
+ ['Memory', dash('src/web/views/Memory.tsx')],
88
+ ['Overview', dash('src/web/views/Overview.tsx')],
89
+ ['Skills', dash('src/web/views/Skills.tsx')],
90
+ ['MiniMaxUsage',dash('src/web/views/MiniMaxUsage.tsx')],
91
+ ];
92
+ for (const [name, content] of views) {
93
+ it(`${name} is wrapped in React.memo`, () => {
94
+ // Pattern: const Name = React.memo(...) or export const Name = React.memo(...)
95
+ if (!content.includes('React.memo(')) {
96
+ throw new Error(`${name} is not wrapped in React.memo`);
97
+ }
98
+ });
99
+ }
100
+ });
101
+
102
+ describe('W6: Topbar ARIA roles', () => {
103
+ const c = dash('src/web/components/Topbar.tsx');
104
+ it('tabs-row has role="tablist"', () => { if (!c.includes('role="tablist"')) throw new Error('Topbar missing role="tablist"'); });
105
+ it('tabs-row has aria-label="Primary tabs"', () => { if (!c.includes('aria-label="Primary tabs"')) throw new Error('Topbar missing aria-label="Primary tabs"'); });
106
+ it('tab buttons have role="tab"', () => { if (!c.includes('role="tab"')) throw new Error('Topbar missing role="tab"'); });
107
+ it('tab buttons have aria-selected', () => { if (!c.includes('aria-selected=')) throw new Error('Topbar missing aria-selected on tabs'); });
108
+ });
109
+
110
+ describe('F1: Manual snapshot refetch removed from file change WS event', () => {
111
+ it("'change' WS handler no longer calls api.get('/snapshot')", () => {
112
+ const c = dash('src/web/App.tsx');
113
+ const idx = c.indexOf("msg.type === 'change'");
114
+ if (idx === -1) throw new Error("msg.type === 'change' not found");
115
+ const block = c.slice(idx, idx + 600);
116
+ if (block.includes("api.get<Snapshot>('/snapshot')") || block.includes('api.get("/snapshot")')) {
117
+ throw new Error("Manual api.get('/snapshot') still present in 'change' handler");
118
+ }
119
+ });
120
+ });
121
+
122
+ describe('F2: content-visibility: auto on list items', () => {
123
+ it('.activity-item has content-visibility: auto (main.css)', () => {
124
+ const c = dash('src/web/styles/main.css');
125
+ const i = c.indexOf('.activity-item');
126
+ if (!c.slice(i, i + 500).includes('content-visibility: auto')) {
127
+ throw new Error('.activity-item missing content-visibility: auto in main.css');
128
+ }
129
+ });
130
+ it('.task-card has content-visibility: auto (main.css)', () => {
131
+ const c = dash('src/web/styles/main.css');
132
+ const i = c.indexOf('.task-card');
133
+ if (!c.slice(i, i + 500).includes('content-visibility: auto')) {
134
+ throw new Error('.task-card missing content-visibility: auto in main.css');
135
+ }
136
+ });
137
+ it('.chat-message has content-visibility: auto (chat.css)', () => {
138
+ const c = dash('src/web/styles/chat.css');
139
+ const i = c.indexOf('.chat-message');
140
+ if (!c.slice(i, i + 300).includes('content-visibility: auto')) {
141
+ throw new Error('.chat-message missing content-visibility: auto in chat.css');
142
+ }
143
+ });
144
+ it('.task-card has content-visibility: auto (tasks.css)', () => {
145
+ const c = dash('src/web/styles/tasks.css');
146
+ const i = c.indexOf('.task-card');
147
+ if (!c.slice(i, i + 500).includes('content-visibility: auto')) {
148
+ throw new Error('.task-card missing content-visibility: auto in tasks.css');
149
+ }
150
+ });
151
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * tests/headroom-install.test.mjs
3
+ *
4
+ * Tests for installHeadroom(), wrapOpencode(), unwrapOpencode(), startProxy(), stopProxy().
5
+ * Uses real commands when available, mocks when not.
6
+ */
7
+
8
+ import { test, describe, beforeEach, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { spawn } from 'node:child_process';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { mkdirSync, rmSync, writeFileSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
14
+ import http from 'node:http';
15
+
16
+ // ── Run command helper ──────────────────────────────────────────────────────
17
+
18
+ function runCmd(cmd, args, opts = {}) {
19
+ return new Promise((resolve) => {
20
+ const child = spawn(cmd, args, { timeout: 5000, ...opts });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
24
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
25
+ child.on('close', (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 }));
26
+ child.on('error', (err) => resolve({ stdout: '', stderr: err.message, exitCode: 1 }));
27
+ });
28
+ }
29
+
30
+ // ── Install tests ────────────────────────────────────────────────────────────
31
+
32
+ test('installHeadroom returns correct shape', async () => {
33
+ const { installHeadroom } = await import('../src/server/headroom.mjs');
34
+ const result = await installHeadroom({ force: false });
35
+
36
+ assert.ok(typeof result.ok === 'boolean', 'ok is boolean');
37
+ assert.ok(typeof result.installed === 'boolean', 'installed is boolean');
38
+ assert.ok(typeof result.version === 'string' || result.version === null, 'version is string|null');
39
+ assert.ok(typeof result.method === 'string', 'method is string');
40
+ });
41
+
42
+ test('installHeadroom returns already-installed when headroom is on PATH', async () => {
43
+ const { installed } = await runCmd('headroom', ['--version']);
44
+ if (installed !== 0) {
45
+ // headroom not installed — skip
46
+ return;
47
+ }
48
+
49
+ const { installHeadroom } = await import('../src/server/headroom.mjs');
50
+ const result = await installHeadroom({ force: false });
51
+
52
+ assert.strictEqual(result.installed, true, 'should report already installed');
53
+ assert.strictEqual(result.method, 'already-installed', 'method should be already-installed');
54
+ });
55
+
56
+ test('installHeadroom.force reinstalls even when already installed', async () => {
57
+ const { installHeadroom } = await import('../src/server/headroom.mjs');
58
+ const result = await installHeadroom({ force: true });
59
+
60
+ // Returns the shape regardless of outcome
61
+ assert.ok(typeof result.ok === 'boolean', 'ok is boolean');
62
+ assert.ok(typeof result.installed === 'boolean', 'installed is boolean');
63
+ assert.ok(typeof result.method === 'string', 'method is string');
64
+ });
65
+
66
+ // ── Wrap / Unwrap tests ─────────────────────────────────────────────────────
67
+
68
+ test('wrapOpencode returns correct shape', async () => {
69
+ const { wrapOpencode } = await import('../src/server/headroom.mjs');
70
+ const result = await wrapOpencode({ port: 8787 });
71
+
72
+ assert.ok(typeof result.ok === 'boolean', 'ok is boolean');
73
+ assert.ok(typeof result.port === 'number', 'port is number');
74
+ assert.ok(typeof result.log === 'string', 'log is string');
75
+ assert.strictEqual(result.port, 8787, 'port is 8787');
76
+ });
77
+
78
+ test('unwrapOpencode returns correct shape', async () => {
79
+ const { unwrapOpencode } = await import('../src/server/headroom.mjs');
80
+ const result = await unwrapOpencode();
81
+
82
+ assert.ok(typeof result.ok === 'boolean', 'ok is boolean');
83
+ assert.ok(typeof result.log === 'string', 'log is string');
84
+ });
85
+
86
+ // ── Proxy tests ─────────────────────────────────────────────────────────────
87
+
88
+ test('startProxy returns correct shape', async () => {
89
+ const { startProxy } = await import('../src/server/headroom.mjs');
90
+ const result = await startProxy({ port: 18787, host: '127.0.0.1' });
91
+
92
+ assert.ok(typeof result.ok === 'boolean', 'ok is boolean');
93
+ assert.ok(typeof result.pid === 'number' || result.pid === null, 'pid is number|null');
94
+ assert.ok(typeof result.port === 'number', 'port is number');
95
+ assert.ok(typeof result.logPath === 'string', 'logPath is string');
96
+ });
97
+
98
+ test('stopProxy returns correct shape', async () => {
99
+ const { stopProxy } = await import('../src/server/headroom.mjs');
100
+ const result = await stopProxy();
101
+
102
+ assert.ok(typeof result.ok === 'boolean', 'ok is boolean');
103
+ assert.ok(Array.isArray(result.killed), 'killed is array');
104
+ });
105
+
106
+ // ── getOpencodeConfig tests ──────────────────────────────────────────────────
107
+
108
+ test('getOpencodeConfig returns correct shape', async () => {
109
+ const { getOpencodeConfig } = await import('../src/server/headroom.mjs');
110
+ const result = await getOpencodeConfig();
111
+
112
+ assert.ok(typeof result.configPath === 'string', 'configPath is string');
113
+ assert.ok(typeof result.hasHeadroomProvider === 'boolean', 'hasHeadroomProvider is boolean');
114
+ assert.ok(result.configPath.includes('opencode.json'), 'configPath points to opencode.json');
115
+ });
116
+
117
+ // ── withHeadroomProxy tests ──────────────────────────────────────────────────
118
+
119
+ test('withHeadroomProxy returns URL unchanged when disabled', async () => {
120
+ const { withHeadroomProxy } = await import('../src/server/headroom.mjs');
121
+ const url = 'https://api.anthropic.com/v1/messages';
122
+ // When headroom is not in settings (default), returns URL unchanged
123
+ const result = withHeadroomProxy(url, { port: 8787 });
124
+ // Result depends on settings — just verify it's a string
125
+ assert.ok(typeof result === 'string', 'result is string');
126
+ assert.ok(result.length > 0, 'result is non-empty');
127
+ });
128
+
129
+ // ── /api/headroom/install endpoint ──────────────────────────────────────────
130
+
131
+ async function buildApp() {
132
+ const { createHeadroomRouter } = await import('../src/server/routes/headroom.mjs');
133
+ const express = (await import('express')).default;
134
+ const app = express();
135
+ app.use(express.json());
136
+ // Mount at /api/headroom so routes like /status become /api/headroom/status
137
+ app.use('/api/headroom', await createHeadroomRouter());
138
+ return app;
139
+ }
140
+
141
+ test('POST /api/headroom/install returns result', async () => {
142
+ const app = await buildApp();
143
+ const server = app.listen(0);
144
+ const { port } = server.address();
145
+
146
+ const httpPost = () =>
147
+ new Promise((resolve, reject) => {
148
+ const data = JSON.stringify({ force: false });
149
+ const req = http.request(
150
+ { hostname: 'localhost', port, path: '/api/headroom/install', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
151
+ (res) => {
152
+ let body = '';
153
+ res.on('data', (c) => (body += c));
154
+ res.on('end', () => {
155
+ try { resolve({ status: res.statusCode, body: JSON.parse(body || '{}') }); }
156
+ catch { resolve({ status: res.statusCode, body }); }
157
+ });
158
+ },
159
+ );
160
+ req.on('error', reject);
161
+ req.write(data);
162
+ req.end();
163
+ });
164
+
165
+ try {
166
+ const res = await httpPost();
167
+ assert.strictEqual(res.status, 200, 'status should be 200');
168
+ assert.ok(typeof res.body.ok === 'boolean', 'ok is boolean');
169
+ assert.ok(typeof res.body.installed === 'boolean', 'installed is boolean');
170
+ } finally {
171
+ server.close();
172
+ }
173
+ });