@polderlabs/bizar 4.4.13 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +116 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +86 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +68 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +18 -0
- package/bizar-dash/src/server/headroom.mjs +645 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +189 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +668 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +20 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +120 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +2 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/Memory.tsx +140 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Overview.tsx +3 -0
- package/bizar-dash/src/web/views/Settings.tsx +36 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +247 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/chat-session-stream.test.mjs — v4.2.5
|
|
3
|
+
*
|
|
4
|
+
* End-to-end tests for the opencode SSE streaming endpoints. The
|
|
5
|
+
* opencode-session-detail router proxies the upstream
|
|
6
|
+
* `/event?directory=...` stream and forwards one canonical
|
|
7
|
+
* envelope per upstream event, filtered by `sessionID`.
|
|
8
|
+
*
|
|
9
|
+
* Tests cover three guarantees:
|
|
10
|
+
*
|
|
11
|
+
* 1. `message.updated` events for the right session are forwarded,
|
|
12
|
+
* and events for other sessions are dropped.
|
|
13
|
+
* 2. Sync envelopes (the v2 wire format) are unwrapped: the inner
|
|
14
|
+
* `syncEvent.type` with version suffix (`.<n>`) becomes the
|
|
15
|
+
* canonical event name on the dashboard-side SSE stream.
|
|
16
|
+
* 3. The proxy emits a heartbeat (`: keepalive`) within the
|
|
17
|
+
* configured SSE_HEARTBEAT_MS (default 25s — we set it to 250ms
|
|
18
|
+
* via env override so the test runs in well under a second).
|
|
19
|
+
*
|
|
20
|
+
* Strategy: stand up a fake opencode serve child on a random port,
|
|
21
|
+
* point readServeInfo() at it. The fake emits three events then
|
|
22
|
+
* holds the connection open long enough for the heartbeat to land.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import {
|
|
28
|
+
mkdtempSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
copyFileSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { tmpdir } from 'node:os';
|
|
38
|
+
import { createServer } from 'node:http';
|
|
39
|
+
import express from 'express';
|
|
40
|
+
|
|
41
|
+
import { createOpencodeSessionDetailRouter } from '../src/server/routes/opencode-session-detail.mjs';
|
|
42
|
+
|
|
43
|
+
// ── fake upstream ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
let upstreamServer, upstreamPort;
|
|
46
|
+
const upstream = {
|
|
47
|
+
streamChunks: [],
|
|
48
|
+
/** When non-null, hold the SSE connection open this many ms before
|
|
49
|
+
* ending it. The heartbeat test uses this. */
|
|
50
|
+
holdMs: 0,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function startUpstream() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
upstreamServer = createServer((req, res) => {
|
|
56
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
57
|
+
if (url.pathname === '/event') {
|
|
58
|
+
res.writeHead(200, {
|
|
59
|
+
'Content-Type': 'text/event-stream',
|
|
60
|
+
'Cache-Control': 'no-cache',
|
|
61
|
+
Connection: 'keep-alive',
|
|
62
|
+
});
|
|
63
|
+
for (const c of upstream.streamChunks) res.write(c);
|
|
64
|
+
const hold = upstream.holdMs;
|
|
65
|
+
setTimeout(() => res.end(), hold);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.writeHead(404);
|
|
69
|
+
res.end('not found');
|
|
70
|
+
});
|
|
71
|
+
upstreamServer.listen(0, '127.0.0.1', () => {
|
|
72
|
+
upstreamPort = upstreamServer.address().port;
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function startDashboard() {
|
|
79
|
+
const app = express();
|
|
80
|
+
app.use(express.json({ limit: '2mb' }));
|
|
81
|
+
app.use('/api', createOpencodeSessionDetailRouter());
|
|
82
|
+
dashboardServer = createServer(app);
|
|
83
|
+
await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
|
|
84
|
+
dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let dashboardServer, dashboardBaseUrl, tmpDir;
|
|
88
|
+
let SERVE_JSON_PATH;
|
|
89
|
+
let originalServeJson;
|
|
90
|
+
|
|
91
|
+
before(async () => {
|
|
92
|
+
// Shrink the SSE heartbeat from 25s → 250ms so the heartbeat test
|
|
93
|
+
// actually exercises the path within the test runner's timeout.
|
|
94
|
+
process.env.BIZAR_SSE_HEARTBEAT_MS = '250';
|
|
95
|
+
// Use a per-PID isolated serve.json path so multiple test files
|
|
96
|
+
// don't contaminate each other.
|
|
97
|
+
SERVE_JSON_PATH = `${tmpdir()}/bizar-test-serve-stream-${process.pid}-${Date.now()}.json`;
|
|
98
|
+
process.env.BIZAR_SERVE_JSON_PATH = SERVE_JSON_PATH;
|
|
99
|
+
await startUpstream();
|
|
100
|
+
await startDashboard();
|
|
101
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'opencode-stream-'));
|
|
102
|
+
if (existsSync(SERVE_JSON_PATH)) {
|
|
103
|
+
originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
after(async () => {
|
|
108
|
+
if (originalServeJson !== undefined) {
|
|
109
|
+
writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
|
|
110
|
+
} else if (existsSync(SERVE_JSON_PATH)) {
|
|
111
|
+
rmSync(SERVE_JSON_PATH);
|
|
112
|
+
}
|
|
113
|
+
if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
|
|
114
|
+
if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
|
|
115
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
116
|
+
delete process.env.BIZAR_SSE_HEARTBEAT_MS;
|
|
117
|
+
delete process.env.BIZAR_SERVE_JSON_PATH;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
upstream.streamChunks = [];
|
|
122
|
+
upstream.holdMs = 0;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function writeServeJson() {
|
|
126
|
+
writeFileSync(
|
|
127
|
+
SERVE_JSON_PATH,
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
port: upstreamPort,
|
|
130
|
+
password: 'test-pw',
|
|
131
|
+
worktree: '/tmp/stream-worktree',
|
|
132
|
+
pid: 99999,
|
|
133
|
+
startedAt: Date.now(),
|
|
134
|
+
}),
|
|
135
|
+
'utf8',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function moveServeJsonAside() {
|
|
140
|
+
if (!existsSync(SERVE_JSON_PATH)) return;
|
|
141
|
+
const tmp = join(tmpDir, `serve-aside-${Date.now()}-${process.pid}.json`);
|
|
142
|
+
copyFileSync(SERVE_JSON_PATH, tmp);
|
|
143
|
+
unlinkSync(SERVE_JSON_PATH);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseSseStream(text) {
|
|
147
|
+
const out = [];
|
|
148
|
+
let event = null, dataLines = [];
|
|
149
|
+
for (const line of text.split(/\r?\n/)) {
|
|
150
|
+
if (line === '') {
|
|
151
|
+
if (event !== null || dataLines.length > 0) {
|
|
152
|
+
out.push({ event, data: dataLines.join('\n') });
|
|
153
|
+
}
|
|
154
|
+
event = null; dataLines = [];
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (line.startsWith(':')) continue;
|
|
158
|
+
const colon = line.indexOf(':');
|
|
159
|
+
if (colon < 0) continue;
|
|
160
|
+
const field = line.slice(0, colon);
|
|
161
|
+
let value = line.slice(colon + 1);
|
|
162
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
163
|
+
if (field === 'event') event = value;
|
|
164
|
+
else if (field === 'data') dataLines.push(value);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── tests ─────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
test('SSE forwards message.updated for the requested session, drops others', async () => {
|
|
172
|
+
writeServeJson();
|
|
173
|
+
upstream.streamChunks = [
|
|
174
|
+
// Direct envelope (older wire format). properties.sessionID.
|
|
175
|
+
'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
|
|
176
|
+
// Wrong session: must be filtered.
|
|
177
|
+
'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"OTHER-id","messageID":"m9"}}\n\n',
|
|
178
|
+
// Sync envelope (newer wire format).
|
|
179
|
+
'event: sync\ndata: {"type":"sync","syncEvent":{"type":"message.updated.1","data":{"sessionID":"our-id","messageID":"m3"}}}\n\n',
|
|
180
|
+
];
|
|
181
|
+
const res = await fetch(
|
|
182
|
+
`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
|
|
183
|
+
);
|
|
184
|
+
assert.equal(res.status, 200);
|
|
185
|
+
assert.match(res.headers.get('content-type'), /text\/event-stream/);
|
|
186
|
+
assert.match(res.headers.get('cache-control'), /no-cache/);
|
|
187
|
+
assert.equal(res.headers.get('x-accel-buffering'), 'no');
|
|
188
|
+
|
|
189
|
+
const events = parseSseStream(await res.text());
|
|
190
|
+
// 1: message.updated (our-id)
|
|
191
|
+
// 2: message.updated from sync envelope (our-id)
|
|
192
|
+
// The OTHER-id message.updated is dropped.
|
|
193
|
+
assert.equal(events.length, 2);
|
|
194
|
+
assert.equal(events[0].event, 'message.updated');
|
|
195
|
+
const d0 = JSON.parse(events[0].data);
|
|
196
|
+
assert.equal(d0.sessionID, 'our-id');
|
|
197
|
+
assert.equal(d0.messageID, 'm1');
|
|
198
|
+
assert.equal(events[1].event, 'message.updated');
|
|
199
|
+
const d1 = JSON.parse(events[1].data);
|
|
200
|
+
assert.equal(d1.sessionID, 'our-id');
|
|
201
|
+
assert.equal(d1.messageID, 'm3');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('SSE unwraps sync envelopes and strips the version suffix from the event name', async () => {
|
|
205
|
+
writeServeJson();
|
|
206
|
+
upstream.streamChunks = [
|
|
207
|
+
'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
|
|
208
|
+
];
|
|
209
|
+
const res = await fetch(
|
|
210
|
+
`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
|
|
211
|
+
);
|
|
212
|
+
assert.equal(res.status, 200);
|
|
213
|
+
const events = parseSseStream(await res.text());
|
|
214
|
+
assert.equal(events.length, 1);
|
|
215
|
+
assert.equal(events[0].event, 'session.idle');
|
|
216
|
+
const d = JSON.parse(events[0].data);
|
|
217
|
+
assert.equal(d.sessionID, 'our-id');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('SSE does NOT emit redundant chat:delta / chat:status aliases (only canonical events)', async () => {
|
|
221
|
+
// v4.2.5 fix: prior version emitted chat:delta/chat:status aliases
|
|
222
|
+
// in ADDITION to the canonical event, causing downstream listeners
|
|
223
|
+
// to see each idle/delta event twice. The proxy now emits one
|
|
224
|
+
// canonical envelope per upstream event — no aliases.
|
|
225
|
+
writeServeJson();
|
|
226
|
+
upstream.streamChunks = [
|
|
227
|
+
'event: message.part.updated\ndata: {"type":"message.part.updated.1","properties":{"sessionID":"our-id","messageID":"m1","part":{"type":"text","text":"hello"}}}\n\n',
|
|
228
|
+
'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
|
|
229
|
+
];
|
|
230
|
+
const res = await fetch(
|
|
231
|
+
`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
|
|
232
|
+
);
|
|
233
|
+
const events = parseSseStream(await res.text());
|
|
234
|
+
// Only 2 events expected — one part.updated and one session.idle.
|
|
235
|
+
// We do NOT want chat:delta or chat:status emitted here.
|
|
236
|
+
assert.equal(events.length, 2);
|
|
237
|
+
const evNames = events.map((e) => e.event);
|
|
238
|
+
assert.ok(evNames.includes('message.part.updated'));
|
|
239
|
+
assert.ok(evNames.includes('session.idle'));
|
|
240
|
+
assert.equal(evNames.filter((n) => n === 'chat:status').length, 0);
|
|
241
|
+
assert.equal(evNames.filter((n) => n === 'chat:delta').length, 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('SSE emits a heartbeat (": keepalive") within BIZAR_SSE_HEARTBEAT_MS', async () => {
|
|
245
|
+
// Override forces a 250ms heartbeat (set in `before`). We hold
|
|
246
|
+
// the upstream connection open for 600ms so the heartbeat fires
|
|
247
|
+
// at least once after the initial chunks land.
|
|
248
|
+
writeServeJson();
|
|
249
|
+
upstream.holdMs = 600;
|
|
250
|
+
upstream.streamChunks = [
|
|
251
|
+
'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
|
|
252
|
+
];
|
|
253
|
+
const t0 = Date.now();
|
|
254
|
+
const res = await fetch(
|
|
255
|
+
`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
|
|
256
|
+
);
|
|
257
|
+
const text = await res.text();
|
|
258
|
+
const elapsed = Date.now() - t0;
|
|
259
|
+
// Body should be at least one blank line-separated chunk containing
|
|
260
|
+
// a `: keepalive` heartbeat comment. We assert the raw body has
|
|
261
|
+
// `: keepalive` somewhere and the total wait was longer than the
|
|
262
|
+
// heartbeat interval (250ms).
|
|
263
|
+
assert.ok(
|
|
264
|
+
text.includes(': keepalive'),
|
|
265
|
+
`expected ": keepalive" in body — got: ${text.slice(0, 500)}`,
|
|
266
|
+
);
|
|
267
|
+
assert.ok(elapsed >= 250, `expected >= 250ms elapsed, got ${elapsed}ms`);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('SSE returns the response in <50ms when upstream immediately errors (plugin offline)', async () => {
|
|
271
|
+
// Move serve.json aside so the proxy sees `plugin_offline`.
|
|
272
|
+
moveServeJsonAside();
|
|
273
|
+
try {
|
|
274
|
+
const t0 = Date.now();
|
|
275
|
+
const res = await fetch(
|
|
276
|
+
`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
|
|
277
|
+
);
|
|
278
|
+
const elapsed = Date.now() - t0;
|
|
279
|
+
assert.equal(res.status, 200);
|
|
280
|
+
assert.match(res.headers.get('content-type'), /text\/event-stream/);
|
|
281
|
+
const text = await res.text();
|
|
282
|
+
assert.ok(text.includes('plugin_offline'));
|
|
283
|
+
assert.ok(elapsed < 200, `expected < 200ms, got ${elapsed}ms`);
|
|
284
|
+
} finally {
|
|
285
|
+
if (originalServeJson !== undefined) {
|
|
286
|
+
writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
|
|
287
|
+
} else {
|
|
288
|
+
writeServeJson();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('SSE handles whitespace-only session id gracefully (no 500 crash)', async () => {
|
|
294
|
+
writeServeJson();
|
|
295
|
+
// Whitespace session id: server resolves `decodeURIComponent('%20')`
|
|
296
|
+
// to a single space, which is non-empty; the proxy tries to filter
|
|
297
|
+
// events and never matches → empty stream, but never 5xx.
|
|
298
|
+
const res = await fetch(
|
|
299
|
+
`${dashboardBaseUrl}/api/opencode-sessions/%20/stream`,
|
|
300
|
+
);
|
|
301
|
+
// The handler may return 200 (empty stream) or 503 (directory
|
|
302
|
+
// unknown because the worktree doesn't contain ` ` session).
|
|
303
|
+
// Critically it must NOT 5xx or return undefined.
|
|
304
|
+
assert.ok(
|
|
305
|
+
res.status === 200 || res.status === 503,
|
|
306
|
+
`unexpected ${res.status} for whitespace session id`,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-vars-store.test.mjs — tests for the env-vars route and loadEnvJson.
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test bizar-dash/tests/env-vars-store.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync, statSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
|
|
12
|
+
const REPO = resolve(import.meta.dirname, '..', '..');
|
|
13
|
+
|
|
14
|
+
// Sandbox process.env for isolation
|
|
15
|
+
let SANDBOX_HOME;
|
|
16
|
+
let ORIGINAL_HOME;
|
|
17
|
+
let BIZAR_DIR;
|
|
18
|
+
let ENV_FILE;
|
|
19
|
+
|
|
20
|
+
before(() => {
|
|
21
|
+
SANDBOX_HOME = mkdtempSync(join(tmpdir(), `bizar-env-vars-test-${Date.now()}-`));
|
|
22
|
+
ORIGINAL_HOME = process.env.HOME;
|
|
23
|
+
process.env.HOME = SANDBOX_HOME;
|
|
24
|
+
BIZAR_DIR = join(SANDBOX_HOME, '.config', 'bizar');
|
|
25
|
+
ENV_FILE = join(BIZAR_DIR, 'env.json');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
after(() => {
|
|
29
|
+
process.env.HOME = ORIGINAL_HOME;
|
|
30
|
+
// Clean up any lingering BIZAR_* test vars
|
|
31
|
+
for (const k of Object.keys(process.env)) {
|
|
32
|
+
if (k.startsWith('BIZAR_TEST_')) delete process.env[k];
|
|
33
|
+
}
|
|
34
|
+
try { rmSync(SANDBOX_HOME, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Helper to create a supertest-like request helper via native http
|
|
38
|
+
async function request(app, method, path, body) {
|
|
39
|
+
const http = await import('node:http');
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const server = app.listen(0, '127.0.0.1', () => {
|
|
42
|
+
const { port } = server.address();
|
|
43
|
+
const bodyStr = body ? JSON.stringify(body) : '';
|
|
44
|
+
const opts = {
|
|
45
|
+
hostname: '127.0.0.1',
|
|
46
|
+
port,
|
|
47
|
+
path,
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
'Content-Length': Buffer.byteLength(bodyStr),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const req = http.request(opts, (res) => {
|
|
55
|
+
let data = '';
|
|
56
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
57
|
+
res.on('end', () => {
|
|
58
|
+
server.close();
|
|
59
|
+
try {
|
|
60
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
61
|
+
resolve({ status: res.statusCode, body: parsed, raw: data });
|
|
62
|
+
} catch {
|
|
63
|
+
resolve({ status: res.statusCode, body: data, raw: data });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
req.on('error', (err) => { server.close(); reject(err); });
|
|
68
|
+
if (bodyStr) req.write(bodyStr);
|
|
69
|
+
req.end();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('env-vars.mjs', () => {
|
|
75
|
+
// Build a minimal express app with just the env-vars router
|
|
76
|
+
let app;
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
// Reset module state between tests by deleting the env file
|
|
79
|
+
try { rmSync(ENV_FILE); } catch { /* ignore */ }
|
|
80
|
+
// Force-reload the module for fresh state
|
|
81
|
+
const express = (await import('express')).default;
|
|
82
|
+
const { createEnvVarsRouter, loadEnvJson, resetStore } = await import(`${REPO}/bizar-dash/src/server/routes/env-vars.mjs`);
|
|
83
|
+
resetStore();
|
|
84
|
+
app = express();
|
|
85
|
+
app.use(express.json());
|
|
86
|
+
app.use('/api', createEnvVarsRouter());
|
|
87
|
+
// Clear any BIZAR_TEST_ vars left from previous tests
|
|
88
|
+
for (const k of Object.keys(process.env)) {
|
|
89
|
+
if (k.startsWith('BIZAR_TEST_')) delete process.env[k];
|
|
90
|
+
}
|
|
91
|
+
app.use(createEnvVarsRouter());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('GET /api/env-vars returns empty list when file does not exist', async () => {
|
|
95
|
+
const res = await request(app, 'GET', '/api/env-vars');
|
|
96
|
+
assert.equal(res.status, 200);
|
|
97
|
+
assert.ok(Array.isArray(res.body), 'response should be an array');
|
|
98
|
+
assert.equal(res.body.length, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('POST creates entry, GET lists it with masked value', async () => {
|
|
102
|
+
const create = await request(app, 'POST', '/api/env-vars', {
|
|
103
|
+
name: 'BIZAR_TEST_KEY',
|
|
104
|
+
value: 'super-secret-value-123',
|
|
105
|
+
});
|
|
106
|
+
assert.equal(create.status, 201);
|
|
107
|
+
assert.equal(create.body.name, 'BIZAR_TEST_KEY');
|
|
108
|
+
assert.ok(create.body.value.includes('*'), 'value should be masked');
|
|
109
|
+
assert.ok(create.body.value.endsWith('-123'), 'masked value should include last 4 chars');
|
|
110
|
+
assert.ok(create.body.createdAt, 'should have createdAt');
|
|
111
|
+
|
|
112
|
+
const list = await request(app, 'GET', '/api/env-vars');
|
|
113
|
+
assert.equal(list.status, 200);
|
|
114
|
+
const entry = list.body.find((e) => e.name === 'BIZAR_TEST_KEY');
|
|
115
|
+
assert.ok(entry, 'entry should be in list');
|
|
116
|
+
assert.equal(entry.value, create.body.value, 'value should be masked on list');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('PUT updates entry, DELETE removes it', async () => {
|
|
120
|
+
await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_PUT', value: 'original' });
|
|
121
|
+
const update = await request(app, 'PUT', '/api/env-vars/BIZAR_TEST_PUT', { value: 'updated-value' });
|
|
122
|
+
assert.equal(update.status, 200);
|
|
123
|
+
assert.ok(update.body.value.endsWith('lue'), 'updated value should be masked');
|
|
124
|
+
|
|
125
|
+
// Verify in list
|
|
126
|
+
const list = await request(app, 'GET', '/api/env-vars');
|
|
127
|
+
const entry = list.body.find((e) => e.name === 'BIZAR_TEST_PUT');
|
|
128
|
+
assert.ok(entry);
|
|
129
|
+
|
|
130
|
+
// Delete
|
|
131
|
+
const del = await request(app, 'DELETE', '/api/env-vars/BIZAR_TEST_PUT');
|
|
132
|
+
assert.equal(del.status, 200);
|
|
133
|
+
assert.ok(del.body.ok);
|
|
134
|
+
|
|
135
|
+
// Verify gone
|
|
136
|
+
const listAfter = await request(app, 'GET', '/api/env-vars');
|
|
137
|
+
const gone = listAfter.body.find((e) => e.name === 'BIZAR_TEST_PUT');
|
|
138
|
+
assert.equal(gone, undefined, 'entry should be deleted');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('rejects invalid name (must match /^BIZAR_[A-Z0-9_]+$/)', async () => {
|
|
142
|
+
const r1 = await request(app, 'POST', '/api/env-vars', { name: 'INVALID', value: 'x' });
|
|
143
|
+
assert.equal(r1.status, 400);
|
|
144
|
+
assert.ok(r1.body.error === 'invalid_name');
|
|
145
|
+
|
|
146
|
+
const r2 = await request(app, 'POST', '/api/env-vars', { name: 'bizar_lowercase', value: 'x' });
|
|
147
|
+
assert.equal(r2.status, 400);
|
|
148
|
+
|
|
149
|
+
const r3 = await request(app, 'POST', '/api/env-vars', { name: 'BIZAR HAS_SPACE', value: 'x' });
|
|
150
|
+
assert.equal(r3.status, 400);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('creates env.json with mode 0600', async () => {
|
|
154
|
+
await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_PERM', value: 'perm-check' });
|
|
155
|
+
const mode = statSync(ENV_FILE).mode & 0o777;
|
|
156
|
+
assert.equal(mode, 0o600, `expected 0o600, got ${octal(mode)}`);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('loadEnvJson sets process.env', async () => {
|
|
160
|
+
// Create an env var
|
|
161
|
+
await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_LOADENV', value: 'env-loaded-value' });
|
|
162
|
+
|
|
163
|
+
// Delete from process.env to simulate fresh start
|
|
164
|
+
delete process.env.BIZAR_TEST_LOADENV;
|
|
165
|
+
|
|
166
|
+
// Call loadEnvJson
|
|
167
|
+
const { loadEnvJson } = await import(`${REPO}/bizar-dash/src/server/routes/env-vars.mjs`);
|
|
168
|
+
loadEnvJson();
|
|
169
|
+
|
|
170
|
+
assert.equal(process.env.BIZAR_TEST_LOADENV, 'env-loaded-value',
|
|
171
|
+
'process.env should be set after loadEnvJson');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('DELETE removes from process.env', async () => {
|
|
175
|
+
// Create
|
|
176
|
+
await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_DELPROCESS', value: 'to-be-deleted' });
|
|
177
|
+
assert.equal(process.env.BIZAR_TEST_DELPROCESS, 'to-be-deleted');
|
|
178
|
+
|
|
179
|
+
// Delete via API
|
|
180
|
+
await request(app, 'DELETE', '/api/env-vars/BIZAR_TEST_DELPROCESS');
|
|
181
|
+
|
|
182
|
+
assert.equal(process.env.BIZAR_TEST_DELPROCESS, undefined,
|
|
183
|
+
'process.env should be cleared after DELETE');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('POST /api/env-vars/:name/test returns referenced and inProcessEnv', async () => {
|
|
187
|
+
// Not in process.env yet
|
|
188
|
+
const r1 = await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_REFS', value: 'refs-val' });
|
|
189
|
+
assert.equal(r1.status, 201);
|
|
190
|
+
|
|
191
|
+
const test = await request(app, 'POST', '/api/env-vars/BIZAR_TEST_REFS/test');
|
|
192
|
+
assert.equal(test.status, 200);
|
|
193
|
+
assert.ok(typeof test.body.referenced === 'boolean');
|
|
194
|
+
assert.ok(typeof test.body.inProcessEnv === 'boolean');
|
|
195
|
+
assert.equal(test.body.inProcessEnv, true, 'should be in process.env after creation');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('409 on duplicate POST', async () => {
|
|
199
|
+
await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_DUP', value: 'first' });
|
|
200
|
+
const r = await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_DUP', value: 'second' });
|
|
201
|
+
assert.equal(r.status, 409);
|
|
202
|
+
assert.ok(r.body.error === 'already_exists');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('404 on PUT/DELETE non-existent var', async () => {
|
|
206
|
+
const put = await request(app, 'PUT', '/api/env-vars/BIZAR_NONEXISTENT', { value: 'x' });
|
|
207
|
+
assert.equal(put.status, 404);
|
|
208
|
+
|
|
209
|
+
const del = await request(app, 'DELETE', '/api/env-vars/BIZAR_NONEXISTENT');
|
|
210
|
+
assert.equal(del.status, 404);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function octal(n) {
|
|
215
|
+
return '0o' + n.toString(8);
|
|
216
|
+
}
|