@polderlabs/bizar 4.8.0 → 5.0.0

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 (129) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
  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-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.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/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
@@ -0,0 +1,189 @@
1
+ // src/web/views/settings/WorkspacesSection.tsx — v5.0.0 — workspace management in settings.
2
+ import { useState, useEffect } from 'react';
3
+ import { Users, Plus, Trash2, Crown, Shield, Eye, Copy, Check } from 'lucide-react';
4
+ import { api } from '../../lib/api';
5
+ import { Button } from '../../components/Button';
6
+ import { useModal } from '../../components/Modal';
7
+ import { InviteDialog } from '../../components/InviteDialog';
8
+ import { cn } from '../../lib/utils';
9
+
10
+ export type WorkspaceInfo = {
11
+ id: string;
12
+ name: string;
13
+ ownerId: string;
14
+ createdAt: string;
15
+ };
16
+
17
+ export type WorkspaceWithRole = {
18
+ workspace: WorkspaceInfo;
19
+ role: string;
20
+ };
21
+
22
+ export type WorkspaceMember = {
23
+ userId: string;
24
+ email: string;
25
+ name: string;
26
+ role: string;
27
+ joinedAt: string;
28
+ };
29
+
30
+ const ROLE_ICONS = { admin: Crown, editor: Shield, viewer: Eye };
31
+ const ROLE_COLORS = { admin: 'role-admin', editor: 'role-editor', viewer: 'role-viewer' };
32
+
33
+ export function WorkspacesSection() {
34
+ const [workspaces, setWorkspaces] = useState<WorkspaceWithRole[]>([]);
35
+ const [loading, setLoading] = useState(true);
36
+ const [creating, setCreating] = useState(false);
37
+ const [newName, setNewName] = useState('');
38
+ const [error, setError] = useState<string | null>(null);
39
+ const [copied, setCopied] = useState<string | null>(null);
40
+ const modal = useModal();
41
+
42
+ useEffect(() => {
43
+ loadWorkspaces();
44
+ }, []);
45
+
46
+ async function loadWorkspaces() {
47
+ setLoading(true);
48
+ try {
49
+ const r = await api.get<{ workspaces: WorkspaceWithRole[] }>('/workspaces');
50
+ setWorkspaces(r.workspaces || []);
51
+ } catch {
52
+ setError('Failed to load workspaces');
53
+ } finally {
54
+ setLoading(false);
55
+ }
56
+ }
57
+
58
+ async function handleCreate(e: React.FormEvent) {
59
+ e.preventDefault();
60
+ if (!newName.trim()) return;
61
+ setCreating(true);
62
+ try {
63
+ await api.post('/workspaces', { name: newName.trim() });
64
+ setNewName('');
65
+ await loadWorkspaces();
66
+ } catch (err: unknown) {
67
+ setError((err as Error).message || 'Failed to create workspace');
68
+ } finally {
69
+ setCreating(false);
70
+ }
71
+ }
72
+
73
+ async function handleDelete(workspaceId: string, name: string) {
74
+ if (!confirm(`Delete workspace "${name}"?`)) return;
75
+ if (!confirm('This cannot be undone.')) return;
76
+ try {
77
+ await api.del(`/workspaces/${workspaceId}`);
78
+ await loadWorkspaces();
79
+ } catch (err: unknown) {
80
+ setError((err as Error).message || 'Failed to delete workspace');
81
+ }
82
+ }
83
+
84
+ function copyInviteLink(workspaceId: string) {
85
+ navigator.clipboard.writeText(`${window.location.origin}/accept-invite?workspace=${workspaceId}`).catch(() => undefined);
86
+ setCopied(workspaceId);
87
+ setTimeout(() => setCopied(null), 2000);
88
+ }
89
+
90
+ if (loading) {
91
+ return <div className="settings-section-loading">Loading workspaces...</div>;
92
+ }
93
+
94
+ return (
95
+ <div className="settings-section settings-section-workspaces">
96
+ <h3 className="settings-section-title">Workspaces</h3>
97
+ <p className="settings-section-desc">
98
+ Workspaces let you share access with team members. Each workspace has its own members, settings, and data.
99
+ </p>
100
+
101
+ {error && (
102
+ <div className="settings-section-error">
103
+ <span>{error}</span>
104
+ <button onClick={() => setError(null)}>×</button>
105
+ </div>
106
+ )}
107
+
108
+ {/* Create new workspace */}
109
+ <form className="workspace-create-form" onSubmit={handleCreate}>
110
+ <input
111
+ type="text"
112
+ placeholder="New workspace name"
113
+ value={newName}
114
+ onChange={(e) => setNewName(e.target.value)}
115
+ maxLength={64}
116
+ />
117
+ <Button type="submit" variant="primary" size="sm" loading={creating} disabled={!newName.trim()}>
118
+ <Plus size={13} />
119
+ Create
120
+ </Button>
121
+ </form>
122
+
123
+ {/* Workspace list */}
124
+ <div className="workspace-list">
125
+ {workspaces.length === 0 && (
126
+ <p className="workspace-list-empty">No workspaces yet. Create one above.</p>
127
+ )}
128
+ {workspaces.map(({ workspace, role }) => {
129
+ const RoleIcon = ROLE_ICONS[role as keyof typeof ROLE_ICONS] || Eye;
130
+ return (
131
+ <div key={workspace.id} className="workspace-item">
132
+ <div className="workspace-item-info">
133
+ <div className="workspace-item-icon">
134
+ <Users size={16} />
135
+ </div>
136
+ <div className="workspace-item-details">
137
+ <span className="workspace-item-name">{workspace.name}</span>
138
+ <span className={cn('workspace-item-role', ROLE_COLORS[role as keyof typeof ROLE_COLORS])}>
139
+ <RoleIcon size={11} />
140
+ {role}
141
+ </span>
142
+ </div>
143
+ </div>
144
+ <div className="workspace-item-actions">
145
+ <Button
146
+ variant="ghost"
147
+ size="sm"
148
+ iconOnly
149
+ title="Copy invite link"
150
+ onClick={() => copyInviteLink(workspace.id)}
151
+ >
152
+ {copied === workspace.id ? <Check size={13} /> : <Copy size={13} />}
153
+ </Button>
154
+ {role === 'admin' && (
155
+ <Button
156
+ variant="ghost"
157
+ size="sm"
158
+ iconOnly
159
+ title="Manage members"
160
+ onClick={() => {
161
+ modal.open({
162
+ title: `Manage "${workspace.name}"`,
163
+ children: <InviteDialog workspaceId={workspace.id} onInviteCreated={() => loadWorkspaces()} />,
164
+ width: 520,
165
+ });
166
+ }}
167
+ >
168
+ <Users size={13} />
169
+ </Button>
170
+ )}
171
+ {role === 'admin' && workspaces.length > 1 && (
172
+ <Button
173
+ variant="ghost"
174
+ size="sm"
175
+ iconOnly
176
+ title="Delete workspace"
177
+ onClick={() => handleDelete(workspace.id, workspace.name)}
178
+ >
179
+ <Trash2 size={13} />
180
+ </Button>
181
+ )}
182
+ </div>
183
+ </div>
184
+ );
185
+ })}
186
+ </div>
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * bundle-analysis.test.mjs
3
+ *
4
+ * Regression test to track dashboard bundle sizes and enforce that the
5
+ * mobile entry chunk stays under control.
6
+ *
7
+ * The mobile entry point (mobile.html → assets/mobile-*.js) is a
8
+ * separate Vite chunk from the desktop entry (index.html → assets/main-*.js).
9
+ * The desktop entry statically imports both App and MobileApp; the mobile
10
+ * entry imports only MobileApp — so the two chunks have different
11
+ * chunking characteristics.
12
+ *
13
+ * We track the primary mobile chunk (largest mobile-*.js, > 10 KB) and
14
+ * ensure neither bundle exceeds its documented cap. The QR-code library
15
+ * (qrcode.react) lives in a separate lazy chunk (index-*.js) and is NOT
16
+ * counted against the mobile bundle cap.
17
+ */
18
+ import { readFileSync, readdirSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
23
+ const dist = join(__dirname, '..', 'dist', 'assets');
24
+
25
+ function findLargestAsset(prefix) {
26
+ const files = readdirSync(dist)
27
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.js'))
28
+ .map((f) => ({ file: f, path: join(dist, f), size: readFileSync(join(dist, f)).length }))
29
+ .filter((f) => f.size > 10 * 1024); // exclude tiny entry wrappers
30
+ if (files.length === 0) throw new Error(`No asset found starting with "${prefix}" in dist/assets`);
31
+ return files.sort((a, b) => b.size - a.size)[0];
32
+ }
33
+
34
+ function kbytes(path) {
35
+ return readFileSync(path).length / 1024;
36
+ }
37
+
38
+ // v5.0 targets:
39
+ // - mobile.js < desktop.js (must be smaller — mobile has no App.tsx deduplication)
40
+ // - mobile.js < 400 KB (was 459 KB after v4.9; manualChunks + lazy views target ~350 KB)
41
+ // - desktop.js < 450 KB (was 393 KB; shared vendor chunks may shift bytes)
42
+ const DESKTOP_MAX_KB = 450;
43
+ const MOBILE_MAX_KB = 400;
44
+
45
+ const desktop = findLargestAsset('main-');
46
+ const mobile = findLargestAsset('mobile-');
47
+
48
+ const dk = kbytes(desktop.path);
49
+ const mk = kbytes(mobile.path);
50
+
51
+ console.log(`desktop bundle : ${dk.toFixed(1)} KB (${desktop.file})`);
52
+ console.log(`mobile bundle : ${mk.toFixed(1)} KB (${mobile.file})`);
53
+
54
+ // Check caps
55
+ if (dk > DESKTOP_MAX_KB) {
56
+ console.error(`FAIL: desktop bundle (${dk.toFixed(1)} KB) exceeds cap ${DESKTOP_MAX_KB} KB`);
57
+ process.exit(1);
58
+ }
59
+ if (mk > MOBILE_MAX_KB) {
60
+ console.error(`FAIL: mobile bundle (${mk.toFixed(1)} KB) exceeds cap ${MOBILE_MAX_KB} KB`);
61
+ process.exit(1);
62
+ }
63
+
64
+ // Dominance rule: mobile users should not pay more per initial load than desktop.
65
+ // The desktop entry (index.html) includes both App and MobileApp in its main chunk;
66
+ // the mobile entry (mobile.html) loads only MobileApp. If mobile ≥ desktop here
67
+ // it means the chunking ratio has inverted and warrants investigation.
68
+ if (mk >= dk) {
69
+ console.error(`FAIL: mobile (${mk.toFixed(1)} KB) is not smaller than desktop (${dk.toFixed(1)} KB)`);
70
+ process.exit(1);
71
+ }
72
+
73
+ console.log(`PASS: mobile (${mk.toFixed(1)} KB) < desktop (${dk.toFixed(1)} KB)`);
@@ -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,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,180 @@
1
+ /**
2
+ * tests/components/settings-search.test.tsx
3
+ *
4
+ * v4.9 — Component tests for SettingsSearch.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import { render, screen, fireEvent } from '@testing-library/react';
8
+ import React from 'react';
9
+ import { SettingsSearch, type SettingsSection } from '../../src/web/components/SettingsSearch';
10
+
11
+ const mockSections: SettingsSection[] = [
12
+ {
13
+ id: 'theme',
14
+ label: 'Theme',
15
+ fields: [
16
+ { key: 'theme.accent', label: 'Accent color', section: 'theme', value: '#8b5cf6' },
17
+ { key: 'theme.fontFamily', label: 'Font family', section: 'theme' },
18
+ ],
19
+ },
20
+ {
21
+ id: 'general',
22
+ label: 'General',
23
+ fields: [
24
+ { key: 'defaultAgent', label: 'Default agent', section: 'general', value: 'odin' },
25
+ ],
26
+ },
27
+ ];
28
+
29
+ /* ─── localStorage mock ─── */
30
+ const localStorageMock = (() => {
31
+ let store: Record<string, string> = {};
32
+ return {
33
+ getItem: vi.fn((key: string) => store[key] ?? null),
34
+ setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
35
+ removeItem: vi.fn((key: string) => { delete store[key]; }),
36
+ clear: vi.fn(() => { store = {}; }),
37
+ };
38
+ })();
39
+
40
+ Object.defineProperty(window, 'localStorage', { value: localStorageMock });
41
+
42
+ describe('SettingsSearch', () => {
43
+ const onJump = vi.fn();
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ localStorageMock.clear();
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ it('renders the search input', () => {
55
+ render(<SettingsSearch sections={mockSections} onJump={onJump} />);
56
+ expect(screen.getByPlaceholderText(/search settings/i)).toBeInTheDocument();
57
+ });
58
+
59
+ it('shows recent searches when input is empty and focused', () => {
60
+ // Pre-populate a recent search
61
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(['accent']));
62
+
63
+ render(<SettingsSearch sections={mockSections} onJump={onJump} />);
64
+ const input = screen.getByPlaceholderText(/search settings/i);
65
+ fireEvent.focus(input);
66
+
67
+ // Wait briefly for focus state — recent dropdown uses focused state
68
+ expect(screen.getByText('accent')).toBeInTheDocument();
69
+ });
70
+
71
+ it('saves search to recent on submit (Enter)', async () => {
72
+ const { container } = render(<SettingsSearch sections={mockSections} onJump={onJump} />);
73
+ const input = screen.getByPlaceholderText(/search settings/i);
74
+
75
+ fireEvent.change(input, { target: { value: 'accent' } });
76
+
77
+ // Wait for the search results to render
78
+ await vi.waitFor(() => {
79
+ expect(container.querySelector('.settings-search-results')).toBeTruthy();
80
+ });
81
+
82
+ fireEvent.keyDown(input, { key: 'Enter' });
83
+
84
+ // localStorage should have been written
85
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
86
+ 'bizar_settings_recent',
87
+ expect.any(String),
88
+ );
89
+ });
90
+
91
+ it('filters items by query', async () => {
92
+ const { container } = render(<SettingsSearch sections={mockSections} onJump={onJump} />);
93
+ const input = screen.getByPlaceholderText(/search settings/i);
94
+
95
+ fireEvent.change(input, { target: { value: 'accent' } });
96
+
97
+ // Check that results contain the matching field key (not split by highlight)
98
+ await vi.waitFor(() => {
99
+ expect(screen.getByText('theme.accent')).toBeInTheDocument();
100
+ });
101
+ });
102
+
103
+ it('handles typos via fuzzy match', async () => {
104
+ const { container } = render(<SettingsSearch sections={mockSections} onJump={onJump} />);
105
+ const input = screen.getByPlaceholderText(/search settings/i);
106
+
107
+ fireEvent.change(input, { target: { value: 'axcent' } });
108
+
109
+ // Typo leads to lower score but still matches — key should appear
110
+ await vi.waitFor(() => {
111
+ expect(screen.getByText('theme.accent')).toBeInTheDocument();
112
+ });
113
+ });
114
+
115
+ it('highlights matched terms in results', async () => {
116
+ render(<SettingsSearch sections={mockSections} onJump={onJump} />);
117
+ const input = screen.getByPlaceholderText(/search settings/i);
118
+
119
+ fireEvent.change(input, { target: { value: 'accent' } });
120
+
121
+ await vi.waitFor(() => {
122
+ // The label contains <mark>Accent</mark> inside a button
123
+ const buttons = screen.getAllByRole('button');
124
+ const matchBtn = buttons.find((b) => b.textContent?.includes('Accent'));
125
+ expect(matchBtn).toBeTruthy();
126
+ const mark = matchBtn?.querySelector('mark');
127
+ expect(mark).toBeTruthy();
128
+ expect(mark?.textContent).toBe('Accent');
129
+ });
130
+ });
131
+
132
+ it('jumps to section on result click', async () => {
133
+ render(<SettingsSearch sections={mockSections} onJump={onJump} />);
134
+ const input = screen.getByPlaceholderText(/search settings/i);
135
+
136
+ fireEvent.change(input, { target: { value: 'theme' } });
137
+
138
+ await vi.waitFor(() => {
139
+ // Click the section result
140
+ const buttons = screen.getAllByRole('button');
141
+ // Find the one containing "section" badge with "Theme"
142
+ const themeBtn = buttons.find(
143
+ (b) => b.textContent?.includes('Theme') && b.textContent?.includes('section'),
144
+ );
145
+ if (themeBtn) {
146
+ fireEvent.click(themeBtn);
147
+ expect(onJump).toHaveBeenCalledWith('theme');
148
+ }
149
+ });
150
+ });
151
+
152
+ it('clears search after jump', async () => {
153
+ render(<SettingsSearch sections={mockSections} onJump={onJump} />);
154
+ const input = screen.getByPlaceholderText(/search settings/i);
155
+
156
+ fireEvent.change(input, { target: { value: 'font' } });
157
+
158
+ await vi.waitFor(() => {
159
+ const buttons = screen.getAllByRole('button');
160
+ const fontBtn = buttons.find((b) => b.textContent?.includes('Font family'));
161
+ if (fontBtn) {
162
+ fireEvent.click(fontBtn);
163
+ expect(onJump).toHaveBeenCalledWith('theme', 'theme.fontFamily');
164
+ // Input should be cleared after jump
165
+ expect(input).toHaveValue('');
166
+ }
167
+ });
168
+ });
169
+
170
+ it('shows no results message for unmatched query', async () => {
171
+ render(<SettingsSearch sections={mockSections} onJump={onJump} />);
172
+ const input = screen.getByPlaceholderText(/search settings/i);
173
+
174
+ fireEvent.change(input, { target: { value: 'zzzzdoesnotmatch' } });
175
+
176
+ await vi.waitFor(() => {
177
+ expect(screen.getByText(/no matching settings/i)).toBeInTheDocument();
178
+ });
179
+ });
180
+ });