@polderlabs/bizar 4.9.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 (107) 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-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-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-DfmIfOUS.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-CL5uUQEC.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/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
@@ -0,0 +1,101 @@
1
+ // src/web/views/memory/VaultFromClipboardPanel.tsx — v5.0.0
2
+ //
3
+ // Memory tab panel for pasting content directly into the vault.
4
+ // Accepts URL + title + content text, saves via /api/clipboard/save.
5
+
6
+ import { useState } from 'react';
7
+ import { Clipboard, Loader2 } from 'lucide-react';
8
+ import { Button } from '../../components/Button';
9
+ import { Card, CardTitle } from '../../components/Card';
10
+ import { useToast } from '../../components/Toast';
11
+ import { api } from '../../lib/api';
12
+
13
+ type Props = {
14
+ refreshKey: number;
15
+ };
16
+
17
+ export function VaultFromClipboardPanel({ refreshKey: _refreshKey }: Props) {
18
+ const toast = useToast();
19
+ const [url, setUrl] = useState('');
20
+ const [title, setTitle] = useState('');
21
+ const [content, setContent] = useState('');
22
+ const [saving, setSaving] = useState(false);
23
+
24
+ const handleSave = async () => {
25
+ if (!content.trim()) {
26
+ toast.error('Content is required');
27
+ return;
28
+ }
29
+ setSaving(true);
30
+ try {
31
+ const r = await api.post<{ ok: boolean; notePath: string }>('/clipboard/save', {
32
+ url: url.trim() || undefined,
33
+ title: title.trim() || undefined,
34
+ content: content,
35
+ savedAt: new Date().toISOString(),
36
+ });
37
+ if (r.ok) {
38
+ toast.success(`Saved to ${r.notePath}`);
39
+ setUrl('');
40
+ setTitle('');
41
+ setContent('');
42
+ }
43
+ } catch (err) {
44
+ toast.error(`Save failed: ${(err as Error).message}`);
45
+ } finally {
46
+ setSaving(false);
47
+ }
48
+ };
49
+
50
+ return (
51
+ <div className="memory-panel-content">
52
+ <Card variant="outlined" className="memory-panel-card">
53
+ <div className="memory-panel-body">
54
+ <div className="vault-clipboard-panel">
55
+ <div className="vault-clipboard-header">
56
+ <Clipboard size={16} />
57
+ <span>Paste Content</span>
58
+ </div>
59
+
60
+ <label className="field-label" htmlFor="clip-url">URL (optional)</label>
61
+ <input
62
+ id="clip-url"
63
+ type="text"
64
+ className="input"
65
+ placeholder="https://example.com"
66
+ value={url}
67
+ onChange={(e) => setUrl(e.target.value)}
68
+ />
69
+
70
+ <label className="field-label" htmlFor="clip-title" style={{ marginTop: 10 }}>Title (optional)</label>
71
+ <input
72
+ id="clip-title"
73
+ type="text"
74
+ className="input"
75
+ placeholder="Page title"
76
+ value={title}
77
+ onChange={(e) => setTitle(e.target.value)}
78
+ />
79
+
80
+ <label className="field-label" htmlFor="clip-content" style={{ marginTop: 10 }}>Content *</label>
81
+ <textarea
82
+ id="clip-content"
83
+ className="input vault-clipboard-textarea mono text-sm"
84
+ rows={12}
85
+ placeholder="Paste or type content to save to your vault…"
86
+ value={content}
87
+ onChange={(e) => setContent(e.target.value)}
88
+ />
89
+
90
+ <div className="vault-clipboard-actions" style={{ marginTop: 12 }}>
91
+ <Button variant="primary" size="sm" onClick={handleSave} disabled={saving || !content.trim()}>
92
+ {saving && <Loader2 size={12} className="spinner" />}
93
+ Save to Vault
94
+ </Button>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </Card>
99
+ </div>
100
+ );
101
+ }
@@ -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
+ }
@@ -35,9 +35,12 @@ function kbytes(path) {
35
35
  return readFileSync(path).length / 1024;
36
36
  }
37
37
 
38
- // Caps — adjust only when the architecture intentionally changes chunk composition.
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)
39
42
  const DESKTOP_MAX_KB = 450;
40
- const MOBILE_MAX_KB = 500;
43
+ const MOBILE_MAX_KB = 400;
41
44
 
42
45
  const desktop = findLargestAsset('main-');
43
46
  const mobile = findLargestAsset('mobile-');
@@ -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,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
+ });