@polderlabs/bizar 5.0.0 → 5.0.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 (97) hide show
  1. package/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  4. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  6. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  7. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  8. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
  9. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
  10. package/bizar-dash/dist/index.html +4 -4
  11. package/bizar-dash/dist/mobile.html +3 -3
  12. package/bizar-dash/node_modules/.package-lock.json +6 -0
  13. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  14. package/bizar-dash/package-lock.json +6 -0
  15. package/bizar-dash/src/server/api.mjs +7 -0
  16. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  17. package/bizar-dash/src/server/memory-store.mjs +46 -0
  18. package/bizar-dash/src/server/providers-store.mjs +11 -4
  19. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  20. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  21. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  22. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  23. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  24. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  25. package/bizar-dash/src/server/serve-info.mjs +172 -0
  26. package/bizar-dash/src/web/App.tsx +48 -11
  27. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  28. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  29. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  30. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  31. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  32. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  33. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  34. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  35. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  36. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  37. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  38. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  39. package/bizar-dash/src/web/lib/types.ts +134 -0
  40. package/bizar-dash/src/web/styles/chat.css +8 -5
  41. package/bizar-dash/src/web/styles/main.css +77 -2
  42. package/bizar-dash/src/web/styles/settings.css +265 -0
  43. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  44. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  46. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  47. package/bizar-dash/src/web/views/Settings.tsx +76 -46
  48. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  49. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  50. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  51. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  52. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  53. package/bizar-dash/tests/autosave.test.tsx +276 -0
  54. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  55. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  56. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  57. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  58. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  59. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  60. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  61. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  62. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  63. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  64. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  65. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  66. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  67. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  68. package/cli/commands/dash.mjs +5 -1
  69. package/cli/commands/minimax.mjs +5 -2
  70. package/cli/tests/minimax-cli.test.mjs +79 -0
  71. package/config/agents/frigg.md +1 -1
  72. package/config/agents/heimdall.md +1 -1
  73. package/config/agents/mimir.md +1 -1
  74. package/config/agents/quick.md +1 -1
  75. package/config/agents/semble-search.md +1 -1
  76. package/config/agents/vor.md +1 -1
  77. package/config/opencode.json +28 -21
  78. package/config/opencode.json.template +8 -1
  79. package/package.json +1 -1
  80. package/plugins/bizar/index.ts +77 -0
  81. package/plugins/bizar/src/compaction.d.mts +48 -0
  82. package/plugins/bizar/src/compaction.mjs +192 -0
  83. package/plugins/bizar/tests/compaction.test.ts +264 -0
  84. package/templates/schedules/daily-backup.json +12 -0
  85. package/templates/schedules/daily-cleanup.json +12 -0
  86. package/templates/schedules/hourly-health-check.json +12 -0
  87. package/templates/schedules/webhook-on-push.json +13 -0
  88. package/templates/schedules/weekly-digest.json +13 -0
  89. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
  90. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
  91. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
  92. package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
  93. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
  94. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
  95. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  96. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  97. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -0,0 +1,206 @@
1
+ /**
2
+ * tests/diagnostics-store.test.mjs
3
+ *
4
+ * v6.0.0 — Tests for the Doctor surface of the diagnostics store:
5
+ * - getRecentErrors({ since }) — timestamp-filtered error feed
6
+ * - runCheck(name) — single-check dispatch
7
+ * - health() — rolled-up status + groups
8
+ * - collectDiagnostics() — full snapshot shape
9
+ * - legacy snapshot() / health() — back-compat with v3 /api/diagnostics
10
+ *
11
+ * The store reads from `~/.config/bizar/service.log` and TCP-probes
12
+ * local services. Tests that exercise I/O use a temporary BIZAR_HOME
13
+ * so they don't touch the real install.
14
+ */
15
+ import { describe, it, beforeEach, afterEach } from 'node:test';
16
+ import assert from 'node:assert';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
20
+
21
+ let tmpHome;
22
+
23
+ const STORE = await import('../src/server/diagnostics-store.mjs');
24
+
25
+ beforeEach(() => {
26
+ tmpHome = mkdtempSync(join(tmpdir(), 'bizar-doctor-store-'));
27
+ // The store reads `~/.config/bizar/service.log` via the homedir()
28
+ // helper. We can't change homedir() at runtime, so we redirect
29
+ // BIZAR-related env by writing an empty service log to the real
30
+ // ~/.config/bizar path... actually the store uses homedir()
31
+ // unconditionally, so tests that need a custom log must write
32
+ // before each test. We work around by NOT requiring log presence
33
+ // — getRecentErrors() returns [] on missing files.
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (tmpHome) {
38
+ try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
39
+ }
40
+ });
41
+
42
+ // ── getRecentErrors ────────────────────────────────────────────────────────
43
+
44
+ describe('getRecentErrors', () => {
45
+ it('returns [] when no service log exists', () => {
46
+ // The real ~/.config/bizar/service.log may or may not exist;
47
+ // either way, the function must return an array (possibly empty).
48
+ const out = STORE.getRecentErrors();
49
+ assert.ok(Array.isArray(out));
50
+ // No crash, no throw — that's the contract.
51
+ });
52
+
53
+ it('respects the limit option', () => {
54
+ const out = STORE.getRecentErrors({ limit: 5 });
55
+ assert.ok(Array.isArray(out));
56
+ assert.ok(out.length <= 5);
57
+ });
58
+
59
+ it('returns entries with the documented shape', () => {
60
+ const out = STORE.getRecentErrors({ limit: 1 });
61
+ if (out.length === 0) return; // nothing to check
62
+ const row = out[0];
63
+ assert.strictEqual(typeof row.line, 'string');
64
+ assert.ok(row.ts === null || typeof row.ts === 'string');
65
+ assert.ok(row.tsMs === null || typeof row.tsMs === 'number');
66
+ });
67
+
68
+ it('filters out lines without error keywords', () => {
69
+ // Drop the result into a sink — we just want to make sure the
70
+ // function never returns a non-error line.
71
+ const out = STORE.getRecentErrors({ limit: 200 });
72
+ for (const row of out) {
73
+ assert.ok(
74
+ /\b(failed|error|err)\b/i.test(row.line),
75
+ `expected an error keyword in: ${row.line}`,
76
+ );
77
+ }
78
+ });
79
+ });
80
+
81
+ // ── runCheck ───────────────────────────────────────────────────────────────
82
+
83
+ describe('runCheck', () => {
84
+ it('returns the named check for a known name', async () => {
85
+ const result = await STORE.runCheck('node');
86
+ assert.strictEqual(result.name, 'node');
87
+ assert.ok(['ok', 'warn', 'fail'].includes(result.status));
88
+ assert.strictEqual(typeof result.message, 'string');
89
+ });
90
+
91
+ it('returns fail for an unknown check name', async () => {
92
+ const result = await STORE.runCheck('not-a-real-check');
93
+ assert.strictEqual(result.status, 'fail');
94
+ assert.strictEqual(result.message, 'unknown check');
95
+ assert.match(result.error, /Available: /);
96
+ });
97
+
98
+ it('handles empty / non-string input', async () => {
99
+ const result = await STORE.runCheck('');
100
+ assert.strictEqual(result.status, 'fail');
101
+ assert.match(result.message, /unknown check|checkName required/);
102
+ });
103
+
104
+ it('returns the same set of names registered', async () => {
105
+ // Smoke test: a system check name (memory) and a config check
106
+ // name (opencode-config) and a service check name (dashboard)
107
+ // must all be reachable.
108
+ for (const name of ['memory', 'opencode-config', 'dashboard']) {
109
+ const r = await STORE.runCheck(name);
110
+ assert.notStrictEqual(r.message, 'unknown check', `${name} should be a known check`);
111
+ assert.ok(['ok', 'warn', 'fail'].includes(r.status));
112
+ }
113
+ });
114
+ });
115
+
116
+ // ── health ─────────────────────────────────────────────────────────────────
117
+
118
+ describe('health', () => {
119
+ it('returns the rolled-up status object', async () => {
120
+ const h = await STORE.health();
121
+ assert.strictEqual(typeof h.ts, 'string');
122
+ assert.ok(['ok', 'warn', 'fail'].includes(h.status));
123
+ assert.ok(Array.isArray(h.issues));
124
+ assert.ok(h.groups && typeof h.groups === 'object');
125
+ assert.ok(Array.isArray(h.groups.system));
126
+ assert.ok(Array.isArray(h.groups.config));
127
+ assert.ok(Array.isArray(h.groups.services));
128
+ });
129
+
130
+ it('derives status from issues (fail > warn > ok)', async () => {
131
+ const h = await STORE.health();
132
+ const has = (s) => h.issues.some((i) => i.status === s);
133
+ if (has('fail')) assert.strictEqual(h.status, 'fail');
134
+ else if (has('warn')) assert.strictEqual(h.status, 'warn');
135
+ else assert.strictEqual(h.status, 'ok');
136
+ });
137
+ });
138
+
139
+ // ── collectDiagnostics ─────────────────────────────────────────────────────
140
+
141
+ describe('collectDiagnostics', () => {
142
+ it('returns a snapshot with the documented top-level keys', async () => {
143
+ const snap = await STORE.collectDiagnostics();
144
+ assert.strictEqual(typeof snap.timestamp, 'string');
145
+ assert.strictEqual(typeof snap.bizarVersion, 'string');
146
+ assert.strictEqual(snap.nodeVersion, process.version);
147
+ assert.strictEqual(snap.platform, process.platform);
148
+ assert.strictEqual(snap.arch, process.arch);
149
+ assert.strictEqual(typeof snap.uptime, 'number');
150
+ assert.ok(snap.memory && typeof snap.memory.rss === 'number');
151
+ assert.ok(snap.disk && typeof snap.disk.exists === 'boolean');
152
+ assert.ok(snap.services);
153
+ assert.ok(snap.services.dashboard);
154
+ assert.ok(snap.services.headroom);
155
+ assert.ok(snap.services.lightrag);
156
+ assert.ok(snap.services.opencode);
157
+ assert.ok(snap.counts);
158
+ assert.ok(Array.isArray(snap.recentErrors));
159
+ assert.ok(Array.isArray(snap.configHealth));
160
+ assert.ok(snap.opencode);
161
+ assert.ok(snap.checks);
162
+ assert.ok(snap.health);
163
+ });
164
+
165
+ it('counts all expected numeric fields', async () => {
166
+ const snap = await STORE.collectDiagnostics();
167
+ const c = snap.counts;
168
+ for (const key of [
169
+ 'tasks', 'schedules', 'mods', 'providers', 'mcps', 'agents',
170
+ 'projects', 'workspaces', 'voiceNotes', 'evalRuns', 'backups',
171
+ ]) {
172
+ assert.strictEqual(typeof c[key], 'number', `counts.${key} should be a number`);
173
+ assert.ok(c[key] >= 0, `counts.${key} should be >= 0`);
174
+ }
175
+ });
176
+
177
+ it('returns recent errors within the requested window', async () => {
178
+ const snap = await STORE.collectDiagnostics();
179
+ for (const err of snap.recentErrors) {
180
+ assert.ok(err.tsMs === null || err.tsMs >= Date.now() - 3700_000,
181
+ `recent error outside the last-hour window: ${err.line}`);
182
+ }
183
+ });
184
+ });
185
+
186
+ // ── legacy snapshot / health (back-compat with v3 /api/diagnostics) ───────
187
+
188
+ describe('diagnosticsStore (legacy v3)', () => {
189
+ it('snapshot() returns the legacy shape', () => {
190
+ const snap = STORE.diagnosticsStore.snapshot();
191
+ assert.strictEqual(typeof snap.version, 'string');
192
+ assert.strictEqual(typeof snap.uptime, 'number');
193
+ assert.strictEqual(snap.nodeVersion, process.version);
194
+ assert.strictEqual(snap.platform, process.platform);
195
+ assert.ok(snap.memory);
196
+ assert.ok(snap.counts);
197
+ assert.ok(Array.isArray(snap.errors));
198
+ assert.ok(snap.service);
199
+ });
200
+
201
+ it('legacy health() returns checks array', () => {
202
+ const h = STORE.diagnosticsStore.health();
203
+ assert.strictEqual(typeof h.ts, 'string');
204
+ assert.ok(Array.isArray(h.checks));
205
+ });
206
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * env-vars-extended.test.mjs — tests for bulk-import, export, grouped endpoints.
3
+ *
4
+ * Run with: node --test bizar-dash/tests/env-vars-extended.test.mjs
5
+ */
6
+ import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+
12
+ const REPO = join(import.meta.dirname, '..', '..');
13
+
14
+ let SANDBOX_HOME;
15
+ let ORIGINAL_HOME;
16
+ let ENV_FILE;
17
+
18
+ before(() => {
19
+ SANDBOX_HOME = mkdtempSync(join(tmpdir(), `bizar-env-vars-ext-${Date.now()}-`));
20
+ ORIGINAL_HOME = process.env.HOME;
21
+ process.env.HOME = SANDBOX_HOME;
22
+ const BIZAR_DIR = join(SANDBOX_HOME, '.config', 'bizar');
23
+ ENV_FILE = join(BIZAR_DIR, 'env.json');
24
+ });
25
+
26
+ after(() => {
27
+ process.env.HOME = ORIGINAL_HOME;
28
+ for (const k of Object.keys(process.env)) {
29
+ if (k.startsWith('BIZAR_EXT_')) delete process.env[k];
30
+ }
31
+ try { rmSync(SANDBOX_HOME, { recursive: true, force: true }); } catch { /* ignore */ }
32
+ });
33
+
34
+ async function request(app, method, path, body) {
35
+ const http = await import('node:http');
36
+ return new Promise((resolve, reject) => {
37
+ const server = app.listen(0, '127.0.0.1', () => {
38
+ const { port } = server.address();
39
+ const bodyStr = body ? JSON.stringify(body) : '';
40
+ const opts = {
41
+ hostname: '127.0.0.1', port, path, method,
42
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) },
43
+ };
44
+ const req = http.request(opts, (res) => {
45
+ let data = '';
46
+ res.on('data', (chunk) => { data += chunk; });
47
+ res.on('end', () => {
48
+ server.close();
49
+ try { resolve({ status: res.statusCode, body: JSON.parse(data), raw: data, headers: res.headers }); }
50
+ catch { resolve({ status: res.statusCode, body: data, raw: data, headers: res.headers }); }
51
+ });
52
+ });
53
+ req.on('error', (err) => { server.close(); reject(err); });
54
+ if (bodyStr) req.write(bodyStr);
55
+ req.end();
56
+ });
57
+ });
58
+ }
59
+
60
+ // Track which keys this test file uses so we can clean them up
61
+ const TEST_KEYS = new Set();
62
+
63
+ describe('env-vars extended endpoints', () => {
64
+ let app;
65
+
66
+ beforeEach(async () => {
67
+ // Delete env file to start fresh
68
+ try { rmSync(ENV_FILE); } catch { /* ignore */ }
69
+ // Import fresh module
70
+ const express = (await import('express')).default;
71
+ const { createEnvVarsRouter } = await import(`${REPO}/bizar-dash/src/server/routes/env-vars.mjs`);
72
+ // Force store re-init
73
+ const { resetStore } = await import(`${REPO}/bizar-dash/src/server/routes/env-vars.mjs`);
74
+ resetStore();
75
+ app = express();
76
+ app.use(express.json());
77
+ app.use('/api', createEnvVarsRouter());
78
+ // Clear test keys from process.env
79
+ for (const k of Object.keys(process.env)) {
80
+ if (k.startsWith('BIZAR_EXT_')) delete process.env[k];
81
+ }
82
+ });
83
+
84
+ afterEach(() => {
85
+ for (const k of Object.keys(process.env)) {
86
+ if (k.startsWith('BIZAR_EXT_')) delete process.env[k];
87
+ }
88
+ });
89
+
90
+ // ─── POST /api/env-vars/bulk-import ───────────────────────────────────────
91
+
92
+ it('bulk-import parses KEY=value lines', async () => {
93
+ const res = await request(app, 'POST', '/api/env-vars/bulk-import', {
94
+ envContent: 'BIZAR_EXT_A=value1\nBIZAR_EXT_B=value2',
95
+ });
96
+ assert.equal(res.status, 200);
97
+ assert.equal(res.body.ok, true);
98
+ assert.equal(res.body.imported, 2);
99
+ assert.equal(res.body.skipped, 0);
100
+ assert.deepEqual(res.body.errors, []);
101
+ });
102
+
103
+ it('bulk-import skips existing keys', async () => {
104
+ // Pre-create one
105
+ const create = await request(app, 'POST', '/api/env-vars', {
106
+ name: 'BIZAR_EXT_EXISTING', value: 'original',
107
+ });
108
+ assert.equal(create.status, 201);
109
+
110
+ const bulk = await request(app, 'POST', '/api/env-vars/bulk-import', {
111
+ envContent: 'BIZAR_EXT_EXISTING=ignored\nBIZAR_EXT_NEWBULK=fresh',
112
+ });
113
+ assert.equal(bulk.body.imported, 1, 'should import only the new key');
114
+ assert.equal(bulk.body.skipped, 1, 'should skip the existing key');
115
+ assert.ok(bulk.body.errors.some((e) => e.includes('Already exists')));
116
+ });
117
+
118
+ it('bulk-import reports invalid lines in errors', async () => {
119
+ const res = await request(app, 'POST', '/api/env-vars/bulk-import', {
120
+ envContent: 'BIZAR_ext_invalid=value\nBIZAR_EXT_VALID=good\nBIZAR_EXT_NOEquals\nBOGUS_PREFIX=bad',
121
+ });
122
+ assert.equal(res.body.imported, 1, 'only valid entries are imported');
123
+ assert.ok(res.body.errors.length >= 2, 'should have errors for invalid lines');
124
+ });
125
+
126
+ it('bulk-import skips blank lines and comments', async () => {
127
+ const res = await request(app, 'POST', '/api/env-vars/bulk-import', {
128
+ envContent: '# comment\n\n \nBIZAR_EXT_TRIMMED=value',
129
+ });
130
+ assert.equal(res.body.imported, 1);
131
+ assert.equal(res.body.skipped, 3, 'comment and two blank/whitespace lines are skipped');
132
+ });
133
+
134
+ it('bulk-import requires envContent string', async () => {
135
+ const res = await request(app, 'POST', '/api/env-vars/bulk-import', {});
136
+ assert.equal(res.status, 400);
137
+ assert.equal(res.body.error, 'envContent_required');
138
+ });
139
+
140
+ // ─── GET /api/env-vars/export ─────────────────────────────────────────────
141
+
142
+ it('export produces valid .env format', async () => {
143
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_EXT_EXPORT1', value: 'val-one' });
144
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_EXT_EXPORT2', value: 'val-two' });
145
+
146
+ const res = await request(app, 'GET', '/api/env-vars/export');
147
+ assert.equal(res.status, 200);
148
+ assert.ok(res.headers['content-type']?.includes('text/plain'), 'should be text/plain');
149
+ assert.ok(res.headers['content-disposition']?.includes('.env'), 'should have .env content-disposition');
150
+ const lines = res.raw.split('\n').filter(Boolean);
151
+ assert.ok(lines.some((l) => l === 'BIZAR_EXT_EXPORT1=val-one'));
152
+ assert.ok(lines.some((l) => l === 'BIZAR_EXT_EXPORT2=val-two'));
153
+ });
154
+
155
+ it('export returns empty string when no vars', async () => {
156
+ const res = await request(app, 'GET', '/api/env-vars/export');
157
+ assert.equal(res.status, 200);
158
+ assert.equal(res.raw.trim(), '');
159
+ });
160
+
161
+ // ─── GET /api/env-vars/grouped ───────────────────────────────────────────
162
+
163
+ it('grouping splits vars by prefix', async () => {
164
+ // All vars must start with BIZAR_ per NAME_RE
165
+ // Grouping is by the "prefix" after BIZAR_ (e.g. EXT_, PROVIDER_, MODEL_)
166
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_EXT_A', value: 'v1' });
167
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_EXT_B', value: 'v2' });
168
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_PROVIDER_KEY', value: 'pv' });
169
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_MODEL_SETTING', value: 'ms' });
170
+
171
+ const res = await request(app, 'GET', '/api/env-vars/grouped');
172
+ assert.equal(res.status, 200);
173
+ const groups = res.body;
174
+ const groupKeys = Object.keys(groups).sort();
175
+ // BIZAR_EXT_* -> EXT_ prefix, BIZAR_PROVIDER_* -> PROVIDER_, BIZAR_MODEL_* -> MODEL_
176
+ assert.ok(groupKeys.includes('EXT_'), `should have EXT_ group, got: ${groupKeys.join(',')}`);
177
+ assert.ok(groupKeys.includes('PROVIDER_'), `should have PROVIDER_ group, got: ${groupKeys.join(',')}`);
178
+ assert.ok(groupKeys.includes('MODEL_'), `should have MODEL_ group, got: ${groupKeys.join(',')}`);
179
+ assert.equal(groups['EXT_'].length, 2, 'BIZAR_EXT_* vars');
180
+ assert.equal(groups['PROVIDER_'].length, 1);
181
+ assert.equal(groups['MODEL_'].length, 1);
182
+ assert.ok(groups['EXT_'][0].value.includes('*'), 'values should be masked');
183
+ });
184
+
185
+ it('grouped returns empty object when no vars', async () => {
186
+ const res = await request(app, 'GET', '/api/env-vars/grouped');
187
+ assert.equal(res.status, 200);
188
+ assert.deepEqual(res.body, {});
189
+ });
190
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * tests/layout-spacing.test.mjs
3
+ *
4
+ * v4.x.x — Smoke test for page-level CSS spacing.
5
+ * Parses main.css and verifies the .view class has sufficient top padding
6
+ * so content isn't squished against the topbar.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+
11
+ const css = readFileSync(
12
+ resolve(import.meta.dirname, '../src/web/styles/main.css'),
13
+ 'utf8',
14
+ );
15
+
16
+ // Extract the .view rule's padding value.
17
+ // Handles both `padding: 24px` (shorthand) and `padding: var(--space-6)` (CSS variable).
18
+ // We extract the first token (top padding) and resolve CSS var to its px value.
19
+ const viewPaddingMatch = css.match(/\.view\s*\{[^}]*padding:\s*([^;]+)(?:\s|;)/);
20
+ if (!viewPaddingMatch) throw new Error('Could not find .view { padding: ... } in main.css');
21
+
22
+ // The padding shorthand may be "var(--space-6) var(--space-6) 0" (top right bottom).
23
+ // We split by whitespace and resolve the first token.
24
+ const paddingTokens = viewPaddingMatch[1].trim().split(/\s+/);
25
+ const paddingValue = paddingTokens[0];
26
+ // CSS var: var(--space-6) = 24px, var(--space-4) = 16px, var(--space-3) = 12px
27
+ const CSS_VAR_MAP = {
28
+ 'var(--space-1)': 4,
29
+ 'var(--space-2)': 8,
30
+ 'var(--space-3)': 12,
31
+ 'var(--space-4)': 16,
32
+ 'var(--space-5)': 20,
33
+ 'var(--space-6)': 24,
34
+ 'var(--space-8)': 32,
35
+ };
36
+
37
+ let paddingNum;
38
+ if (paddingValue.startsWith('var(')) {
39
+ paddingNum = CSS_VAR_MAP[paddingValue] ?? NaN;
40
+ } else {
41
+ paddingNum = parseInt(paddingValue, 10);
42
+ }
43
+ if (isNaN(paddingNum)) throw new Error(`Unexpected padding value: "${paddingValue}"`);
44
+
45
+ const MIN_PADDING = 16;
46
+ if (paddingNum < MIN_PADDING) {
47
+ throw new Error(
48
+ `.view padding-top too small: ${paddingNum}px (expected >= ${MIN_PADDING}px). ` +
49
+ `Content will be squished against the topbar.`,
50
+ );
51
+ }
52
+
53
+ console.log(`✓ .view padding-top: ${paddingNum}px (minimum: ${MIN_PADDING}px)`);
54
+
55
+ // Also verify .bg-active-view has adequate top padding
56
+ const bgMatch = css.match(/\.bg-active-view\s*\{[^}]*padding:\s*([^;]+)(?:\s|;)/);
57
+ if (bgMatch) {
58
+ const bgTokens = bgMatch[1].trim().split(/\s+/);
59
+ const bgVal = bgTokens[0];
60
+ let bgPadding;
61
+ if (bgVal.startsWith('var(')) {
62
+ bgPadding = CSS_VAR_MAP[bgVal] ?? NaN;
63
+ } else {
64
+ bgPadding = parseInt(bgVal, 10);
65
+ }
66
+ if (!isNaN(bgPadding) && bgPadding < MIN_PADDING) {
67
+ throw new Error(
68
+ `.bg-active-view padding-top too small: ${bgPadding}px (expected >= ${MIN_PADDING}px)`,
69
+ );
70
+ }
71
+ if (!isNaN(bgPadding)) console.log(`✓ .bg-active-view padding-top: ${bgPadding}px`);
72
+ }
73
+
74
+ // Verify .view gap is at least 16px
75
+ const gapMatch = css.match(/\.view\s*\{[^}]*gap:\s*([^;]+)(?:\s|;)/);
76
+ if (gapMatch) {
77
+ const gapVal = gapMatch[1].trim();
78
+ let gapNum;
79
+ if (gapVal.startsWith('var(')) {
80
+ gapNum = CSS_VAR_MAP[gapVal] ?? NaN;
81
+ } else {
82
+ gapNum = parseInt(gapVal, 10);
83
+ }
84
+ const MIN_GAP = 16;
85
+ if (!isNaN(gapNum) && gapNum < MIN_GAP) {
86
+ throw new Error(`.view gap too small: ${gapNum}px (expected >= ${MIN_GAP}px)`);
87
+ }
88
+ if (!isNaN(gapNum)) console.log(`✓ .view gap: ${gapNum}px`);
89
+ }
90
+
91
+ // Verify .page-header exists (margin-bottom for spacing below page headers)
92
+ if (!css.includes('.page-header')) {
93
+ throw new Error('.page-header not found in main.css — page header spacing missing');
94
+ }
95
+ console.log('✓ .page-header rule present');
96
+
97
+ // Verify .card + .card rule for sibling card spacing
98
+ if (!css.includes('.card + .card')) {
99
+ throw new Error('.card + .card sibling rule not found — card spacing may be cramped');
100
+ }
101
+ console.log('✓ .card + .card sibling rule present');
@@ -0,0 +1,98 @@
1
+ /**
2
+ * tests/memory-default-vault.test.mjs
3
+ *
4
+ * Tests for the default vault location, currentVault(), and ensureVaultExists().
5
+ */
6
+
7
+ import { describe, it, beforeEach, afterEach } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import { tmpdir, homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { rmSync, existsSync, readFileSync } from 'node:fs';
12
+
13
+ const TEST_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
14
+
15
+ describe('DEFAULT_MEMORY_VAULT', () => {
16
+ it('is ~/.local/share/bizar/memory', () => {
17
+ const expected = join(homedir(), '.local', 'share', 'bizar', 'memory');
18
+ assert.strictEqual(TEST_STORE.DEFAULT_MEMORY_VAULT, expected);
19
+ });
20
+ });
21
+
22
+ describe('DEFAULT_GIT_REMOTE', () => {
23
+ it('is null by default', () => {
24
+ assert.strictEqual(TEST_STORE.DEFAULT_GIT_REMOTE, null);
25
+ });
26
+ });
27
+
28
+ describe('currentVault', () => {
29
+ it('returns DEFAULT_MEMORY_VAULT when no env override', () => {
30
+ assert.strictEqual(TEST_STORE.currentVault(), TEST_STORE.DEFAULT_MEMORY_VAULT);
31
+ });
32
+
33
+ it('returns BIZAR_MEMORY_VAULT env var when set', () => {
34
+ const orig = process.env.BIZAR_MEMORY_VAULT;
35
+ process.env.BIZAR_MEMORY_VAULT = '/tmp/test-current-vault';
36
+ try {
37
+ assert.strictEqual(TEST_STORE.currentVault(), '/tmp/test-current-vault');
38
+ } finally {
39
+ if (orig === undefined) delete process.env.BIZAR_MEMORY_VAULT;
40
+ else process.env.BIZAR_MEMORY_VAULT = orig;
41
+ }
42
+ });
43
+ });
44
+
45
+ describe('ensureVaultExists', () => {
46
+ // Use a real temp path so we actually test the function
47
+ let testVaultRoot;
48
+
49
+ beforeEach(() => {
50
+ testVaultRoot = join(
51
+ tmpdir(),
52
+ `bizar-test-vault-${Date.now()}-${Math.random().toString(36).slice(2)}`,
53
+ );
54
+ // Set env var before importing / calling
55
+ process.env.BIZAR_MEMORY_VAULT = testVaultRoot;
56
+ });
57
+
58
+ afterEach(() => {
59
+ delete process.env.BIZAR_MEMORY_VAULT;
60
+ try { rmSync(testVaultRoot, { recursive: true, force: true }); } catch { /* ignore */ }
61
+ });
62
+
63
+ it('creates the vault directory if missing', () => {
64
+ assert.strictEqual(existsSync(testVaultRoot), false);
65
+ const result = TEST_STORE.ensureVaultExists();
66
+ assert.strictEqual(existsSync(testVaultRoot), true);
67
+ assert.strictEqual(result, testVaultRoot);
68
+ });
69
+
70
+ it('is idempotent — calling twice does not throw', () => {
71
+ TEST_STORE.ensureVaultExists();
72
+ const result = TEST_STORE.ensureVaultExists();
73
+ assert.strictEqual(result, testVaultRoot);
74
+ assert.strictEqual(existsSync(testVaultRoot), true);
75
+ });
76
+
77
+ it('inits git repo on first run when .git is absent', () => {
78
+ assert.strictEqual(existsSync(join(testVaultRoot, '.git')), false);
79
+ TEST_STORE.ensureVaultExists();
80
+ assert.strictEqual(existsSync(join(testVaultRoot, '.git')), true);
81
+ });
82
+
83
+ it('skips git init if .git already exists', () => {
84
+ TEST_STORE.ensureVaultExists();
85
+ assert.strictEqual(existsSync(join(testVaultRoot, '.git')), true);
86
+ // second call must not throw
87
+ TEST_STORE.ensureVaultExists();
88
+ assert.strictEqual(existsSync(join(testVaultRoot, '.git')), true);
89
+ });
90
+
91
+ it('sets git user.email config', () => {
92
+ TEST_STORE.ensureVaultExists();
93
+ const configFile = join(testVaultRoot, '.git', 'config');
94
+ assert.strictEqual(existsSync(configFile), true);
95
+ const content = readFileSync(configFile, 'utf8');
96
+ assert.match(content, /bizar@localhost/);
97
+ });
98
+ });