@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.
- 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-DmpSFPJY.js +9 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
- 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-DX_Jh8Wc.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-Chvf9u_B.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/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/otel.mjs +133 -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/chat.mjs +246 -170
- 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/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- 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/server.mjs +40 -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/SettingsSearch.tsx +204 -89
- 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/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +166 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +22 -2
- package/bizar-dash/src/web/views/Settings.tsx +99 -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/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -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 +73 -0
- 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/settings-search.test.tsx +180 -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/docker-build.test.mjs +96 -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/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/otel.test.mjs +188 -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/dash.mjs +6 -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 -2
- 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-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/plugins-store.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for src/server/plugins/store.mjs:
|
|
5
|
+
* - listInstalled is empty before any install
|
|
6
|
+
* - installPlugin downloads, verifies checksum, extracts, registers
|
|
7
|
+
* - installPlugin refuses to reinstall without force
|
|
8
|
+
* - installPlugin rejects a checksum mismatch
|
|
9
|
+
* - installPlugin rejects a tarball with no plugin.json
|
|
10
|
+
* - uninstallPlugin removes disk + installed.json
|
|
11
|
+
* - updatePlugin preserves config across the version bump
|
|
12
|
+
* - invokePlugin runs an installed plugin in the sandbox
|
|
13
|
+
* - replaceConfig persists a new config
|
|
14
|
+
*
|
|
15
|
+
* Strategy: stand up a fake HTTP server that serves both a registry
|
|
16
|
+
* JSON and a tarball on demand. Build the tarball in-memory using
|
|
17
|
+
* node:tar's `tar.c` (create + gzip). Point the client at the server
|
|
18
|
+
* with a fetch override + BIZAR_PLUGIN_HOME tmpdir.
|
|
19
|
+
*/
|
|
20
|
+
import { test, describe, before, after, beforeEach } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import {
|
|
24
|
+
mkdirSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
existsSync,
|
|
29
|
+
statSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { join, resolve, dirname } from 'node:path';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { createGzip } from 'node:zlib';
|
|
35
|
+
import { pack as tarPack } from 'tar-stream';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
|
|
40
|
+
// ── Sandbox HOME + plugin home BEFORE importing the store ────────────────
|
|
41
|
+
// The store reads BIZAR_PLUGIN_HOME at import time, so set it first.
|
|
42
|
+
const SANDBOX_HOME = join(tmpdir(), `bizar-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
43
|
+
process.env.HOME = SANDBOX_HOME;
|
|
44
|
+
process.env.BIZAR_PLUGIN_HOME = join(SANDBOX_HOME, 'plugins');
|
|
45
|
+
mkdirSync(SANDBOX_HOME, { recursive: true });
|
|
46
|
+
mkdirSync(process.env.BIZAR_PLUGIN_HOME, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const STORE = await import(resolve(REPO, 'src/server/plugins/store.mjs'));
|
|
49
|
+
const REG = await import(resolve(REPO, 'src/server/plugins/registry.mjs'));
|
|
50
|
+
|
|
51
|
+
function sha256OfBuffer(buf) {
|
|
52
|
+
return 'sha256:' + createHash('sha256').update(buf).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a gzip-compressed tarball in memory. The contents are a
|
|
57
|
+
* synthetic plugin: { plugin.json, index.js }.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} opts
|
|
60
|
+
* @param {string} opts.id
|
|
61
|
+
* @param {string} opts.version
|
|
62
|
+
* @param {string} [opts.mainSource] source code for index.js
|
|
63
|
+
* @param {string[]} [opts.permissions]
|
|
64
|
+
* @param {string} [opts.wrapper] 'flat' (default) | 'topdir' (one top-level dir)
|
|
65
|
+
*/
|
|
66
|
+
async function buildTarball(opts) {
|
|
67
|
+
const { id, version, mainSource, permissions = [], wrapper = 'flat' } = opts;
|
|
68
|
+
const tmpDir = join(SANDBOX_HOME, 'tarball-src', id);
|
|
69
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
70
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
const manifest = {
|
|
73
|
+
id,
|
|
74
|
+
name: `Test ${id}`,
|
|
75
|
+
version,
|
|
76
|
+
description: `Synthetic test plugin ${id}@${version}`,
|
|
77
|
+
permissions,
|
|
78
|
+
main: 'index.js',
|
|
79
|
+
exports: [
|
|
80
|
+
{ name: 'whoami', args: [], returns: 'string' },
|
|
81
|
+
{ name: 'echo', args: [{ name: 'msg', type: 'string' }], returns: 'string' },
|
|
82
|
+
{ name: 'init', args: [], returns: 'void' },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
const indexSrc = mainSource || `
|
|
86
|
+
module.exports = {
|
|
87
|
+
async whoami() { return '${id}@${version}'; },
|
|
88
|
+
async echo(msg) { return 'echo:' + msg; },
|
|
89
|
+
async init() { /* no-op */ },
|
|
90
|
+
};
|
|
91
|
+
`;
|
|
92
|
+
writeFileSync(join(tmpDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
|
|
93
|
+
writeFileSync(join(tmpDir, 'index.js'), indexSrc);
|
|
94
|
+
|
|
95
|
+
// Build the tarball as a Buffer. tar-stream's `pack()` is a
|
|
96
|
+
// Transform stream: we collect bytes by piping through a PassThrough
|
|
97
|
+
// (or directly collecting chunks after attach a data listener).
|
|
98
|
+
// The trick is to attach the 'data' listener BEFORE finalize()
|
|
99
|
+
// so the stream can flow.
|
|
100
|
+
const tarBytes = await new Promise((res, rej) => {
|
|
101
|
+
const packer = tarPack();
|
|
102
|
+
const chunks = [];
|
|
103
|
+
packer.on('data', (c) => chunks.push(c));
|
|
104
|
+
packer.on('end', () => res(Buffer.concat(chunks)));
|
|
105
|
+
packer.on('error', rej);
|
|
106
|
+
const pluginJsonBuf = readFileSync(join(tmpDir, 'plugin.json'));
|
|
107
|
+
const indexBuf = readFileSync(join(tmpDir, 'index.js'));
|
|
108
|
+
if (wrapper === 'topdir') {
|
|
109
|
+
packer.entry({ name: `${id}/plugin.json`, size: pluginJsonBuf.length }, pluginJsonBuf);
|
|
110
|
+
packer.entry({ name: `${id}/index.js`, size: indexBuf.length }, indexBuf);
|
|
111
|
+
} else {
|
|
112
|
+
packer.entry({ name: 'plugin.json', size: pluginJsonBuf.length }, pluginJsonBuf);
|
|
113
|
+
packer.entry({ name: 'index.js', size: indexBuf.length }, indexBuf);
|
|
114
|
+
}
|
|
115
|
+
packer.finalize();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Gzip-compress.
|
|
119
|
+
return new Promise((res, rej) => {
|
|
120
|
+
const gzChunks = [];
|
|
121
|
+
const gz = createGzip();
|
|
122
|
+
gz.on('data', (c) => gzChunks.push(c));
|
|
123
|
+
gz.on('end', () => res(Buffer.concat(gzChunks)));
|
|
124
|
+
gz.on('error', rej);
|
|
125
|
+
gz.end(tarBytes);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Fake registry server ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
let server;
|
|
132
|
+
let serverUrl;
|
|
133
|
+
let servedTarballs = {}; // id -> { buf, version }
|
|
134
|
+
let servedRegistry = { version: 1, updatedAt: 'x', plugins: [] };
|
|
135
|
+
|
|
136
|
+
function rebuildRegistry() {
|
|
137
|
+
servedRegistry = {
|
|
138
|
+
version: 1,
|
|
139
|
+
updatedAt: new Date().toISOString(),
|
|
140
|
+
plugins: Object.entries(servedTarballs).map(([id, { buf, version }]) => ({
|
|
141
|
+
id,
|
|
142
|
+
name: `Test ${id}`,
|
|
143
|
+
version,
|
|
144
|
+
description: '...',
|
|
145
|
+
tarball: `${serverUrl}/tarball/${id}`,
|
|
146
|
+
checksum: sha256OfBuffer(buf),
|
|
147
|
+
permissions: [],
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function startServer() {
|
|
153
|
+
return new Promise((r) => {
|
|
154
|
+
const s = http.createServer((req, res) => {
|
|
155
|
+
if (req.url === '/registry.json') {
|
|
156
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
157
|
+
res.end(JSON.stringify(servedRegistry));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const m = /^\/tarball\/([a-z0-9-]+)$/.exec(req.url);
|
|
161
|
+
if (m) {
|
|
162
|
+
const id = m[1];
|
|
163
|
+
const entry = servedTarballs[id];
|
|
164
|
+
if (!entry) {
|
|
165
|
+
res.writeHead(404);
|
|
166
|
+
res.end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
|
170
|
+
res.end(entry.buf);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
res.writeHead(404);
|
|
174
|
+
res.end();
|
|
175
|
+
});
|
|
176
|
+
s.listen(0, '127.0.0.1', () => {
|
|
177
|
+
const { port } = s.address();
|
|
178
|
+
r({
|
|
179
|
+
url: `http://127.0.0.1:${port}`,
|
|
180
|
+
close: () => new Promise((rr) => s.close(rr)),
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
before(async () => {
|
|
187
|
+
server = await startServer();
|
|
188
|
+
serverUrl = server.url;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
after(async () => {
|
|
192
|
+
if (server) await server.close();
|
|
193
|
+
rmSync(SANDBOX_HOME, { recursive: true, force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
// Wipe installed state between tests so each starts clean.
|
|
198
|
+
rmSync(process.env.BIZAR_PLUGIN_HOME, { recursive: true, force: true });
|
|
199
|
+
mkdirSync(process.env.BIZAR_PLUGIN_HOME, { recursive: true });
|
|
200
|
+
servedTarballs = {};
|
|
201
|
+
REG.__resetCache();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── listInstalled / readInstalled ────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe('listInstalled', () => {
|
|
207
|
+
test('returns [] before any install', () => {
|
|
208
|
+
assert.deepEqual(STORE.listInstalled(), []);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('reads installed.json even if empty', () => {
|
|
212
|
+
writeFileSync(
|
|
213
|
+
join(process.env.BIZAR_PLUGIN_HOME, 'installed.json'),
|
|
214
|
+
JSON.stringify({ plugins: [] }),
|
|
215
|
+
);
|
|
216
|
+
assert.deepEqual(STORE.listInstalled(), []);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('returns [] for a malformed installed.json', () => {
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(process.env.BIZAR_PLUGIN_HOME, 'installed.json'),
|
|
222
|
+
'not json',
|
|
223
|
+
);
|
|
224
|
+
assert.deepEqual(STORE.listInstalled(), []);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── installPlugin happy path ─────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Convenience: pass the fake registry URL through to installPlugin
|
|
232
|
+
* so every call below doesn't need to spell it out.
|
|
233
|
+
*/
|
|
234
|
+
const registryUrl = () => `${serverUrl}/registry.json`;
|
|
235
|
+
|
|
236
|
+
describe('installPlugin', () => {
|
|
237
|
+
test('installs a plugin from the registry (flat tarball)', async () => {
|
|
238
|
+
servedTarballs['hello'] = { buf: await buildTarball({ id: 'hello', version: '1.0.0' }), version: '1.0.0' };
|
|
239
|
+
rebuildRegistry();
|
|
240
|
+
|
|
241
|
+
const r = await STORE.installPlugin('hello', { url: registryUrl() });
|
|
242
|
+
assert.equal(r.id, 'hello');
|
|
243
|
+
assert.equal(r.version, '1.0.0');
|
|
244
|
+
assert.ok(r.installedAt);
|
|
245
|
+
assert.ok(r.path);
|
|
246
|
+
assert.ok(existsSync(join(r.path, 'plugin.json')));
|
|
247
|
+
assert.ok(existsSync(join(r.path, 'index.js')));
|
|
248
|
+
|
|
249
|
+
// installed.json records the plugin.
|
|
250
|
+
const listed = STORE.listInstalled();
|
|
251
|
+
assert.equal(listed.length, 1);
|
|
252
|
+
assert.equal(listed[0].id, 'hello');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('installs a plugin from the registry (top-level dir wrapper)', async () => {
|
|
256
|
+
servedTarballs['wrapped'] = {
|
|
257
|
+
buf: await buildTarball({ id: 'wrapped', version: '1.0.0', wrapper: 'topdir' }),
|
|
258
|
+
version: '1.0.0',
|
|
259
|
+
};
|
|
260
|
+
rebuildRegistry();
|
|
261
|
+
|
|
262
|
+
const r = await STORE.installPlugin('wrapped', { url: registryUrl() });
|
|
263
|
+
assert.ok(existsSync(join(r.path, 'plugin.json')));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('refuses to reinstall without force', async () => {
|
|
267
|
+
servedTarballs['dup'] = { buf: await buildTarball({ id: 'dup', version: '1.0.0' }), version: '1.0.0' };
|
|
268
|
+
rebuildRegistry();
|
|
269
|
+
await STORE.installPlugin('dup', { url: registryUrl() });
|
|
270
|
+
await assert.rejects(
|
|
271
|
+
() => STORE.installPlugin('dup', { url: registryUrl() }),
|
|
272
|
+
(err) => err.code === 'already_installed',
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('force=true reinstalls', async () => {
|
|
277
|
+
servedTarballs['forced'] = { buf: await buildTarball({ id: 'forced', version: '1.0.0' }), version: '1.0.0' };
|
|
278
|
+
rebuildRegistry();
|
|
279
|
+
await STORE.installPlugin('forced', { url: registryUrl() });
|
|
280
|
+
const r2 = await STORE.installPlugin('forced', { force: true, url: registryUrl() });
|
|
281
|
+
assert.equal(r2.id, 'forced');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('rejects a checksum mismatch', async () => {
|
|
285
|
+
const buf = await buildTarball({ id: 'evil', version: '1.0.0' });
|
|
286
|
+
servedTarballs['evil'] = { buf, version: '1.0.0' };
|
|
287
|
+
// Build the registry with a WRONG checksum.
|
|
288
|
+
servedRegistry = {
|
|
289
|
+
version: 1,
|
|
290
|
+
updatedAt: new Date().toISOString(),
|
|
291
|
+
plugins: [{
|
|
292
|
+
id: 'evil', name: 'Evil', version: '1.0.0',
|
|
293
|
+
tarball: `${serverUrl}/tarball/evil`,
|
|
294
|
+
checksum: 'sha256:' + '0'.repeat(64),
|
|
295
|
+
permissions: [],
|
|
296
|
+
}],
|
|
297
|
+
};
|
|
298
|
+
REG.__resetCache();
|
|
299
|
+
await assert.rejects(
|
|
300
|
+
() => STORE.installPlugin('evil', { url: registryUrl() }),
|
|
301
|
+
(err) => err.code === 'checksum_mismatch',
|
|
302
|
+
);
|
|
303
|
+
// No plugin should be on disk after a failed install.
|
|
304
|
+
assert.equal(existsSync(join(process.env.BIZAR_PLUGIN_HOME, 'evil')), false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('rejects a tarball with no plugin.json', async () => {
|
|
308
|
+
// Build a tarball that only contains a junk file.
|
|
309
|
+
const tmpDir = join(SANDBOX_HOME, 'no-manifest');
|
|
310
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
311
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
312
|
+
writeFileSync(join(tmpDir, 'README.md'), 'no manifest here');
|
|
313
|
+
const tarBytes = await new Promise((res, rej) => {
|
|
314
|
+
const packer = tarPack();
|
|
315
|
+
const chunks = [];
|
|
316
|
+
packer.on('data', (c) => chunks.push(c));
|
|
317
|
+
packer.on('end', () => res(Buffer.concat(chunks)));
|
|
318
|
+
packer.on('error', rej);
|
|
319
|
+
const readmeBuf = readFileSync(join(tmpDir, 'README.md'));
|
|
320
|
+
packer.entry({ name: 'README.md', size: readmeBuf.length }, readmeBuf);
|
|
321
|
+
packer.finalize();
|
|
322
|
+
});
|
|
323
|
+
const gzBuf = await new Promise((res, rej) => {
|
|
324
|
+
const gz = createGzip();
|
|
325
|
+
const out = [];
|
|
326
|
+
gz.on('data', (c) => out.push(c));
|
|
327
|
+
gz.on('end', () => res(Buffer.concat(out)));
|
|
328
|
+
gz.on('error', rej);
|
|
329
|
+
gz.end(tarBytes);
|
|
330
|
+
});
|
|
331
|
+
servedTarballs['noman'] = { buf: gzBuf, version: '1.0.0' };
|
|
332
|
+
rebuildRegistry();
|
|
333
|
+
|
|
334
|
+
await assert.rejects(
|
|
335
|
+
() => STORE.installPlugin('noman', { url: registryUrl() }),
|
|
336
|
+
/contains no plugin\.json/,
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('throws not_found for an unknown id', async () => {
|
|
341
|
+
rebuildRegistry();
|
|
342
|
+
await assert.rejects(
|
|
343
|
+
() => STORE.installPlugin('never', { url: registryUrl() }),
|
|
344
|
+
(err) => err.code === 'not_found',
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ── uninstallPlugin ──────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
describe('uninstallPlugin', () => {
|
|
352
|
+
test('removes disk + installed.json entry', async () => {
|
|
353
|
+
servedTarballs['bye'] = { buf: await buildTarball({ id: 'bye', version: '1.0.0' }), version: '1.0.0' };
|
|
354
|
+
rebuildRegistry();
|
|
355
|
+
await STORE.installPlugin('bye', { url: registryUrl() });
|
|
356
|
+
const dir = join(process.env.BIZAR_PLUGIN_HOME, 'bye');
|
|
357
|
+
assert.ok(existsSync(dir));
|
|
358
|
+
|
|
359
|
+
const ok = STORE.uninstallPlugin('bye');
|
|
360
|
+
assert.equal(ok, true);
|
|
361
|
+
assert.equal(existsSync(dir), false);
|
|
362
|
+
assert.equal(STORE.listInstalled().length, 0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('returns false for a not-installed id', () => {
|
|
366
|
+
assert.equal(STORE.uninstallPlugin('never'), false);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ── updatePlugin ─────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
describe('updatePlugin', () => {
|
|
373
|
+
test('upgrades to the latest registry version, preserving config', async () => {
|
|
374
|
+
servedTarballs['updatable'] = { buf: await buildTarball({ id: 'updatable', version: '1.0.0' }), version: '1.0.0' };
|
|
375
|
+
rebuildRegistry();
|
|
376
|
+
await STORE.installPlugin('updatable', { url: registryUrl() });
|
|
377
|
+
STORE.replaceConfig('updatable', { apiKey: 'sk-test-123', enabled: true });
|
|
378
|
+
|
|
379
|
+
// Bump the registry to 2.0.0 with a fresh tarball.
|
|
380
|
+
servedTarballs['updatable'] = { buf: await buildTarball({ id: 'updatable', version: '2.0.0' }), version: '2.0.0' };
|
|
381
|
+
rebuildRegistry();
|
|
382
|
+
// Reset the in-memory cache so the next installPlugin() actually
|
|
383
|
+
// re-fetches and sees the new checksum.
|
|
384
|
+
REG.__resetCache();
|
|
385
|
+
|
|
386
|
+
const r = await STORE.updatePlugin('updatable', { url: registryUrl() });
|
|
387
|
+
assert.equal(r.from, '1.0.0');
|
|
388
|
+
assert.equal(r.to, '2.0.0');
|
|
389
|
+
// Config survived the update.
|
|
390
|
+
const installed = STORE.getInstalled('updatable');
|
|
391
|
+
assert.equal(installed.config.apiKey, 'sk-test-123');
|
|
392
|
+
assert.equal(installed.config.enabled, true);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('throws not_installed for a missing plugin', async () => {
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
() => STORE.updatePlugin('never', { url: registryUrl() }),
|
|
398
|
+
(err) => err.code === 'not_installed',
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ── invokePlugin ─────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
describe('invokePlugin', () => {
|
|
406
|
+
test('runs a method on the installed plugin', async () => {
|
|
407
|
+
servedTarballs['runner'] = { buf: await buildTarball({ id: 'runner', version: '1.0.0' }), version: '1.0.0' };
|
|
408
|
+
rebuildRegistry();
|
|
409
|
+
await STORE.installPlugin('runner', { url: registryUrl() });
|
|
410
|
+
|
|
411
|
+
const r = await STORE.invokePlugin('runner', 'whoami', []);
|
|
412
|
+
assert.equal(r.ok, true);
|
|
413
|
+
assert.equal(r.result, 'runner@1.0.0');
|
|
414
|
+
|
|
415
|
+
const r2 = await STORE.invokePlugin('runner', 'echo', ['hi']);
|
|
416
|
+
assert.equal(r2.ok, true);
|
|
417
|
+
assert.equal(r2.result, 'echo:hi');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('returns not_installed for a missing plugin', async () => {
|
|
421
|
+
const r = await STORE.invokePlugin('never', 'echo', []);
|
|
422
|
+
assert.equal(r.ok, false);
|
|
423
|
+
assert.equal(r.code, 'not_installed');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('returns no_such_method for an unknown method', async () => {
|
|
427
|
+
servedTarballs['runs2'] = { buf: await buildTarball({ id: 'runs2', version: '1.0.0' }), version: '1.0.0' };
|
|
428
|
+
rebuildRegistry();
|
|
429
|
+
await STORE.installPlugin('runs2', { url: registryUrl() });
|
|
430
|
+
const r = await STORE.invokePlugin('runs2', 'imaginary', []);
|
|
431
|
+
assert.equal(r.ok, false);
|
|
432
|
+
assert.equal(r.code, 'no_such_method');
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ── replaceConfig ────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
describe('replaceConfig', () => {
|
|
439
|
+
test('replaces the entire config object', async () => {
|
|
440
|
+
servedTarballs['cfg'] = { buf: await buildTarball({ id: 'cfg', version: '1.0.0' }), version: '1.0.0' };
|
|
441
|
+
rebuildRegistry();
|
|
442
|
+
await STORE.installPlugin('cfg', { url: registryUrl() });
|
|
443
|
+
STORE.replaceConfig('cfg', { apiKey: 'a', region: 'us-east-1' });
|
|
444
|
+
const installed = STORE.getInstalled('cfg');
|
|
445
|
+
assert.equal(installed.config.apiKey, 'a');
|
|
446
|
+
assert.equal(installed.config.region, 'us-east-1');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('rejects non-object config', () => {
|
|
450
|
+
assert.throws(
|
|
451
|
+
() => STORE.replaceConfig('cfg', 'not an object'),
|
|
452
|
+
/must be a plain object/,
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/users.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Tests for the users route handlers.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, beforeEach, afterEach, after } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
mkdirSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
|
|
15
|
+
// These are tested via the REST API in workspaces.test.mjs (user-related functions)
|
|
16
|
+
// This file tests the users store functions directly.
|
|
17
|
+
|
|
18
|
+
const TEST_ID = `bizar-user-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
19
|
+
const TEST_HOME = join(tmpdir(), TEST_ID);
|
|
20
|
+
const BIZAR_LOCAL = join(TEST_HOME, '.local', 'share', 'bizar');
|
|
21
|
+
const WORKSPACES_ROOT = join(BIZAR_LOCAL, 'workspaces');
|
|
22
|
+
|
|
23
|
+
const originalHomedir = process.env.HOME;
|
|
24
|
+
process.env.HOME = TEST_HOME;
|
|
25
|
+
|
|
26
|
+
describe('users', () => {
|
|
27
|
+
let cleanupDirs = [];
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mkdirSync(BIZAR_LOCAL, { recursive: true });
|
|
31
|
+
cleanupDirs.push(TEST_HOME);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const dir of cleanupDirs) {
|
|
36
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
cleanupDirs = [];
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
after(() => {
|
|
42
|
+
process.env.HOME = originalHomedir;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Users are managed through the workspaces module (index.json users array)
|
|
46
|
+
// This test verifies user CRUD through workspaces
|
|
47
|
+
|
|
48
|
+
describe('user CRUD via workspaces module', () => {
|
|
49
|
+
it('getOrCreateUser creates user and persists to index', async () => {
|
|
50
|
+
const ws = await import('../src/server/workspaces.mjs');
|
|
51
|
+
const { user, created } = await ws.getOrCreateUser('test@example.com', 'Test User');
|
|
52
|
+
|
|
53
|
+
assert.ok(created);
|
|
54
|
+
assert.ok(user.id.startsWith('usr_'));
|
|
55
|
+
assert.strictEqual(user.email, 'test@example.com');
|
|
56
|
+
assert.strictEqual(user.name, 'Test User');
|
|
57
|
+
|
|
58
|
+
// Verify persisted
|
|
59
|
+
const found = await ws.getUser(user.id);
|
|
60
|
+
assert.ok(found);
|
|
61
|
+
assert.strictEqual(found.email, 'test@example.com');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('getUser returns user by id', async () => {
|
|
65
|
+
const ws = await import('../src/server/workspaces.mjs');
|
|
66
|
+
const { user } = await ws.getOrCreateUser('findme@example.com', 'Find Me');
|
|
67
|
+
|
|
68
|
+
const found = await ws.getUser(user.id);
|
|
69
|
+
assert.ok(found);
|
|
70
|
+
assert.strictEqual(found.id, user.id);
|
|
71
|
+
assert.strictEqual(found.name, 'Find Me');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('getUser returns null for non-existent user', async () => {
|
|
75
|
+
const ws = await import('../src/server/workspaces.mjs');
|
|
76
|
+
const found = await ws.getUser('usr_nonexistent');
|
|
77
|
+
assert.strictEqual(found, null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('updateUser updates name and email', async () => {
|
|
81
|
+
const ws = await import('../src/server/workspaces.mjs');
|
|
82
|
+
const { user } = await ws.getOrCreateUser('original@example.com', 'Original Name');
|
|
83
|
+
|
|
84
|
+
const updated = await ws.updateUser(user.id, {
|
|
85
|
+
name: 'Updated Name',
|
|
86
|
+
email: 'updated@example.com',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assert.strictEqual(updated.name, 'Updated Name');
|
|
90
|
+
assert.strictEqual(updated.email, 'updated@example.com');
|
|
91
|
+
|
|
92
|
+
// Verify persisted
|
|
93
|
+
const found = await ws.getUser(user.id);
|
|
94
|
+
assert.strictEqual(found.name, 'Updated Name');
|
|
95
|
+
assert.strictEqual(found.email, 'updated@example.com');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('email matching is case-insensitive', async () => {
|
|
99
|
+
const ws = await import('../src/server/workspaces.mjs');
|
|
100
|
+
const { user } = await ws.getOrCreateUser('case@test.com', 'Case Test');
|
|
101
|
+
|
|
102
|
+
// Try to get or create with different case
|
|
103
|
+
const found = await ws.getOrCreateUser('CASE@TEST.COM', 'Different Case');
|
|
104
|
+
assert.strictEqual(found.created, false);
|
|
105
|
+
assert.strictEqual(found.user.id, user.id);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/voice-recorder.test.tsx
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Tests for VoiceRecorder React component.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { VoiceRecorder } from '../src/web/components/VoiceRecorder';
|
|
10
|
+
|
|
11
|
+
// Mock the browser's MediaRecorder — use a class so `new MediaRecorder()` works
|
|
12
|
+
class MockMediaRecorder {
|
|
13
|
+
constructor(stream, opts) {
|
|
14
|
+
this.stream = stream;
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
this.state = 'inactive';
|
|
17
|
+
this.ondataavailable = null;
|
|
18
|
+
this.onstop = null;
|
|
19
|
+
MockMediaRecorder.instances.push(this);
|
|
20
|
+
}
|
|
21
|
+
start() { this.state = 'recording'; }
|
|
22
|
+
stop() { this.state = 'stopped'; this.onstop?.(); }
|
|
23
|
+
static isTypeSupported() { return true; }
|
|
24
|
+
static instances = [];
|
|
25
|
+
static reset() { MockMediaRecorder.instances = []; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mockMediaStream = {
|
|
29
|
+
getTracks: vi.fn(() => [{ stop: vi.fn() }]),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
vi.stubGlobal('navigator', {
|
|
33
|
+
mediaDevices: {
|
|
34
|
+
getUserMedia: vi.fn(() => Promise.resolve(mockMediaStream)),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
vi.stubGlobal('MediaRecorder', MockMediaRecorder);
|
|
39
|
+
|
|
40
|
+
// Mock fetch
|
|
41
|
+
const mockFetch = vi.fn();
|
|
42
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
43
|
+
|
|
44
|
+
describe('VoiceRecorder', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
MockMediaRecorder.reset();
|
|
48
|
+
mockFetch.mockReset();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders a Record button initially', () => {
|
|
52
|
+
render(<VoiceRecorder />);
|
|
53
|
+
const btn = screen.getByRole('button', { name: /start recording/i });
|
|
54
|
+
expect(btn).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('starts recording when Record is clicked', async () => {
|
|
58
|
+
render(<VoiceRecorder />);
|
|
59
|
+
const btn = screen.getByRole('button', { name: /start recording/i });
|
|
60
|
+
fireEvent.click(btn);
|
|
61
|
+
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
expect(MockMediaRecorder.instances.length).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
expect(MockMediaRecorder.instances[0].state).toBe('recording');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shows Stop button while recording', async () => {
|
|
69
|
+
render(<VoiceRecorder />);
|
|
70
|
+
const recordBtn = screen.getByRole('button', { name: /start recording/i });
|
|
71
|
+
fireEvent.click(recordBtn);
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(screen.getByRole('button', { name: /stop recording/i })).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('shows Transcribing state after stopping', async () => {
|
|
79
|
+
mockFetch.mockResolvedValueOnce({
|
|
80
|
+
ok: true,
|
|
81
|
+
json: () => Promise.resolve({ notePath: '/v/notes/abc.md', transcription: 'x' }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
render(<VoiceRecorder />);
|
|
85
|
+
const recordBtn = screen.getByRole('button', { name: /start recording/i });
|
|
86
|
+
fireEvent.click(recordBtn);
|
|
87
|
+
|
|
88
|
+
const stopBtn = await screen.findByRole('button', { name: /stop recording/i });
|
|
89
|
+
fireEvent.click(stopBtn);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(screen.getByRole('button', { name: /transcribing/i })).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|