@polderlabs/bizar 5.5.1 → 5.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 (49) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-BUQnyVQD.js → EnvVarsSection-DoIzqrlb.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-BUQnyVQD.js.map → EnvVarsSection-DoIzqrlb.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-8FupFgTv.js → MobileChat-CoUCUsbr.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-8FupFgTv.js.map → MobileChat-CoUCUsbr.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-BkXtglA6.js.map → MobileSettings-BeO04g52.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CUwkLRms.js → Toast-D9nv5N6m.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CUwkLRms.js.map → Toast-D9nv5N6m.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-COpQr35l.js → icons-Btipv2pp.js} +65 -70
  10. package/bizar-dash/dist/assets/icons-Btipv2pp.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-DHXPuddY.js +18 -0
  12. package/bizar-dash/dist/assets/main-DHXPuddY.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-Bz_zWFCw.css → main-UG4jKK-F.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-H5KH9cTL.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-B7dAkmYd.js.map → mobile-H5KH9cTL.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-ClC6Qa42.js → mobile-layout-BqHxVb6_.js} +2 -2
  17. package/bizar-dash/dist/assets/{mobile-layout-ClC6Qa42.js.map → mobile-layout-BqHxVb6_.js.map} +1 -1
  18. package/bizar-dash/dist/assets/{useSlashCommands-B3MZ1JXa.js → useSlashCommands-C24-O1e7.js} +2 -2
  19. package/bizar-dash/dist/assets/{useSlashCommands-B3MZ1JXa.js.map → useSlashCommands-C24-O1e7.js.map} +1 -1
  20. package/bizar-dash/dist/index.html +7 -7
  21. package/bizar-dash/dist/mobile.html +3 -3
  22. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  23. package/bizar-dash/src/server/memory-store.mjs +74 -28
  24. package/bizar-dash/src/server/routes/memory.mjs +91 -38
  25. package/bizar-dash/src/web/App.tsx +36 -43
  26. package/bizar-dash/src/web/components/SettingsNav.tsx +92 -0
  27. package/bizar-dash/src/web/components/Sidebar.tsx +26 -7
  28. package/bizar-dash/src/web/components/Topbar.tsx +11 -48
  29. package/bizar-dash/src/web/styles/main.css +18 -3
  30. package/bizar-dash/src/web/styles/settings.css +115 -3
  31. package/bizar-dash/src/web/views/Settings.tsx +184 -205
  32. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +4 -4
  33. package/bizar-dash/tests/memory-namespace.test.mjs +2 -2
  34. package/bizar-dash/tests/memory-status-root.test.mjs +144 -0
  35. package/bizar-dash/tests/memory-store.test.mjs +5 -4
  36. package/bizar-dash/tests/memory-vault-config-endpoint.test.mjs +173 -0
  37. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  38. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  39. package/bizar-dash/tests/settings-nav.test.tsx +127 -0
  40. package/bizar-dash/tests/views/Memory.test.tsx +40 -1
  41. package/bizar-dash/tests/views/topbar.test.tsx +35 -50
  42. package/cli/memory.mjs +1 -1
  43. package/package.json +1 -1
  44. package/bizar-dash/dist/assets/MobileSettings-BkXtglA6.js +0 -1
  45. package/bizar-dash/dist/assets/icons-COpQr35l.js.map +0 -1
  46. package/bizar-dash/dist/assets/main-gqVwliHz.js +0 -18
  47. package/bizar-dash/dist/assets/main-gqVwliHz.js.map +0 -1
  48. package/bizar-dash/dist/assets/mobile-B7dAkmYd.js +0 -1
  49. package/bizar-dash/tests/views/Settings.test.tsx +0 -160
@@ -53,11 +53,12 @@ describe('memory-store', () => {
53
53
  lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
54
54
  git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(my-proj): {summary}' },
55
55
  });
56
- const { vaultRoot, mode } = TEST_MEMORY_STORE.resolveVault(projectRoot);
56
+ const { vaultRoot, projectVaultRoot, mode } = TEST_MEMORY_STORE.resolveVault(projectRoot);
57
57
  assert.strictEqual(mode, 'managed');
58
- assert.ok(vaultRoot.includes('data/memory'));
59
- assert.ok(vaultRoot.includes('projects'));
60
- assert.ok(vaultRoot.includes('my-proj'));
58
+ assert.ok(vaultRoot.includes('data/memory'), 'vaultRoot should include data/memory');
59
+ assert.ok(projectVaultRoot.includes('projects'), 'projectVaultRoot should include projects');
60
+ assert.ok(projectVaultRoot.includes('my-proj'), 'projectVaultRoot should include my-proj');
61
+ assert.ok(projectVaultRoot.startsWith(vaultRoot), 'projectVaultRoot should be under vaultRoot');
61
62
  });
62
63
  });
63
64
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * tests/memory-vault-config-endpoint.test.mjs
3
+ *
4
+ * v6.x — Tests for POST /memory/config/vault endpoint which updates the
5
+ * vault root path persistently in ~/.config/bizar/memory-config.json
6
+ * and sets process.env.BIZAR_MEMORY_VAULT.
7
+ */
8
+
9
+ import { test, describe, beforeEach, afterEach } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { tmpdir, homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { rmSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
14
+ import http from 'node:http';
15
+
16
+ const httpPost = (port, path, body) =>
17
+ new Promise((resolve) => {
18
+ const data = JSON.stringify(body);
19
+ const req = http.request(
20
+ { hostname: 'localhost', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
21
+ (res) => {
22
+ let respBody = '';
23
+ res.on('data', (c) => (respBody += c));
24
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(respBody || '{}') }));
25
+ },
26
+ );
27
+ req.write(data);
28
+ req.end();
29
+ });
30
+
31
+ async function buildApp(projectRoot) {
32
+ const { createMemoryRouter } = await import('../src/server/routes/memory.mjs');
33
+ const express = (await import('express')).default;
34
+ const app = express();
35
+ app.use(express.json());
36
+ app.use('/api', createMemoryRouter({ projectRoot }));
37
+ return app;
38
+ }
39
+
40
+ async function withApp(projectRoot, fn) {
41
+ const app = await buildApp(projectRoot);
42
+ const server = app.listen(0);
43
+ const port = server.address().port;
44
+ try {
45
+ return await fn(port);
46
+ } finally {
47
+ server.close();
48
+ }
49
+ }
50
+
51
+ function makeTmp() {
52
+ const root = join(tmpdir(), `bizar-vault-cfg-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
53
+ mkdirSync(root, { recursive: true });
54
+ mkdirSync(join(root, '.bizar'), { recursive: true });
55
+ return root;
56
+ }
57
+
58
+ describe('POST /api/memory/config/vault', () => {
59
+ let root;
60
+ const capturedEnv = [];
61
+
62
+ beforeEach(() => {
63
+ root = makeTmp();
64
+ // Capture process.env changes so we can restore them
65
+ capturedEnv.push(process.env.BIZAR_MEMORY_VAULT);
66
+ });
67
+
68
+ afterEach(() => {
69
+ // Restore env
70
+ if (capturedEnv.length > 0) {
71
+ const prev = capturedEnv.pop();
72
+ if (prev === undefined) delete process.env.BIZAR_MEMORY_VAULT;
73
+ else process.env.BIZAR_MEMORY_VAULT = prev;
74
+ }
75
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
76
+ });
77
+
78
+ test('returns 400 when vaultRoot is missing', async () => {
79
+ await withApp(root, async (port) => {
80
+ const res = await httpPost(port, '/api/memory/config/vault', {});
81
+ assert.strictEqual(res.status, 400);
82
+ assert.strictEqual(res.body.error, 'vaultRoot required');
83
+ });
84
+ });
85
+
86
+ test('returns 400 when vaultRoot is not a string', async () => {
87
+ await withApp(root, async (port) => {
88
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: 123 });
89
+ assert.strictEqual(res.status, 400);
90
+ assert.strictEqual(res.body.error, 'vaultRoot required');
91
+ });
92
+ });
93
+
94
+ test('returns 400 when vaultRoot is an empty string', async () => {
95
+ await withApp(root, async (port) => {
96
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: ' ' });
97
+ assert.strictEqual(res.status, 400);
98
+ assert.strictEqual(res.body.error, 'vaultRoot required');
99
+ });
100
+ });
101
+
102
+ test('creates the vault directory if it does not exist', async () => {
103
+ const targetVault = join(tmpdir(), `bizar-vault-${Date.now()}-${Math.random().toString(36).slice(2)}`);
104
+ assert.strictEqual(existsSync(targetVault), false);
105
+
106
+ await withApp(root, async (port) => {
107
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: targetVault });
108
+ assert.strictEqual(res.status, 200);
109
+ assert.strictEqual(res.body.ok, true);
110
+ assert.strictEqual(res.body.vaultRoot, targetVault);
111
+ });
112
+
113
+ assert.strictEqual(existsSync(targetVault), true);
114
+ rmSync(targetVault, { recursive: true, force: true });
115
+ });
116
+
117
+ test('persists the vault root to ~/.config/bizar/memory-config.json', async () => {
118
+ const targetVault = join(tmpdir(), `bizar-vault-persist-${Date.now()}-${Math.random().toString(36).slice(2)}`);
119
+ const configPath = join(homedir(), '.config', 'bizar', 'memory-config.json');
120
+
121
+ await withApp(root, async (port) => {
122
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: targetVault });
123
+ assert.strictEqual(res.status, 200);
124
+ assert.strictEqual(res.body.ok, true);
125
+ });
126
+
127
+ // Verify config was written
128
+ assert.strictEqual(existsSync(configPath), true);
129
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
130
+ assert.strictEqual(cfg.git?.repoPath, targetVault);
131
+
132
+ rmSync(targetVault, { recursive: true, force: true });
133
+ });
134
+
135
+ test('sets process.env.BIZAR_MEMORY_VAULT', async () => {
136
+ const targetVault = join(tmpdir(), `bizar-vault-env-${Date.now()}-${Math.random().toString(36).slice(2)}`);
137
+ assert.strictEqual(process.env.BIZAR_MEMORY_VAULT, capturedEnv[capturedEnv.length - 1] || undefined);
138
+
139
+ await withApp(root, async (port) => {
140
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: targetVault });
141
+ assert.strictEqual(res.status, 200);
142
+ });
143
+
144
+ assert.strictEqual(process.env.BIZAR_MEMORY_VAULT, targetVault);
145
+ rmSync(targetVault, { recursive: true, force: true });
146
+ });
147
+
148
+ test('expands ~ to home directory', async () => {
149
+ const targetVault = `~/bizar-test-vault-${Date.now()}`;
150
+ const expectedVault = join(homedir(), `bizar-test-vault-${Date.now()}`);
151
+
152
+ await withApp(root, async (port) => {
153
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: targetVault });
154
+ assert.strictEqual(res.status, 200);
155
+ assert.strictEqual(res.body.vaultRoot, expectedVault);
156
+ });
157
+
158
+ rmSync(expectedVault, { recursive: true, force: true });
159
+ });
160
+
161
+ test('returns the resolved vault root in the response', async () => {
162
+ const targetVault = join(tmpdir(), `bizar-vault-resolved-${Date.now()}-${Math.random().toString(36).slice(2)}`);
163
+
164
+ await withApp(root, async (port) => {
165
+ const res = await httpPost(port, '/api/memory/config/vault', { vaultRoot: targetVault });
166
+ assert.strictEqual(res.status, 200);
167
+ assert.strictEqual(res.body.ok, true);
168
+ assert.strictEqual(res.body.vaultRoot, targetVault);
169
+ });
170
+
171
+ rmSync(targetVault, { recursive: true, force: true });
172
+ });
173
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * tests/settings-layout.test.tsx
3
+ *
4
+ * v4.9.0 — Integration tests for the settings sidebar layout mode.
5
+ * Verifies that when settingsMode is active, the sidebar shows SettingsNav
6
+ * and clicking sections updates the active section.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
+ import { render, screen } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import React from 'react';
12
+ import { Sidebar } from '../src/web/components/Sidebar';
13
+ import type { TabDef } from '../src/web/components/Topbar';
14
+ import { LayoutDashboard, MessageSquare, Sliders } from 'lucide-react';
15
+
16
+ const TABS: TabDef[] = [
17
+ { id: 'overview', label: 'Overview', icon: LayoutDashboard },
18
+ { id: 'chat', label: 'Chat', icon: MessageSquare },
19
+ { id: 'settings', label: 'Settings', icon: Sliders },
20
+ ];
21
+
22
+ describe('Sidebar — settingsMode', () => {
23
+ const onTabChange = vi.fn();
24
+ const onSettingsSectionChange = vi.fn();
25
+ const onExitSettings = vi.fn();
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ it('renders normal tab rail when settingsMode is false', () => {
32
+ render(
33
+ <Sidebar
34
+ tabs={TABS}
35
+ activeTab="overview"
36
+ onTabChange={onTabChange}
37
+ settingsMode={false}
38
+ />,
39
+ );
40
+ expect(screen.getByRole('tablist')).toBeInTheDocument();
41
+ expect(screen.getByRole('tab', { name: /overview/i })).toBeInTheDocument();
42
+ expect(screen.getByRole('tab', { name: /chat/i })).toBeInTheDocument();
43
+ });
44
+
45
+ it('renders SettingsNav instead of tab rail when settingsMode is true', () => {
46
+ render(
47
+ <Sidebar
48
+ tabs={TABS}
49
+ activeTab="settings"
50
+ onTabChange={onTabChange}
51
+ settingsMode={true}
52
+ settingsActiveSection={null}
53
+ onSettingsSectionChange={onSettingsSectionChange}
54
+ onExitSettings={onExitSettings}
55
+ />,
56
+ );
57
+ // Should NOT show the normal tab rail
58
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
59
+ // Should show the back button
60
+ expect(screen.getByRole('button', { name: /exit settings/i })).toBeInTheDocument();
61
+ // Should show section groups (use selector to avoid duplicate text matches)
62
+ expect(screen.getByText('General', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
63
+ expect(screen.getByText('Core', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
64
+ });
65
+
66
+ it('shows Back button that calls onExitSettings', async () => {
67
+ const user = userEvent.setup();
68
+ render(
69
+ <Sidebar
70
+ tabs={TABS}
71
+ activeTab="settings"
72
+ onTabChange={onTabChange}
73
+ settingsMode={true}
74
+ settingsActiveSection={null}
75
+ onSettingsSectionChange={onSettingsSectionChange}
76
+ onExitSettings={onExitSettings}
77
+ />,
78
+ );
79
+ await user.click(screen.getByText('Back'));
80
+ expect(onExitSettings).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('highlights the active section when settingsActiveSection is set', () => {
84
+ render(
85
+ <Sidebar
86
+ tabs={TABS}
87
+ activeTab="settings"
88
+ onTabChange={onTabChange}
89
+ settingsMode={true}
90
+ settingsActiveSection="theme"
91
+ onSettingsSectionChange={onSettingsSectionChange}
92
+ onExitSettings={onExitSettings}
93
+ />,
94
+ );
95
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
96
+ expect(themeBtn).toHaveClass('settings-nav-item-active');
97
+ });
98
+
99
+ it('calls onSettingsSectionChange when a section is clicked', async () => {
100
+ const user = userEvent.setup();
101
+ render(
102
+ <Sidebar
103
+ tabs={TABS}
104
+ activeTab="settings"
105
+ onTabChange={onTabChange}
106
+ settingsMode={true}
107
+ settingsActiveSection={null}
108
+ onSettingsSectionChange={onSettingsSectionChange}
109
+ onExitSettings={onExitSettings}
110
+ />,
111
+ );
112
+ await user.click(screen.getByRole('button', { name: /env vars/i }));
113
+ expect(onSettingsSectionChange).toHaveBeenCalledWith('env-vars');
114
+ });
115
+
116
+ it('calls onTabChange when a normal sidebar tab is clicked', async () => {
117
+ const user = userEvent.setup();
118
+ render(
119
+ <Sidebar
120
+ tabs={TABS}
121
+ activeTab="overview"
122
+ onTabChange={onTabChange}
123
+ settingsMode={false}
124
+ />,
125
+ );
126
+ await user.click(screen.getByRole('tab', { name: /chat/i }));
127
+ expect(onTabChange).toHaveBeenCalledWith('chat');
128
+ });
129
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tests/settings-mode-wiring.test.tsx
3
+ *
4
+ * v4.9.0 — Wiring tests for settings mode.
5
+ * Verifies:
6
+ * 1. Topbar Settings tab calls onTabChange('settings') when clicked
7
+ * 2. Sidebar renders SettingsNav when settingsMode is true
8
+ * 3. Sidebar renders normal tab rail when settingsMode is false
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { render, screen } from '@testing-library/react';
12
+ import userEvent from '@testing-library/user-event';
13
+ import React from 'react';
14
+ import { Sidebar } from '../src/web/components/Sidebar';
15
+ import { Topbar, TABS } from '../src/web/components/Topbar';
16
+ import type { TabDef } from '../src/web/components/Topbar';
17
+
18
+ // Minimal tabs for testing (first 4 tabs from TABS)
19
+ const TEST_TABS: TabDef[] = TABS.slice(0, 4);
20
+
21
+ describe('Settings mode wiring', () => {
22
+ describe('Topbar', () => {
23
+ it('calls onTabChange with "settings" when the Settings tab is clicked', async () => {
24
+ const onTabChange = vi.fn();
25
+ const user = userEvent.setup();
26
+
27
+ render(
28
+ <Topbar
29
+ activeTab="overview"
30
+ onTabChange={onTabChange}
31
+ wsStatus="connected"
32
+ version="v4.9.0"
33
+ activeProject={null}
34
+ projects={[]}
35
+ onProjectChange={vi.fn()}
36
+ onProjectsRefresh={vi.fn()}
37
+ onOpenSearch={vi.fn()}
38
+ />,
39
+ );
40
+
41
+ // Find and click the Settings tab
42
+ const settingsTab = screen.getByRole('tab', { name: /settings/i });
43
+ await user.click(settingsTab);
44
+
45
+ expect(onTabChange).toHaveBeenCalledWith('settings');
46
+ });
47
+
48
+ it('calls onTabChange with "overview" when the Overview tab is clicked', async () => {
49
+ const onTabChange = vi.fn();
50
+ const user = userEvent.setup();
51
+
52
+ render(
53
+ <Topbar
54
+ activeTab="settings"
55
+ onTabChange={onTabChange}
56
+ wsStatus="connected"
57
+ version="v4.9.0"
58
+ activeProject={null}
59
+ projects={[]}
60
+ onProjectChange={vi.fn()}
61
+ onProjectsRefresh={vi.fn()}
62
+ onOpenSearch={vi.fn()}
63
+ />,
64
+ );
65
+
66
+ const overviewTab = screen.getByRole('tab', { name: /overview/i });
67
+ await user.click(overviewTab);
68
+
69
+ expect(onTabChange).toHaveBeenCalledWith('overview');
70
+ });
71
+ });
72
+
73
+ describe('Sidebar', () => {
74
+ const onTabChange = vi.fn();
75
+ const onSettingsSectionChange = vi.fn();
76
+ const onExitSettings = vi.fn();
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ });
81
+
82
+ it('renders SettingsNav when settingsMode is true', () => {
83
+ render(
84
+ <Sidebar
85
+ tabs={TEST_TABS}
86
+ activeTab="settings"
87
+ onTabChange={onTabChange}
88
+ settingsMode={true}
89
+ settingsActiveSection={null}
90
+ onSettingsSectionChange={onSettingsSectionChange}
91
+ onExitSettings={onExitSettings}
92
+ />,
93
+ );
94
+
95
+ // SettingsNav should be visible (renders the back button)
96
+ expect(screen.getByRole('button', { name: /exit settings/i })).toBeInTheDocument();
97
+ // Normal tab rail should NOT be visible
98
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
99
+ });
100
+
101
+ it('renders normal tab rail when settingsMode is false', () => {
102
+ render(
103
+ <Sidebar
104
+ tabs={TEST_TABS}
105
+ activeTab="overview"
106
+ onTabChange={onTabChange}
107
+ settingsMode={false}
108
+ />,
109
+ );
110
+
111
+ // Tab rail should be visible
112
+ expect(screen.getByRole('tablist')).toBeInTheDocument();
113
+ // SettingsNav should NOT be visible
114
+ expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument();
115
+ });
116
+
117
+ it('exits settings mode when back button is clicked', async () => {
118
+ const user = userEvent.setup();
119
+ render(
120
+ <Sidebar
121
+ tabs={TEST_TABS}
122
+ activeTab="settings"
123
+ onTabChange={onTabChange}
124
+ settingsMode={true}
125
+ settingsActiveSection={null}
126
+ onSettingsSectionChange={onSettingsSectionChange}
127
+ onExitSettings={onExitSettings}
128
+ />,
129
+ );
130
+
131
+ await user.click(screen.getByRole('button', { name: /exit settings/i }));
132
+ expect(onExitSettings).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it('calls onTabChange when a tab is clicked while in settingsMode=false', async () => {
136
+ const user = userEvent.setup();
137
+ render(
138
+ <Sidebar
139
+ tabs={TEST_TABS}
140
+ activeTab="overview"
141
+ onTabChange={onTabChange}
142
+ settingsMode={false}
143
+ />,
144
+ );
145
+
146
+ // Click the Chat tab
147
+ await user.click(screen.getByRole('tab', { name: /chat/i }));
148
+ expect(onTabChange).toHaveBeenCalledWith('chat');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * tests/settings-nav.test.tsx
3
+ *
4
+ * v4.9.0 — Component tests for SettingsNav sidebar component.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ import { render, screen } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import React from 'react';
10
+ import { SettingsNav } from '../src/web/components/SettingsNav';
11
+
12
+ describe('SettingsNav', () => {
13
+ const onSectionChange = vi.fn();
14
+ const onExitSettings = vi.fn();
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ it('renders the back button', () => {
21
+ render(
22
+ <SettingsNav
23
+ activeSection={null}
24
+ onSectionChange={onSectionChange}
25
+ onExitSettings={onExitSettings}
26
+ />,
27
+ );
28
+ expect(screen.getByRole('button', { name: /exit settings/i })).toBeInTheDocument();
29
+ });
30
+
31
+ it('calls onExitSettings when back button is clicked', async () => {
32
+ const user = userEvent.setup();
33
+ render(
34
+ <SettingsNav
35
+ activeSection={null}
36
+ onSectionChange={onSectionChange}
37
+ onExitSettings={onExitSettings}
38
+ />,
39
+ );
40
+ await user.click(screen.getByText('Back'));
41
+ expect(onExitSettings).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it('renders section groups with group-label class', () => {
45
+ render(
46
+ <SettingsNav
47
+ activeSection={null}
48
+ onSectionChange={onSectionChange}
49
+ onExitSettings={onExitSettings}
50
+ />,
51
+ );
52
+ expect(screen.getByText('General', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
53
+ expect(screen.getByText('Core', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
54
+ expect(screen.getByText('Experience', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
55
+ });
56
+
57
+ it('renders section items within each group', () => {
58
+ render(
59
+ <SettingsNav
60
+ activeSection={null}
61
+ onSectionChange={onSectionChange}
62
+ onExitSettings={onExitSettings}
63
+ />,
64
+ );
65
+ // General group
66
+ expect(screen.getByRole('button', { name: /theme/i })).toBeInTheDocument();
67
+ expect(screen.getByRole('button', { name: /layout/i })).toBeInTheDocument();
68
+ expect(screen.getByRole('button', { name: /general/i })).toBeInTheDocument();
69
+ // Core group
70
+ expect(screen.getByRole('button', { name: /env vars/i })).toBeInTheDocument();
71
+ expect(screen.getByRole('button', { name: /system llm/i })).toBeInTheDocument();
72
+ // Experience group
73
+ expect(screen.getByRole('button', { name: /updates/i })).toBeInTheDocument();
74
+ expect(screen.getByRole('button', { name: /headroom/i })).toBeInTheDocument();
75
+ });
76
+
77
+ it('calls onSectionChange with correct id when a section is clicked', async () => {
78
+ const user = userEvent.setup();
79
+ render(
80
+ <SettingsNav
81
+ activeSection={null}
82
+ onSectionChange={onSectionChange}
83
+ onExitSettings={onExitSettings}
84
+ />,
85
+ );
86
+ await user.click(screen.getByRole('button', { name: /theme/i }));
87
+ expect(onSectionChange).toHaveBeenCalledWith('theme');
88
+ });
89
+
90
+ it('highlights the active section', () => {
91
+ render(
92
+ <SettingsNav
93
+ activeSection="theme"
94
+ onSectionChange={onSectionChange}
95
+ onExitSettings={onExitSettings}
96
+ />,
97
+ );
98
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
99
+ expect(themeBtn).toHaveClass('settings-nav-item-active');
100
+ });
101
+
102
+ it('toggles off the active section when clicking it again', async () => {
103
+ const user = userEvent.setup();
104
+ render(
105
+ <SettingsNav
106
+ activeSection="theme"
107
+ onSectionChange={onSectionChange}
108
+ onExitSettings={onExitSettings}
109
+ />,
110
+ );
111
+ await user.click(screen.getByRole('button', { name: /theme/i }));
112
+ expect(onSectionChange).toHaveBeenCalledWith(null); // toggles off
113
+ });
114
+
115
+ it('renders chevron on the active section', () => {
116
+ render(
117
+ <SettingsNav
118
+ activeSection="theme"
119
+ onSectionChange={onSectionChange}
120
+ onExitSettings={onExitSettings}
121
+ />,
122
+ );
123
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
124
+ // The chevron is inside the button as an SVG (aria-hidden), so we just verify the button exists
125
+ expect(themeBtn).toBeInTheDocument();
126
+ });
127
+ });
@@ -6,6 +6,9 @@
6
6
  * path to '' and rendered "(loading…)" forever. After the fix, they fetch
7
7
  * /api/memory/status and render the actual path or a "not initialised"
8
8
  * placeholder, plus an Initialise button.
9
+ *
10
+ * v6.x also adds tests for the Save Path button calling
11
+ * POST /memory/config/vault (instead of just showing a message).
9
12
  */
10
13
  import { describe, it, expect, vi, beforeEach } from 'vitest';
11
14
  import { render, screen, waitFor } from '@testing-library/react';
@@ -56,6 +59,7 @@ describe('MemoryOverview vault path', () => {
56
59
  initialized: true,
57
60
  mode: 'local-only',
58
61
  vaultRoot: '/Users/test/.bizar_memory',
62
+ projectVaultRoot: '/Users/test/.bizar_memory',
59
63
  noteCount: 5,
60
64
  gitClean: true,
61
65
  branch: 'main',
@@ -88,7 +92,7 @@ describe('MemoryOverview vault path', () => {
88
92
  it('shows "not initialised" when status reports initialized=false', async () => {
89
93
  (api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
90
94
  if (url === '/memory/status') return Promise.resolve({
91
- initialized: false, mode: null, projectId: null, vaultRoot: null,
95
+ initialized: false, mode: null, projectId: null, vaultRoot: null, projectVaultRoot: null,
92
96
  });
93
97
  if (url === '/memory/health') return Promise.resolve({
94
98
  score: 0, status: 'unconfigured', checks: [], message: 'not initialised',
@@ -185,6 +189,41 @@ describe('ConfigPanel vault path', () => {
185
189
  await user.click(screen.getByTestId('memory-init-vault'));
186
190
  expect(post).toHaveBeenCalledWith('/memory/init', expect.anything());
187
191
  });
192
+
193
+ // v6.x — Save Path now calls POST /memory/config/vault
194
+ it('calls POST /memory/config/vault when Save path is clicked (already initialised)', async () => {
195
+ const post = vi.fn().mockResolvedValue({ ok: true, vaultRoot: '/new/path/.bizar_memory' });
196
+ (api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
197
+ if (url === '/memory/config/global') return Promise.resolve({ config: { git: { remoteUrl: '' } } });
198
+ if (url === '/memory/status') return Promise.resolve({
199
+ initialized: true, vaultRoot: '/old/path/.bizar_memory', mode: 'managed',
200
+ });
201
+ if (url === '/memory/git/status') return Promise.resolve({ ok: true, clean: true, branch: 'main' });
202
+ return Promise.resolve(null);
203
+ });
204
+ (api.post as ReturnType<typeof vi.fn>) = post;
205
+
206
+ const user = userEvent.setup();
207
+ render(
208
+ <Harness>
209
+ <ConfigPanel refreshKey={0} />
210
+ </Harness>,
211
+ );
212
+
213
+ await waitFor(() => {
214
+ expect(screen.getByTestId('memory-save-vault-path')).toBeInTheDocument();
215
+ });
216
+
217
+ // Change the vault path input
218
+ const input = screen.getByTestId('memory-vault-path-input');
219
+ await user.clear(input);
220
+ await user.type(input, '/new/path/.bizar_memory');
221
+
222
+ await user.click(screen.getByTestId('memory-save-vault-path'));
223
+
224
+ // v6.x: should call /memory/config/vault (not just show a message)
225
+ expect(post).toHaveBeenCalledWith('/memory/config/vault', { vaultRoot: '/new/path/.bizar_memory' });
226
+ });
188
227
  });
189
228
 
190
229
  describe('MemorySection (settings) vault path', () => {