@polderlabs/bizar 4.9.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js +701 -0
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  6. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DD-FZrTC.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.package-lock.json +6 -0
  19. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  20. package/bizar-dash/package-lock.json +6 -0
  21. package/bizar-dash/skills/eval/SKILL.md +237 -0
  22. package/bizar-dash/src/server/api.mjs +35 -0
  23. package/bizar-dash/src/server/auth.mjs +155 -1
  24. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  25. package/bizar-dash/src/server/eval-store.mjs +226 -0
  26. package/bizar-dash/src/server/eval.mjs +347 -0
  27. package/bizar-dash/src/server/memory-store.mjs +46 -0
  28. package/bizar-dash/src/server/ocr.mjs +55 -0
  29. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  30. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  31. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  32. package/bizar-dash/src/server/providers-store.mjs +11 -4
  33. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  34. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  35. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  36. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  37. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  39. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  40. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  41. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  42. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  43. package/bizar-dash/src/server/routes/users.mjs +84 -0
  44. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  45. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  46. package/bizar-dash/src/server/serve-info.mjs +172 -0
  47. package/bizar-dash/src/server/voice-store.mjs +202 -0
  48. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  49. package/bizar-dash/src/server/workspaces.mjs +626 -0
  50. package/bizar-dash/src/web/App.tsx +48 -11
  51. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  52. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  53. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  54. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  55. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  56. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  57. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  58. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  59. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  60. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  61. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  62. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  63. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  64. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  65. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  66. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  67. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  68. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  69. package/bizar-dash/src/web/lib/types.ts +134 -0
  70. package/bizar-dash/src/web/styles/chat.css +8 -5
  71. package/bizar-dash/src/web/styles/main.css +77 -2
  72. package/bizar-dash/src/web/styles/memory.css +82 -0
  73. package/bizar-dash/src/web/styles/settings.css +265 -0
  74. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  75. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  76. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  77. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  78. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  79. package/bizar-dash/src/web/views/Settings.tsx +78 -45
  80. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  82. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  83. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  84. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  85. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  86. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  87. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  88. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  89. package/bizar-dash/tests/autosave.test.tsx +276 -0
  90. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  91. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  92. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  93. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  94. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  95. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  96. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  97. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  98. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  99. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  100. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  101. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  102. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  103. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  104. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  105. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  106. package/bizar-dash/tests/ocr.test.mjs +87 -0
  107. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  108. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  109. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  110. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  111. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  112. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  113. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  114. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  115. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  116. package/bizar-dash/tests/users.test.mjs +108 -0
  117. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  118. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  119. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  120. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  121. package/cli/bin.mjs +72 -2
  122. package/cli/commands/clip.mjs +146 -0
  123. package/cli/commands/deploy/cloudflare.mjs +250 -0
  124. package/cli/commands/deploy/docker.mjs +221 -0
  125. package/cli/commands/deploy/fly.mjs +161 -0
  126. package/cli/commands/deploy/vercel.mjs +225 -0
  127. package/cli/commands/deploy.mjs +240 -0
  128. package/cli/commands/eval.mjs +378 -0
  129. package/cli/commands/marketplace.mjs +64 -0
  130. package/cli/commands/minimax.mjs +5 -2
  131. package/cli/commands/ocr.mjs +165 -0
  132. package/cli/commands/plugin.mjs +358 -0
  133. package/cli/commands/voice.mjs +211 -0
  134. package/cli/commands/workspace.mjs +247 -0
  135. package/cli/tests/minimax-cli.test.mjs +79 -0
  136. package/config/agents/frigg.md +1 -1
  137. package/config/agents/heimdall.md +1 -1
  138. package/config/agents/mimir.md +1 -1
  139. package/config/agents/quick.md +1 -1
  140. package/config/agents/semble-search.md +1 -1
  141. package/config/agents/vor.md +1 -1
  142. package/config/opencode.json +28 -21
  143. package/config/opencode.json.template +8 -1
  144. package/package.json +12 -8
  145. package/plugins/bizar/index.ts +77 -0
  146. package/plugins/bizar/src/compaction.d.mts +48 -0
  147. package/plugins/bizar/src/compaction.mjs +192 -0
  148. package/plugins/bizar/tests/compaction.test.ts +264 -0
  149. package/templates/deploy/cloudflare/README.md +32 -0
  150. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  151. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  152. package/templates/deploy/docker/.env.template +16 -0
  153. package/templates/deploy/docker/README.md +58 -0
  154. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  155. package/templates/deploy/fly/README.md +35 -0
  156. package/templates/deploy/fly/fly.toml.template +28 -0
  157. package/templates/deploy/vercel/README.md +29 -0
  158. package/templates/deploy/vercel/api-index.template.js +18 -0
  159. package/templates/deploy/vercel/vercel.json.template +16 -0
  160. package/templates/eval-fixtures/README.md +58 -0
  161. package/templates/eval-fixtures/code-search-basic.json +28 -0
  162. package/templates/eval-fixtures/latency-bounds.json +16 -0
  163. package/templates/eval-fixtures/regression-suite.json +79 -0
  164. package/templates/eval-fixtures/response-format.json +30 -0
  165. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  166. package/templates/plugin-template/README.md +121 -0
  167. package/templates/plugin-template/index.js +66 -0
  168. package/templates/plugin-template/plugin.json +42 -0
  169. package/templates/plugin-template/tests/plugin.test.js +83 -0
  170. package/templates/schedules/daily-backup.json +12 -0
  171. package/templates/schedules/daily-cleanup.json +12 -0
  172. package/templates/schedules/hourly-health-check.json +12 -0
  173. package/templates/schedules/webhook-on-push.json +13 -0
  174. package/templates/schedules/weekly-digest.json +13 -0
  175. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  176. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  177. package/bizar-dash/dist/assets/main-DfmIfOUS.css +0 -1
  178. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  179. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  180. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
  181. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  182. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  183. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -0,0 +1,144 @@
1
+ /**
2
+ * bizar-dash/tests/schedules-templates.test.mjs
3
+ *
4
+ * Verify schedule template loading and from-template endpoint.
5
+ */
6
+ import { describe, it } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
13
+ const PROJECT_ROOT = join(__dirname, '..', '..');
14
+ const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', 'schedules');
15
+
16
+ describe('Schedule templates', () => {
17
+ it('templates/schedules directory exists', () => {
18
+ assert.ok(existsSync(TEMPLATES_DIR), 'Missing templates/schedules directory');
19
+ });
20
+
21
+ it('at least 4 template files exist', () => {
22
+ const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'));
23
+ assert.ok(files.length >= 4, `Expected >=4 templates, found ${files.length}: ${files.join(', ')}`);
24
+ });
25
+
26
+ it('each template is valid JSON with required fields', () => {
27
+ const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'));
28
+ for (const file of files) {
29
+ const content = readFileSync(join(TEMPLATES_DIR, file), 'utf8');
30
+ const template = JSON.parse(content);
31
+ assert.ok(template.id, `${file}: missing id`);
32
+ assert.ok(template.name, `${file}: missing name`);
33
+ assert.ok(template.description, `${file}: missing description`);
34
+ assert.ok(template.type, `${file}: missing type`);
35
+ assert.ok(template.action, `${file}: missing action`);
36
+ assert.strictEqual(template.source, undefined, `${file}: should NOT have source field (added at load time)`);
37
+ }
38
+ });
39
+
40
+ it('daily-backup.json is a valid cron schedule template', () => {
41
+ const content = readFileSync(join(TEMPLATES_DIR, 'daily-backup.json'), 'utf8');
42
+ const t = JSON.parse(content);
43
+ assert.strictEqual(t.type, 'cron');
44
+ assert.strictEqual(t.schedule, '0 3 * * *');
45
+ assert.strictEqual(t.action.type, 'command');
46
+ assert.ok(t.action.target.includes('bizar backup'));
47
+ });
48
+
49
+ it('weekly-digest.json is a valid agent template', () => {
50
+ const content = readFileSync(join(TEMPLATES_DIR, 'weekly-digest.json'), 'utf8');
51
+ const t = JSON.parse(content);
52
+ assert.strictEqual(t.type, 'cron');
53
+ assert.strictEqual(t.action.type, 'agent');
54
+ assert.ok(t.action.prompt);
55
+ });
56
+
57
+ it('hourly-health-check.json is a valid interval template', () => {
58
+ const content = readFileSync(join(TEMPLATES_DIR, 'hourly-health-check.json'), 'utf8');
59
+ const t = JSON.parse(content);
60
+ assert.strictEqual(t.type, 'interval');
61
+ assert.strictEqual(t.action.type, 'command');
62
+ });
63
+
64
+ it('webhook-on-push.json has webhook action', () => {
65
+ const content = readFileSync(join(TEMPLATES_DIR, 'webhook-on-push.json'), 'utf8');
66
+ const t = JSON.parse(content);
67
+ assert.strictEqual(t.action.type, 'webhook');
68
+ assert.ok(t.action.target);
69
+ });
70
+ });
71
+
72
+ describe('loadTemplates (unit)', () => {
73
+ // Re-implement loadTemplates here to test in isolation
74
+ function loadTemplates() {
75
+ if (!existsSync(TEMPLATES_DIR)) return [];
76
+ return readdirSync(TEMPLATES_DIR)
77
+ .filter((f) => f.endsWith('.json'))
78
+ .map((f) => {
79
+ const content = JSON.parse(readFileSync(join(TEMPLATES_DIR, f), 'utf8'));
80
+ return { ...content, source: 'template', templateFile: f };
81
+ });
82
+ }
83
+
84
+ it('returns array with source=template and templateFile set', () => {
85
+ const templates = loadTemplates();
86
+ assert.ok(Array.isArray(templates));
87
+ assert.ok(templates.length > 0);
88
+ for (const t of templates) {
89
+ assert.strictEqual(t.source, 'template', 'each template should have source=template');
90
+ assert.ok(t.templateFile.endsWith('.json'), 'templateFile should be a .json file');
91
+ }
92
+ });
93
+
94
+ it('missing templates dir returns empty array', () => {
95
+ // Use a path that definitely doesn't exist
96
+ const fakeDir = join(TEMPLATES_DIR, '..', 'schedules-does-not-exist');
97
+ function loadFromFake() {
98
+ if (!existsSync(fakeDir)) return [];
99
+ return readdirSync(fakeDir).filter((f) => f.endsWith('.json'));
100
+ }
101
+ const result = loadFromFake();
102
+ assert.deepStrictEqual(result, []);
103
+ });
104
+ });
105
+
106
+ describe('from-template endpoint contract', () => {
107
+ it('template payload can be used to create a valid schedule payload', () => {
108
+ const content = readFileSync(join(TEMPLATES_DIR, 'daily-backup.json'), 'utf8');
109
+ const template = JSON.parse(content);
110
+
111
+ // Simulate what the route does
112
+ const schedulePayload = {
113
+ name: template.name,
114
+ type: template.type,
115
+ schedule: template.schedule,
116
+ timezone: template.timezone || 'UTC',
117
+ action: template.action,
118
+ enabled: true,
119
+ };
120
+
121
+ assert.ok(schedulePayload.name);
122
+ assert.ok(['cron', 'interval', 'once'].includes(schedulePayload.type));
123
+ assert.ok(schedulePayload.schedule);
124
+ assert.ok(schedulePayload.action);
125
+ assert.strictEqual(schedulePayload.enabled, true);
126
+ });
127
+
128
+ it('custom name can override template name', () => {
129
+ const content = readFileSync(join(TEMPLATES_DIR, 'daily-backup.json'), 'utf8');
130
+ const template = JSON.parse(content);
131
+ const customName = 'My Custom Backup';
132
+
133
+ const schedulePayload = {
134
+ name: customName || template.name,
135
+ type: template.type,
136
+ schedule: template.schedule,
137
+ timezone: template.timezone || 'UTC',
138
+ action: template.action,
139
+ enabled: true,
140
+ };
141
+
142
+ assert.strictEqual(schedulePayload.name, customName);
143
+ });
144
+ });
@@ -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: /back/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: /memory/i }));
113
+ expect(onSettingsSectionChange).toHaveBeenCalledWith('memory');
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
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,126 @@
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: /back/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
+ expect(screen.getByText('Data', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
56
+ });
57
+
58
+ it('renders section items within each group', () => {
59
+ render(
60
+ <SettingsNav
61
+ activeSection={null}
62
+ onSectionChange={onSectionChange}
63
+ onExitSettings={onExitSettings}
64
+ />,
65
+ );
66
+ expect(screen.getByRole('button', { name: /theme/i })).toBeInTheDocument();
67
+ expect(screen.getByRole('button', { name: /env vars/i })).toBeInTheDocument();
68
+ expect(screen.getByRole('button', { name: /memory/i })).toBeInTheDocument();
69
+ expect(screen.getByRole('button', { name: /system llm/i })).toBeInTheDocument();
70
+ expect(screen.getByRole('button', { name: /updates/i })).toBeInTheDocument();
71
+ expect(screen.getByRole('button', { name: /skills/i })).toBeInTheDocument();
72
+ expect(screen.getByRole('button', { name: /headroom/i })).toBeInTheDocument();
73
+ expect(screen.getByRole('button', { name: /backup/i })).toBeInTheDocument();
74
+ });
75
+
76
+ it('calls onSectionChange with correct id when a section is clicked', async () => {
77
+ const user = userEvent.setup();
78
+ render(
79
+ <SettingsNav
80
+ activeSection={null}
81
+ onSectionChange={onSectionChange}
82
+ onExitSettings={onExitSettings}
83
+ />,
84
+ );
85
+ await user.click(screen.getByRole('button', { name: /theme/i }));
86
+ expect(onSectionChange).toHaveBeenCalledWith('theme');
87
+ });
88
+
89
+ it('highlights the active section', () => {
90
+ render(
91
+ <SettingsNav
92
+ activeSection="theme"
93
+ onSectionChange={onSectionChange}
94
+ onExitSettings={onExitSettings}
95
+ />,
96
+ );
97
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
98
+ expect(themeBtn).toHaveClass('settings-nav-item-active');
99
+ });
100
+
101
+ it('toggles off the active section when clicking it again', async () => {
102
+ const user = userEvent.setup();
103
+ render(
104
+ <SettingsNav
105
+ activeSection="theme"
106
+ onSectionChange={onSectionChange}
107
+ onExitSettings={onExitSettings}
108
+ />,
109
+ );
110
+ await user.click(screen.getByRole('button', { name: /theme/i }));
111
+ expect(onSectionChange).toHaveBeenCalledWith(null); // toggles off
112
+ });
113
+
114
+ it('renders chevron on the active section', () => {
115
+ render(
116
+ <SettingsNav
117
+ activeSection="theme"
118
+ onSectionChange={onSectionChange}
119
+ onExitSettings={onExitSettings}
120
+ />,
121
+ );
122
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
123
+ // The chevron is inside the button as an SVG (aria-hidden), so we just verify the button exists
124
+ expect(themeBtn).toBeInTheDocument();
125
+ });
126
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * tests/users.test.mjs
3
+ *
4
+ * v5.0.0 — Tests for the users route handlers.
5
+ */
6
+ import { describe, it, beforeEach, afterEach, after } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import {
11
+ mkdirSync,
12
+ rmSync,
13
+ } from 'node:fs';
14
+
15
+ // These are tested via the REST API in workspaces.test.mjs (user-related functions)
16
+ // This file tests the users store functions directly.
17
+
18
+ const TEST_ID = `bizar-user-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
19
+ const TEST_HOME = join(tmpdir(), TEST_ID);
20
+ const BIZAR_LOCAL = join(TEST_HOME, '.local', 'share', 'bizar');
21
+ const WORKSPACES_ROOT = join(BIZAR_LOCAL, 'workspaces');
22
+
23
+ const originalHomedir = process.env.HOME;
24
+ process.env.HOME = TEST_HOME;
25
+
26
+ describe('users', () => {
27
+ let cleanupDirs = [];
28
+
29
+ beforeEach(() => {
30
+ mkdirSync(BIZAR_LOCAL, { recursive: true });
31
+ cleanupDirs.push(TEST_HOME);
32
+ });
33
+
34
+ afterEach(() => {
35
+ for (const dir of cleanupDirs) {
36
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
37
+ }
38
+ cleanupDirs = [];
39
+ });
40
+
41
+ after(() => {
42
+ process.env.HOME = originalHomedir;
43
+ });
44
+
45
+ // Users are managed through the workspaces module (index.json users array)
46
+ // This test verifies user CRUD through workspaces
47
+
48
+ describe('user CRUD via workspaces module', () => {
49
+ it('getOrCreateUser creates user and persists to index', async () => {
50
+ const ws = await import('../src/server/workspaces.mjs');
51
+ const { user, created } = await ws.getOrCreateUser('test@example.com', 'Test User');
52
+
53
+ assert.ok(created);
54
+ assert.ok(user.id.startsWith('usr_'));
55
+ assert.strictEqual(user.email, 'test@example.com');
56
+ assert.strictEqual(user.name, 'Test User');
57
+
58
+ // Verify persisted
59
+ const found = await ws.getUser(user.id);
60
+ assert.ok(found);
61
+ assert.strictEqual(found.email, 'test@example.com');
62
+ });
63
+
64
+ it('getUser returns user by id', async () => {
65
+ const ws = await import('../src/server/workspaces.mjs');
66
+ const { user } = await ws.getOrCreateUser('findme@example.com', 'Find Me');
67
+
68
+ const found = await ws.getUser(user.id);
69
+ assert.ok(found);
70
+ assert.strictEqual(found.id, user.id);
71
+ assert.strictEqual(found.name, 'Find Me');
72
+ });
73
+
74
+ it('getUser returns null for non-existent user', async () => {
75
+ const ws = await import('../src/server/workspaces.mjs');
76
+ const found = await ws.getUser('usr_nonexistent');
77
+ assert.strictEqual(found, null);
78
+ });
79
+
80
+ it('updateUser updates name and email', async () => {
81
+ const ws = await import('../src/server/workspaces.mjs');
82
+ const { user } = await ws.getOrCreateUser('original@example.com', 'Original Name');
83
+
84
+ const updated = await ws.updateUser(user.id, {
85
+ name: 'Updated Name',
86
+ email: 'updated@example.com',
87
+ });
88
+
89
+ assert.strictEqual(updated.name, 'Updated Name');
90
+ assert.strictEqual(updated.email, 'updated@example.com');
91
+
92
+ // Verify persisted
93
+ const found = await ws.getUser(user.id);
94
+ assert.strictEqual(found.name, 'Updated Name');
95
+ assert.strictEqual(found.email, 'updated@example.com');
96
+ });
97
+
98
+ it('email matching is case-insensitive', async () => {
99
+ const ws = await import('../src/server/workspaces.mjs');
100
+ const { user } = await ws.getOrCreateUser('case@test.com', 'Case Test');
101
+
102
+ // Try to get or create with different case
103
+ const found = await ws.getOrCreateUser('CASE@TEST.COM', 'Different Case');
104
+ assert.strictEqual(found.created, false);
105
+ assert.strictEqual(found.user.id, user.id);
106
+ });
107
+ });
108
+ });