@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.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -8
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- 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
|
-
//
|
|
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 =
|
|
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
|
+
});
|