@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,140 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { ChatComposer } from '../src/web/components/chat/ChatComposer';
5
+
6
+ // Mock the enhancePrompt API
7
+ vi.mock('../src/web/lib/api', () => ({
8
+ enhancePrompt: vi.fn().mockResolvedValue('enhanced prompt'),
9
+ }));
10
+
11
+ // Mock lucide-react — use importOriginal to preserve all real icons,
12
+ // then override only the ones we need to stub
13
+ vi.mock('lucide-react', async (importOriginal) => {
14
+ const actual = await importOriginal() as Record<string, unknown>;
15
+ return {
16
+ ...actual,
17
+ Send: () => <svg data-testid="send-icon" />,
18
+ Paperclip: () => <svg data-testid="paperclip-icon" />,
19
+ X: () => <svg data-testid="x-icon" />,
20
+ Sparkles: () => <svg data-testid="sparkles-icon" />,
21
+ Bot: () => <svg data-testid="bot-icon" />,
22
+ ChevronDown: () => <svg data-testid="chevron-down-icon" />,
23
+ };
24
+ });
25
+
26
+ function makeProps() {
27
+ return {
28
+ agent: 'test-agent',
29
+ setAgent: vi.fn(),
30
+ model: 'test-model',
31
+ setModel: vi.fn(),
32
+ text: '',
33
+ setText: vi.fn(),
34
+ sending: false,
35
+ onSend: vi.fn(),
36
+ attachments: [] as string[],
37
+ setAttachments: vi.fn(),
38
+ suggestions: [] as Array<{ cmd: string; desc: string }>,
39
+ onPickSuggestion: vi.fn(),
40
+ agents: [{ name: 'test-agent' }],
41
+ onAttach: vi.fn(),
42
+ };
43
+ }
44
+
45
+ describe('ChatComposer sizing', () => {
46
+ it('renders a textarea inside the composer', () => {
47
+ render(<ChatComposer {...makeProps()} />);
48
+ const textarea = screen.getByRole('textbox');
49
+ expect(textarea).toBeInTheDocument();
50
+ });
51
+
52
+ it('composer pill exists with the correct class', () => {
53
+ const { container } = render(<ChatComposer {...makeProps()} />);
54
+ const pill = container.querySelector('.chat-composer-pill');
55
+ expect(pill).not.toBeNull();
56
+ });
57
+
58
+ it('send button exists with correct class', () => {
59
+ const { container } = render(<ChatComposer {...makeProps()} />);
60
+ const sendBtn = container.querySelector('.chat-send-btn');
61
+ expect(sendBtn).not.toBeNull();
62
+ });
63
+
64
+ it('textarea starts with rows=1 for compact single-line appearance', () => {
65
+ render(<ChatComposer {...makeProps()} />);
66
+ const textarea = screen.getByRole('textbox');
67
+ expect(textarea.getAttribute('rows')).toBe('1');
68
+ });
69
+ });
70
+
71
+ describe('ChatComposer auto-grow', () => {
72
+ it('setText is called on each keystroke (controlled component)', async () => {
73
+ const user = userEvent.setup();
74
+ const props = makeProps();
75
+ render(<ChatComposer {...props} />);
76
+ const textarea = screen.getByRole('textbox');
77
+ await user.click(textarea);
78
+ // user.type fires input events which trigger onChange → setText
79
+ await user.type(textarea, 'hello');
80
+ // setText is called for each character in a controlled component
81
+ expect(props.setText.mock.calls.length).toBeGreaterThanOrEqual(5);
82
+ });
83
+
84
+ it('empty state shows send message placeholder', () => {
85
+ render(<ChatComposer {...makeProps()} />);
86
+ const textarea = screen.getByRole('textbox');
87
+ expect(textarea).toHaveAttribute('placeholder', 'Send a message…');
88
+ });
89
+
90
+ it('sending state shows sending placeholder', () => {
91
+ const props = makeProps();
92
+ props.sending = true;
93
+ render(<ChatComposer {...props} />);
94
+ const textarea = screen.getByRole('textbox');
95
+ expect(textarea).toHaveAttribute('placeholder', 'Sending…');
96
+ });
97
+ });
98
+
99
+ describe('ChatComposer interactions', () => {
100
+ it('calls onSend when send button is clicked', async () => {
101
+ const user = userEvent.setup();
102
+ const props = makeProps();
103
+ const { rerender } = render(<ChatComposer {...props} />);
104
+ // Simulate typing: update the text prop as a real state would
105
+ props.text = 'hello world';
106
+ rerender(<ChatComposer {...props} />);
107
+ const sendBtn = document.querySelector('.chat-send-btn') as HTMLButtonElement;
108
+ await user.click(sendBtn);
109
+ expect(props.onSend).toHaveBeenCalled();
110
+ });
111
+
112
+ it('does not call onSend when textarea is empty', async () => {
113
+ const user = userEvent.setup();
114
+ const props = makeProps();
115
+ render(<ChatComposer {...props} />);
116
+ const sendBtn = document.querySelector('.chat-send-btn') as HTMLButtonElement;
117
+ await user.click(sendBtn);
118
+ expect(props.onSend).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('setText is called on input change', async () => {
122
+ const user = userEvent.setup();
123
+ const props = makeProps();
124
+ render(<ChatComposer {...props} />);
125
+ const textarea = screen.getByRole('textbox');
126
+ await user.click(textarea);
127
+ await user.keyboard('typed text');
128
+ // For controlled components, setText is called per-keystroke.
129
+ // Verify it was called multiple times (once per character)
130
+ expect(props.setText.mock.calls.length).toBeGreaterThanOrEqual(10);
131
+ });
132
+
133
+ it('textarea is disabled while sending', () => {
134
+ const props = makeProps();
135
+ props.sending = true;
136
+ render(<ChatComposer {...props} />);
137
+ const textarea = screen.getByRole('textbox');
138
+ expect(textarea).toBeDisabled();
139
+ });
140
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * tests/clipboard.test.mjs
3
+ *
4
+ * v5.0.0 — Tests for the clipboard route save-format logic.
5
+ *
6
+ * These tests verify the note file format (frontmatter, body) produced
7
+ * by the clipboard/save endpoint. We test via a lightweight HTTP server
8
+ * using a temp project root.
9
+ */
10
+
11
+ import { describe, it, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
16
+ import express from 'express';
17
+
18
+ const CLIPBOARD_ROUTES = await import('../src/server/routes/clipboard.mjs');
19
+
20
+ describe('clipboard note format', () => {
21
+ let projectRoot;
22
+ let server;
23
+ let baseUrl;
24
+
25
+ beforeEach(async () => {
26
+ projectRoot = join(tmpdir(), `bizar-clip-test2-${Date.now()}-${Math.random().toString(36).slice(2)}`);
27
+ mkdirSync(projectRoot, { recursive: true });
28
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
29
+
30
+ const router = await CLIPBOARD_ROUTES.createClipboardRouter({ projectRoot });
31
+ const app = express();
32
+ app.use(express.json({ limit: '5mb' }));
33
+ app.use('/api', router);
34
+
35
+ await new Promise((resolve) => {
36
+ server = app.listen(0, () => {
37
+ const addr = server.address();
38
+ baseUrl = `http://127.0.0.1:${addr.port}`;
39
+ resolve();
40
+ });
41
+ });
42
+ });
43
+
44
+ afterEach(() => {
45
+ try { server?.close?.(); } catch { /* ignore */ }
46
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
47
+ });
48
+
49
+ it('saves a note with correct YAML frontmatter', async () => {
50
+ const r = await fetch(`${baseUrl}/api/clipboard/save`, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({
54
+ url: 'https://example.com/test',
55
+ title: 'Test Page Title',
56
+ content: '<html>test content</html>',
57
+ selection: 'selected text',
58
+ savedAt: '2026-07-05T12:00:00.000Z',
59
+ }),
60
+ });
61
+ assert.strictEqual(r.status, 201);
62
+ const data = await r.json();
63
+ assert.ok(data.ok);
64
+ assert.ok(data.notePath);
65
+ assert.ok(data.notePath.startsWith('clips/'));
66
+ assert.ok(data.notePath.endsWith('.md'));
67
+
68
+ // Read the file
69
+ const noteFile = join(projectRoot, '.obsidian', data.notePath);
70
+ assert.ok(existsSync(noteFile));
71
+ const content = readFileSync(noteFile, 'utf8');
72
+
73
+ // Check frontmatter
74
+ assert.ok(content.startsWith('---'));
75
+ assert.ok(content.includes('title: "Test Page Title"'));
76
+ assert.ok(content.includes('url: "https://example.com/test"'));
77
+ assert.ok(content.includes('type: "webclip"'));
78
+ assert.ok(content.includes('savedAt: "2026-07-05T12:00:00.000Z"'));
79
+
80
+ // Check body contains selection and content
81
+ assert.ok(content.includes('selected text'));
82
+ assert.ok(content.includes('<html>test content</html>'));
83
+
84
+ // Check tags
85
+ assert.ok(content.includes('"webclip"'));
86
+ });
87
+
88
+ it('returns 400 when url and content are both missing', async () => {
89
+ const r = await fetch(`${baseUrl}/api/clipboard/save`, {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json' },
92
+ body: JSON.stringify({}),
93
+ });
94
+ assert.strictEqual(r.status, 400);
95
+ const data = await r.json();
96
+ assert.strictEqual(data.error, 'bad_request');
97
+ });
98
+
99
+ it('GET /api/clipboard/list returns clips array', async () => {
100
+ // Save a clip first
101
+ await fetch(`${baseUrl}/api/clipboard/save`, {
102
+ method: 'POST',
103
+ headers: { 'content-type': 'application/json' },
104
+ body: JSON.stringify({
105
+ url: 'https://example.com/list-test',
106
+ title: 'List Test',
107
+ content: 'list content',
108
+ }),
109
+ });
110
+
111
+ const r = await fetch(`${baseUrl}/api/clipboard/list`);
112
+ assert.strictEqual(r.status, 200);
113
+ const data = await r.json();
114
+ assert.ok(Array.isArray(data.clips));
115
+ assert.strictEqual(data.clips.length, 1);
116
+ assert.ok(data.clips[0].id);
117
+ assert.strictEqual(data.clips[0].title, 'List Test');
118
+ });
119
+
120
+ it('DELETE /api/clipboard/:id removes clip from log', async () => {
121
+ // Save
122
+ const saveRes = await fetch(`${baseUrl}/api/clipboard/save`, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({
126
+ url: 'https://example.com/delete-test',
127
+ title: 'Delete Test',
128
+ content: 'delete content',
129
+ }),
130
+ });
131
+ const { id } = await saveRes.json();
132
+
133
+ // Delete
134
+ const delRes = await fetch(`${baseUrl}/api/clipboard/${id}`, { method: 'DELETE' });
135
+ assert.strictEqual(delRes.status, 200);
136
+
137
+ // List should be empty
138
+ const listRes = await fetch(`${baseUrl}/api/clipboard/list`);
139
+ const { clips } = await listRes.json();
140
+ assert.strictEqual(clips.length, 0);
141
+ });
142
+
143
+ it('DELETE /api/clipboard/:id returns 404 for unknown id', async () => {
144
+ const r = await fetch(`${baseUrl}/api/clipboard/nonexistent`, { method: 'DELETE' });
145
+ assert.strictEqual(r.status, 404);
146
+ });
147
+ });
@@ -0,0 +1,105 @@
1
+ // tests/components/doctor-panel.test.tsx — v6.0.0
2
+ //
3
+ // Tests for the reusable DoctorPanel component. Verifies:
4
+ // - Renders the category title and check rows with status pills
5
+ // - Empty state when no checks / info / children
6
+ // - info list renders below checks
7
+ // - children bypasses the default check-list render
8
+
9
+ import { render, screen } from '@testing-library/react';
10
+ import { describe, it, expect } from 'vitest';
11
+ import { DoctorPanel } from '../../src/web/components/DoctorPanel';
12
+ import type { DoctorCheck } from '../../src/web/lib/types';
13
+
14
+ const checks: DoctorCheck[] = [
15
+ { name: 'node', status: 'ok', message: 'v22.16.0 on linux/x64' },
16
+ { name: 'memory', status: 'warn', message: 'rss 612.4 MB' },
17
+ { name: 'opencode', status: 'fail', message: 'serve-info missing' },
18
+ ];
19
+
20
+ describe('DoctorPanel', () => {
21
+ it('renders the category title from defaults', () => {
22
+ render(<DoctorPanel category="system" checks={checks} />);
23
+ expect(screen.getByText('System health')).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders one row per check, with name + message', () => {
27
+ render(<DoctorPanel category="services" checks={checks} />);
28
+ expect(screen.getByText('node')).toBeInTheDocument();
29
+ expect(screen.getByText('memory')).toBeInTheDocument();
30
+ expect(screen.getByText('opencode')).toBeInTheDocument();
31
+ expect(screen.getByText('v22.16.0 on linux/x64')).toBeInTheDocument();
32
+ expect(screen.getByText('rss 612.4 MB')).toBeInTheDocument();
33
+ expect(screen.getByText('serve-info missing')).toBeInTheDocument();
34
+ });
35
+
36
+ it('renders the status pill text per row', () => {
37
+ render(<DoctorPanel category="services" checks={checks} />);
38
+ expect(screen.getAllByText('ok').length).toBeGreaterThanOrEqual(1);
39
+ expect(screen.getAllByText('warn').length).toBeGreaterThanOrEqual(1);
40
+ expect(screen.getAllByText('fail').length).toBeGreaterThanOrEqual(1);
41
+ });
42
+
43
+ it('shows the empty state when no checks / info / children are passed', () => {
44
+ render(<DoctorPanel category="errors" checks={[]} />);
45
+ expect(screen.getByText('No recent errors')).toBeInTheDocument();
46
+ });
47
+
48
+ it('renders the info list when info rows are supplied', () => {
49
+ render(
50
+ <DoctorPanel
51
+ category="counts"
52
+ checks={[]}
53
+ info={[
54
+ { label: 'Tasks', value: 42 },
55
+ { label: 'Mods', value: 5 },
56
+ ]}
57
+ />,
58
+ );
59
+ expect(screen.getByText('Tasks')).toBeInTheDocument();
60
+ expect(screen.getByText('Mods')).toBeInTheDocument();
61
+ expect(screen.getByText('42')).toBeInTheDocument();
62
+ expect(screen.getByText('5')).toBeInTheDocument();
63
+ });
64
+
65
+ it('renders children when provided, bypassing the default check-list', () => {
66
+ render(
67
+ <DoctorPanel category="actions" checks={[]}>
68
+ <div data-testid="custom-child">Run Check</div>
69
+ </DoctorPanel>,
70
+ );
71
+ expect(screen.getByTestId('custom-child')).toBeInTheDocument();
72
+ expect(screen.getByText('Actions')).toBeInTheDocument();
73
+ });
74
+
75
+ it('honors an explicit title override', () => {
76
+ render(
77
+ <DoctorPanel
78
+ category="system"
79
+ title="My custom title"
80
+ checks={[]}
81
+ />,
82
+ );
83
+ expect(screen.getByText('My custom title')).toBeInTheDocument();
84
+ expect(screen.queryByText('System health')).not.toBeInTheDocument();
85
+ });
86
+
87
+ it('renders the meta string when supplied', () => {
88
+ render(
89
+ <DoctorPanel
90
+ category="system"
91
+ checks={[]}
92
+ meta="linux/x64"
93
+ />,
94
+ );
95
+ expect(screen.getByText('linux/x64')).toBeInTheDocument();
96
+ });
97
+
98
+ it('applies the category-specific class to the card', () => {
99
+ const { container } = render(
100
+ <DoctorPanel category="services" checks={checks} />,
101
+ );
102
+ const card = container.querySelector('.doctor-panel-services');
103
+ expect(card).toBeInTheDocument();
104
+ });
105
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * tests/components/screenshot-ocr.test.tsx
3
+ *
4
+ * v5.0.0 — Component tests for ScreenshotCapture and ScreenshotOCR.
5
+ *
6
+ * These tests verify that the components render correctly and handle
7
+ * their states (capturing, processing, results).
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import { render, screen } from '@testing-library/react';
12
+ import { ScreenshotCapture } from '../../src/web/components/ScreenshotCapture';
13
+ import { ScreenshotOCR } from '../../src/web/components/ScreenshotOCR';
14
+
15
+ // Mock the Toast component
16
+ vi.mock('../../src/web/components/Toast', () => ({
17
+ useToast: () => ({
18
+ info: vi.fn(),
19
+ success: vi.fn(),
20
+ error: vi.fn(),
21
+ warning: vi.fn(),
22
+ }),
23
+ ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
24
+ }));
25
+
26
+ // Mock the api module
27
+ vi.mock('../../src/web/lib/api', () => ({
28
+ api: {
29
+ post: vi.fn().mockResolvedValue({ ok: true, text: 'test text', notePath: 'ocr/test.md' }),
30
+ },
31
+ }));
32
+
33
+ // Mock getDisplayMedia
34
+ beforeEach(() => {
35
+ Object.defineProperty(navigator, 'mediaDevices', {
36
+ value: {
37
+ getDisplayMedia: vi.fn().mockRejectedValue(new DOMException('', 'NotAllowedError')),
38
+ },
39
+ writable: true,
40
+ configurable: true,
41
+ });
42
+ });
43
+
44
+ describe('ScreenshotCapture', () => {
45
+ it('renders the capture button', () => {
46
+ render(<ScreenshotCapture onTextExtracted={vi.fn()} />);
47
+ expect(screen.getByText('Capture Screen')).toBeDefined();
48
+ });
49
+
50
+ it('renders without crashing when no onTextExtracted provided', () => {
51
+ render(<ScreenshotCapture />);
52
+ expect(screen.getByText('Capture Screen')).toBeDefined();
53
+ });
54
+
55
+ it('shows loading state when capturing', async () => {
56
+ // getDisplayMedia won't resolve, so it will show "Selecting…"
57
+ render(<ScreenshotCapture onTextExtracted={vi.fn()} />);
58
+ const btn = screen.getByText('Capture Screen');
59
+ // Not clicking — just verify initial state renders
60
+ expect(btn).toBeDefined();
61
+ });
62
+ });
63
+
64
+ describe('ScreenshotOCR', () => {
65
+ it('renders the combined panel', () => {
66
+ render(<ScreenshotOCR />);
67
+ expect(screen.getByText('Capture Screen')).toBeDefined();
68
+ expect(screen.getByText(/capture your screen/i)).toBeDefined();
69
+ });
70
+
71
+ it('renders without crashing', () => {
72
+ const { container } = render(<ScreenshotOCR />);
73
+ expect(container).toBeDefined();
74
+ });
75
+ });
@@ -0,0 +1,73 @@
1
+ import { render, screen, waitFor, act } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import React from 'react';
5
+ import { WorkspaceSelector } from '../../src/web/components/WorkspaceSelector';
6
+
7
+ // Mock the api module
8
+ vi.mock('../../src/web/lib/api', () => ({
9
+ api: {
10
+ get: vi.fn().mockResolvedValue({ workspaces: [] }),
11
+ },
12
+ }));
13
+
14
+ describe('WorkspaceSelector', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it('renders with default text when no workspace is selected', () => {
20
+ render(
21
+ <WorkspaceSelector
22
+ currentWorkspaceId={null}
23
+ onWorkspaceChange={vi.fn()}
24
+ />,
25
+ );
26
+ expect(screen.getByText('Workspace')).toBeInTheDocument();
27
+ });
28
+
29
+ it('opens dropdown on click', async () => {
30
+ const user = userEvent.setup();
31
+ render(
32
+ <WorkspaceSelector
33
+ currentWorkspaceId={null}
34
+ onWorkspaceChange={vi.fn()}
35
+ />,
36
+ );
37
+ await user.click(screen.getByRole('button'));
38
+ expect(screen.getByRole('menu')).toBeInTheDocument();
39
+ });
40
+
41
+ it('shows empty state in dropdown when no workspaces', async () => {
42
+ const user = userEvent.setup();
43
+ render(
44
+ <WorkspaceSelector
45
+ currentWorkspaceId={null}
46
+ onWorkspaceChange={vi.fn()}
47
+ />,
48
+ );
49
+ await user.click(screen.getByRole('button'));
50
+ expect(screen.getByText('No workspaces')).toBeInTheDocument();
51
+ });
52
+
53
+ it('renders workspace name when provided and API returns data', async () => {
54
+ const { api } = await import('../../src/web/lib/api');
55
+ (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
56
+ workspaces: [
57
+ { workspace: { id: 'ws_abc', name: 'Engineering' }, role: 'admin' },
58
+ ],
59
+ });
60
+
61
+ render(
62
+ <WorkspaceSelector
63
+ currentWorkspaceId="ws_abc"
64
+ onWorkspaceChange={vi.fn()}
65
+ />,
66
+ );
67
+
68
+ // Wait for the effect to run and state to update
69
+ await waitFor(() => {
70
+ expect(screen.getByText('Engineering')).toBeInTheDocument();
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * bizar-dash/tests/deploy-templates.test.mjs
3
+ *
4
+ * Verify that deploy templates exist and are valid.
5
+ */
6
+ import { describe, it } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
9
+ import { join } 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', 'deploy');
15
+
16
+ // Lazy-import toml/yaml only if needed (they're devDependencies)
17
+ let toml;
18
+ let yaml;
19
+
20
+ function getToml() {
21
+ if (!toml) {
22
+ toml = import('smol-toml').then((m) => m.parse || m.default?.parse || m.default);
23
+ }
24
+ // synchronous wrapper
25
+ return { parse: (s) => { throw new Error('use async version'); } };
26
+ }
27
+
28
+ async function parseToml(content) {
29
+ const mod = await import('smol-toml');
30
+ return (mod.parse || mod.default?.parse || mod.default)(content);
31
+ }
32
+
33
+ async function parseYaml(content) {
34
+ const mod = await import('js-yaml');
35
+ return (mod.load || mod.default?.load || mod.default)(content);
36
+ }
37
+
38
+ describe('Deploy templates', () => {
39
+ const platforms = ['vercel', 'cloudflare', 'fly', 'docker'];
40
+ const requiredFiles = {
41
+ vercel: ['vercel.json.template', 'api-index.template.js', 'README.md'],
42
+ cloudflare: ['wrangler.toml.template', 'functions-index.template.js', 'README.md'],
43
+ fly: ['fly.toml.template', 'README.md'],
44
+ docker: ['docker-compose.template.yml', '.env.template', 'README.md'],
45
+ };
46
+
47
+ for (const platform of platforms) {
48
+ it(platform + ' template directory exists', () => {
49
+ const dir = join(TEMPLATES_DIR, platform);
50
+ assert.ok(existsSync(dir), 'Directory missing: templates/deploy/' + platform);
51
+ });
52
+
53
+ it(platform + ' has at least 2 files', () => {
54
+ const dir = join(TEMPLATES_DIR, platform);
55
+ const entries = readdirSync(dir).filter((f) => {
56
+ return statSync(join(dir, f)).isFile();
57
+ });
58
+ assert.ok(entries.length >= 2, platform + ' has ' + entries.length + ' files (need >=2): ' + entries.join(', '));
59
+ });
60
+
61
+ for (const file of (requiredFiles[platform] || [])) {
62
+ it(platform + '/' + file + ' exists', () => {
63
+ const fpath = join(TEMPLATES_DIR, platform, file);
64
+ assert.ok(existsSync(fpath), 'Missing file: templates/deploy/' + platform + '/' + file);
65
+ });
66
+ }
67
+ }
68
+
69
+ it('vercel.json.template is valid JSON', () => {
70
+ const content = readFileSync(join(TEMPLATES_DIR, 'vercel', 'vercel.json.template'), 'utf8');
71
+ const parsed = JSON.parse(content);
72
+ assert.ok(parsed.name);
73
+ assert.strictEqual(parsed.version, 2);
74
+ assert.ok(Array.isArray(parsed.routes));
75
+ });
76
+
77
+ it('wrangler.toml.template is valid TOML', { timeout: 10000 }, async () => {
78
+ const content = readFileSync(join(TEMPLATES_DIR, 'cloudflare', 'wrangler.toml.template'), 'utf8');
79
+ const parsed = await parseToml(content);
80
+ assert.ok(parsed);
81
+ assert.ok(parsed.name);
82
+ assert.ok(parsed.site);
83
+ });
84
+
85
+ it('fly.toml.template is valid TOML', { timeout: 10000 }, async () => {
86
+ const content = readFileSync(join(TEMPLATES_DIR, 'fly', 'fly.toml.template'), 'utf8');
87
+ const parsed = await parseToml(content);
88
+ assert.ok(parsed);
89
+ assert.ok(parsed.app);
90
+ assert.ok(parsed.http_service);
91
+ });
92
+
93
+ it('docker-compose.template.yml is valid YAML', { timeout: 10000 }, async () => {
94
+ const content = readFileSync(join(TEMPLATES_DIR, 'docker', 'docker-compose.template.yml'), 'utf8');
95
+ const parsed = await parseYaml(content);
96
+ assert.ok(parsed);
97
+ assert.ok(parsed.services);
98
+ assert.ok(parsed.services['bizar-dash']);
99
+ });
100
+ });