@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.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,187 @@
1
+ /**
2
+ * tasks-create.test.mjs — tests for POST /api/tasks (no agent selection).
3
+ *
4
+ * Run with: node --test bizar-dash/tests/tasks-create.test.mjs
5
+ *
6
+ * Strategy: build a minimal Express app with just the tasks router
7
+ * (mounted on /api). Sandbox HOME so the tasks-store writes to a
8
+ * tmpdir instead of ~/.config/bizar. Pass an explicit projectId in
9
+ * every request body so we don't depend on the active project.
10
+ */
11
+ import { describe, it, before, after, beforeEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import express from 'express';
14
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+
18
+ const REPO = resolve(import.meta.dirname, '..', '..');
19
+
20
+ let SANDBOX_HOME;
21
+ let ORIGINAL_HOME;
22
+ let app;
23
+ let broadcastEvents;
24
+ let tasksFilePath;
25
+
26
+ before(async () => {
27
+ SANDBOX_HOME = mkdtempSync(join(tmpdir(), `bizar-tasks-test-${Date.now()}-`));
28
+ ORIGINAL_HOME = process.env.HOME;
29
+ process.env.HOME = SANDBOX_HOME;
30
+ // tasksStore writes to ~/.config/opencode/projects/<id>/tasks.json
31
+ // (projects-store.mjs:PROJECTS_DIR).
32
+ tasksFilePath = join(SANDBOX_HOME, '.config', 'opencode', 'projects', 'test-proj', 'tasks.json');
33
+ });
34
+
35
+ after(() => {
36
+ process.env.HOME = ORIGINAL_HOME;
37
+ try { rmSync(SANDBOX_HOME, { recursive: true, force: true }); } catch { /* ignore */ }
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ broadcastEvents = [];
42
+ // Reset file between tests so each one starts clean.
43
+ try { rmSync(tasksFilePath); } catch { /* ignore */ }
44
+ // Fresh import of the router — build a minimal app.
45
+ const { createTasksRouter } = await import(
46
+ `${REPO}/bizar-dash/src/server/routes/tasks.mjs`
47
+ );
48
+ app = express();
49
+ app.use(express.json());
50
+ app.use('/api', createTasksRouter({
51
+ state: {},
52
+ broadcast: (msg) => broadcastEvents.push(msg),
53
+ projectRoot: SANDBOX_HOME,
54
+ }));
55
+ });
56
+
57
+ async function request(method, path, body) {
58
+ const http = await import('node:http');
59
+ return new Promise((resolveP, reject) => {
60
+ const server = app.listen(0, '127.0.0.1', () => {
61
+ const { port } = server.address();
62
+ const bodyStr = body ? JSON.stringify(body) : '';
63
+ const req = http.request({
64
+ hostname: '127.0.0.1',
65
+ port,
66
+ path,
67
+ method,
68
+ headers: bodyStr
69
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }
70
+ : {},
71
+ }, (res) => {
72
+ let data = '';
73
+ res.on('data', (c) => { data += c; });
74
+ res.on('end', () => {
75
+ server.close();
76
+ try {
77
+ resolveP({ status: res.statusCode, body: JSON.parse(data) });
78
+ } catch {
79
+ resolveP({ status: res.statusCode, body: data });
80
+ }
81
+ });
82
+ });
83
+ req.on('error', (err) => { server.close(); reject(err); });
84
+ if (bodyStr) req.write(bodyStr);
85
+ req.end();
86
+ });
87
+ });
88
+ }
89
+
90
+ describe('POST /api/tasks — simplified create flow (no agent selection)', () => {
91
+ it('creates a task with just title + description, returning 201', async () => {
92
+ const res = await request('POST', '/api/tasks', {
93
+ projectId: 'test-proj',
94
+ title: 'Test task',
95
+ description: 'A simple task without any agent field.',
96
+ });
97
+ assert.equal(res.status, 201, `expected 201, got ${res.status}: ${JSON.stringify(res.body)}`);
98
+ assert.ok(res.body.id, 'response should include id');
99
+ assert.equal(res.body.title, 'Test task');
100
+ assert.equal(res.body.description, 'A simple task without any agent field.');
101
+ });
102
+
103
+ it('does not store any agent-related field when none is provided', async () => {
104
+ const res = await request('POST', '/api/tasks', {
105
+ projectId: 'test-proj',
106
+ title: 'No agent task',
107
+ description: 'Just title and description.',
108
+ });
109
+ assert.equal(res.status, 201);
110
+ // The store does not have an `agent` field — it has `assignee`
111
+ // and `workedBy`. Both must be null/empty since no agent was picked.
112
+ assert.equal(res.body.assignee, null, 'assignee should be null when not provided');
113
+ assert.equal(res.body.workedBy, null, 'workedBy should be null when not provided');
114
+ // Defensive: no field literally named `agent` should leak through.
115
+ assert.equal(res.body.agent, undefined, 'response should not have an `agent` field');
116
+ });
117
+
118
+ it('still respects an explicitly passed assignee (backward compat)', async () => {
119
+ // The store API has always accepted `assignee`. We don't break it —
120
+ // we just don't ask for it in the simplified UI. Tests that callers
121
+ // can still set it explicitly when they want to.
122
+ const res = await request('POST', '/api/tasks', {
123
+ projectId: 'test-proj',
124
+ title: 'Manually assigned',
125
+ description: 'Caller passed assignee explicitly.',
126
+ assignee: 'odin',
127
+ });
128
+ assert.equal(res.status, 201);
129
+ assert.equal(res.body.assignee, 'odin');
130
+ });
131
+
132
+ it('ignores an unrecognized `agent` field (does not store it)', async () => {
133
+ const res = await request('POST', '/api/tasks', {
134
+ projectId: 'test-proj',
135
+ title: 'With unknown field',
136
+ description: 'Caller passed agent field by mistake.',
137
+ agent: 'thor',
138
+ });
139
+ assert.equal(res.status, 201);
140
+ // The store doesn't have an `agent` field. Any incoming `agent`
141
+ // is just an unknown prop and should not appear on the stored task.
142
+ assert.equal(res.body.agent, undefined, 'agent field should not be stored');
143
+ assert.equal(res.body.assignee, null, 'assignee should remain null');
144
+ });
145
+
146
+ it('persists the task to disk with no agent data', async () => {
147
+ const res = await request('POST', '/api/tasks', {
148
+ projectId: 'test-proj',
149
+ title: 'Persisted',
150
+ description: 'Check disk state.',
151
+ });
152
+ assert.equal(res.status, 201);
153
+
154
+ assert.ok(existsSync(tasksFilePath), 'tasks.json should be written to disk');
155
+ const stored = JSON.parse(readFileSync(tasksFilePath, 'utf8'));
156
+ assert.equal(stored.tasks.length, 1);
157
+ const task = stored.tasks[0];
158
+ assert.equal(task.title, 'Persisted');
159
+ assert.equal(task.assignee, null);
160
+ assert.equal(task.workedBy, null);
161
+ assert.equal(task.agent, undefined);
162
+ });
163
+
164
+ it('rejects create without a title (non-201 with title-related error)', async () => {
165
+ const res = await request('POST', '/api/tasks', {
166
+ projectId: 'test-proj',
167
+ description: 'No title — should fail.',
168
+ });
169
+ assert.notEqual(res.status, 201, 'should not succeed without a title');
170
+ assert.ok(
171
+ res.body.message?.toLowerCase().includes('title'),
172
+ `error message should mention title, got: ${JSON.stringify(res.body)}`,
173
+ );
174
+ });
175
+
176
+ it('emits a tasks:change broadcast event', async () => {
177
+ const res = await request('POST', '/api/tasks', {
178
+ projectId: 'test-proj',
179
+ title: 'Broadcast test',
180
+ description: 'Should fire tasks:change.',
181
+ });
182
+ assert.equal(res.status, 201);
183
+ assert.equal(broadcastEvents.length, 1, 'should emit exactly one event');
184
+ assert.equal(broadcastEvents[0].type, 'tasks:change');
185
+ assert.equal(broadcastEvents[0].task.title, 'Broadcast test');
186
+ });
187
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * update-check.test.mjs — tests for /api/updates/status and /api/updates/check.
3
+ *
4
+ * Run with: node --test bizar-dash/tests/update-check.test.mjs
5
+ *
6
+ * Strategy: import the module once, call `resetUpdateCache()` between
7
+ * tests. The route reads through the module-level cache path on every
8
+ * request. In this sandbox, `npm view` may or may not work — the route
9
+ * must degrade gracefully (null latest, hasUpdates=false) on failure.
10
+ */
11
+ import { describe, it, beforeEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import express from 'express';
14
+ import { resolve } from 'node:path';
15
+
16
+ const REPO = resolve(import.meta.dirname, '..', '..');
17
+
18
+ const { createUpdateRouter, resetUpdateCache } = await import(
19
+ `${REPO}/bizar-dash/src/server/routes/update.mjs`
20
+ );
21
+
22
+ let app;
23
+ beforeEach(async () => {
24
+ resetUpdateCache();
25
+ app = express();
26
+ app.use(express.json());
27
+ app.use('/api', createUpdateRouter({ broadcast: () => {} }));
28
+ });
29
+
30
+ async function request(app, method, path, body) {
31
+ const http = await import('node:http');
32
+ return new Promise((resolveP, reject) => {
33
+ const server = app.listen(0, '127.0.0.1', () => {
34
+ const { port } = server.address();
35
+ const bodyStr = body ? JSON.stringify(body) : '';
36
+ const req = http.request({
37
+ hostname: '127.0.0.1',
38
+ port,
39
+ path,
40
+ method,
41
+ headers: bodyStr
42
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }
43
+ : {},
44
+ }, (res) => {
45
+ let data = '';
46
+ res.on('data', (c) => { data += c; });
47
+ res.on('end', () => {
48
+ server.close();
49
+ try {
50
+ resolveP({ status: res.statusCode, body: JSON.parse(data) });
51
+ } catch {
52
+ resolveP({ status: res.statusCode, body: data });
53
+ }
54
+ });
55
+ });
56
+ req.on('error', (err) => { server.close(); reject(err); });
57
+ if (bodyStr) req.write(bodyStr);
58
+ req.end();
59
+ });
60
+ });
61
+ }
62
+
63
+ describe('update.mjs — /api/updates/status', () => {
64
+ it('returns the installed package map', async () => {
65
+ const res = await request(app, 'GET', '/api/updates/status');
66
+ assert.equal(res.status, 200);
67
+ assert.ok(res.body.current, 'response should have current field');
68
+ assert.equal(typeof res.body.current, 'object');
69
+ for (const id of ['bizar', 'bizar-dash', 'bizar-plugin']) {
70
+ assert.ok(id in res.body.current, `current.${id} should be a key`);
71
+ assert.ok(
72
+ res.body.current[id] === null || typeof res.body.current[id] === 'string',
73
+ `current.${id} should be string or null`,
74
+ );
75
+ }
76
+ });
77
+
78
+ it('does not include latest/hasUpdates fields', async () => {
79
+ const res = await request(app, 'GET', '/api/updates/status');
80
+ assert.equal(res.status, 200);
81
+ assert.equal(res.body.latest, undefined, 'status should not include latest');
82
+ assert.equal(res.body.hasUpdates, undefined, 'status should not include hasUpdates');
83
+ });
84
+ });
85
+
86
+ describe('update.mjs — /api/updates/check', () => {
87
+ it('returns shape { current, latest, hasUpdates }', async () => {
88
+ const res = await request(app, 'GET', '/api/updates/check');
89
+ assert.equal(res.status, 200);
90
+ assert.ok(res.body.current, 'should have current');
91
+ assert.ok(res.body.latest, 'should have latest');
92
+ assert.equal(typeof res.body.hasUpdates, 'boolean');
93
+ for (const id of ['bizar', 'bizar-dash', 'bizar-plugin']) {
94
+ assert.ok(id in res.body.latest, `latest.${id} should be a key`);
95
+ }
96
+ });
97
+
98
+ it('does not 5xx on registry failure (graceful degradation)', async () => {
99
+ const res = await request(app, 'GET', '/api/updates/check');
100
+ assert.equal(res.status, 200, 'should not 5xx even if npm view fails');
101
+ assert.equal(typeof res.body.hasUpdates, 'boolean');
102
+ });
103
+ });
104
+
105
+ describe('update.mjs — POST /api/updates/apply', () => {
106
+ it('rejects empty/invalid package list with 400', async () => {
107
+ // Empty array → falls back to all known packages, which is valid
108
+ // (returns 202). Test instead that an unknown package id is handled
109
+ // — current implementation filters to known ids and starts the run
110
+ // if at least one id is valid. With no valid ids, returns 400.
111
+ const res = await request(app, 'POST', '/api/updates/apply', {
112
+ packages: ['__nonexistent_pkg__'],
113
+ });
114
+ assert.equal(res.status, 400);
115
+ assert.equal(res.body.error, 'bad_request');
116
+ });
117
+
118
+ it('accepts missing broadcast (default no-op)', async () => {
119
+ const localApp = express();
120
+ localApp.use(express.json());
121
+ localApp.use('/api', createUpdateRouter());
122
+ const res = await request(localApp, 'POST', '/api/updates/apply', {});
123
+ // We accept either 202 (npm succeeded) or 500 (npm not on PATH).
124
+ // The point is the route didn't crash before the handler.
125
+ assert.ok([202, 500].includes(res.status), `unexpected status ${res.status}`);
126
+ });
127
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * update-run.test.mjs — tests for /api/updates/apply progress streaming.
3
+ *
4
+ * Run with: node --test bizar-dash/tests/update-run.test.mjs
5
+ *
6
+ * Strategy: stub `node:child_process` so `spawn` returns a controllable
7
+ * fake process that emits scripted stdout/stderr lines and a chosen
8
+ * exit code. Capture the broadcast channel's emitted messages and
9
+ * assert they fire in the documented order.
10
+ */
11
+ import { describe, it, beforeEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import express from 'express';
14
+ import { EventEmitter } from 'node:events';
15
+ import { Readable } from 'node:stream';
16
+ import { resolve } from 'node:path';
17
+
18
+ const REPO = resolve(import.meta.dirname, '..', '..');
19
+
20
+ // ─── Spawn fake ────────────────────────────────────────────────────────────
21
+ // We monkey-patch the module loader's view of `node:child_process` so
22
+ // that update.mjs's `import { spawn } from 'node:child_process'` resolves
23
+ // to our fake.
24
+
25
+ class FakeChildProcess extends EventEmitter {
26
+ constructor() {
27
+ super();
28
+ this.stdout = new Readable({ read() {} });
29
+ this.stderr = new Readable({ read() {} });
30
+ }
31
+ // Helpers for tests
32
+ emitStdout(line) { this.stdout.push(line + '\n'); }
33
+ emitStderr(line) { this.stderr.push(line + '\n'); }
34
+ finish(code = 0) {
35
+ this.stdout.push(null);
36
+ this.stderr.push(null);
37
+ this.emit('close', code);
38
+ }
39
+ failWith(err) {
40
+ this.emit('error', err);
41
+ }
42
+ }
43
+
44
+ let fakeProc = null;
45
+ function makeFakeSpawn() {
46
+ return (_cmd, _args, _opts) => {
47
+ fakeProc = new FakeChildProcess();
48
+ return fakeProc;
49
+ };
50
+ }
51
+
52
+ // Install the fake by intercepting `node:child_process`. We do this by
53
+ // rewriting `Module._resolveFilename` for one test at a time.
54
+ import { createRequire } from 'node:module';
55
+ const require = createRequire(import.meta.url);
56
+
57
+ let originalLoad;
58
+ let patched = false;
59
+ function installFakeSpawn() {
60
+ if (patched) return;
61
+ // Override via global require cache: pre-populate the require cache
62
+ // for `node:child_process` with a stub module.
63
+ const realChild = require('node:child_process');
64
+ const stub = { ...realChild, spawn: makeFakeSpawn() };
65
+ require.cache[require.resolve('node:child_process')] = {
66
+ id: 'node:child_process',
67
+ filename: 'node:child_process',
68
+ loaded: true,
69
+ exports: stub,
70
+ };
71
+ patched = true;
72
+ }
73
+
74
+ // Load update.mjs once after we've patched child_process. We do this
75
+ // dynamically so the import picks up the stubbed `spawn` from the
76
+ // require cache that ESM bridges through.
77
+ //
78
+ // NOTE: ESM `import` does NOT use the require cache, so the patch above
79
+ // only works for CommonJS callers of `child_process`. The route imports
80
+ // `spawn` from `node:child_process` directly via ESM. To make this test
81
+ // work, we patch the actual child_process module's `spawn` property at
82
+ // runtime instead.
83
+ function patchSpawnDirectly() {
84
+ const cp = require('node:child_process');
85
+ cp.__originalSpawn = cp.__originalSpawn || cp.spawn;
86
+ cp.spawn = makeFakeSpawn();
87
+ }
88
+
89
+ function unpatchSpawn() {
90
+ const cp = require('node:child_process');
91
+ if (cp.__originalSpawn) {
92
+ cp.spawn = cp.__originalSpawn;
93
+ }
94
+ fakeProc = null;
95
+ }
96
+
97
+ // Now we need the route to use the patched spawn. update.mjs does:
98
+ // import { spawn, execFileSync } from 'node:child_process';
99
+ // In Node.js, ESM imports for built-in modules go through the loader,
100
+ // which uses a private internal cache separate from the CommonJS
101
+ // require cache. So patching require.cache alone won't redirect ESM.
102
+ //
103
+ // The reliable approach is to use a loader hook. For simplicity in
104
+ // this test, we instead use module-aliasing via a wrapper: we use a
105
+ // small middleware-only test that monkey-patches spawn on the imported
106
+ // module instance directly. Since the same module instance is shared
107
+ // across imports within a process, this works after first import.
108
+
109
+ async function importUpdateModuleFresh() {
110
+ // Use a query string to force re-import. ESM treats query strings as
111
+ // distinct specifiers, so this gives us a fresh module instance.
112
+ const url = `${REPO}/bizar-dash/src/server/routes/update.mjs?case=${Date.now()}-${Math.random()}`;
113
+ return import(url);
114
+ }
115
+
116
+ // ─── App setup ─────────────────────────────────────────────────────────────
117
+
118
+ function setupApp(broadcast) {
119
+ const app = express();
120
+ app.use(express.json());
121
+ return importUpdateModuleFresh().then((mod) => {
122
+ app.use('/api', mod.createUpdateRouter({ broadcast }));
123
+ return { app, mod };
124
+ });
125
+ }
126
+
127
+ async function request(app, method, path, body) {
128
+ const http = await import('node:http');
129
+ return new Promise((resolveP, reject) => {
130
+ const server = app.listen(0, '127.0.0.1', () => {
131
+ const { port } = server.address();
132
+ const bodyStr = body ? JSON.stringify(body) : '';
133
+ const req = http.request({
134
+ hostname: '127.0.0.1',
135
+ port,
136
+ path,
137
+ method,
138
+ headers: bodyStr
139
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }
140
+ : {},
141
+ }, (res) => {
142
+ let data = '';
143
+ res.on('data', (c) => { data += c; });
144
+ res.on('end', () => {
145
+ server.close();
146
+ try {
147
+ resolveP({ status: res.statusCode, body: JSON.parse(data) });
148
+ } catch {
149
+ resolveP({ status: res.statusCode, body: data });
150
+ }
151
+ });
152
+ });
153
+ req.on('error', (err) => { server.close(); reject(err); });
154
+ if (bodyStr) req.write(bodyStr);
155
+ req.end();
156
+ });
157
+ });
158
+ }
159
+
160
+ // ─── Wait helper ───────────────────────────────────────────────────────────
161
+
162
+ function waitFor(predicate, { timeoutMs = 4000, intervalMs = 10 } = {}) {
163
+ return new Promise((resolveP, reject) => {
164
+ const start = Date.now();
165
+ const tick = () => {
166
+ if (predicate()) {
167
+ resolveP();
168
+ return;
169
+ }
170
+ if (Date.now() - start > timeoutMs) {
171
+ reject(new Error(`waitFor timed out after ${timeoutMs}ms`));
172
+ return;
173
+ }
174
+ setTimeout(tick, intervalMs);
175
+ };
176
+ tick();
177
+ });
178
+ }
179
+
180
+ // ─── Tests ─────────────────────────────────────────────────────────────────
181
+
182
+ describe('update.mjs — apply run broadcasts events in order', () => {
183
+ let broadcast;
184
+ beforeEach(() => {
185
+ patchSpawnDirectly();
186
+ broadcast = [];
187
+ });
188
+
189
+ it('emits starting → log → done → complete for a successful install', async () => {
190
+ const { app } = await setupApp((msg) => broadcast.push(msg));
191
+ const res = await request(app, 'POST', '/api/updates/apply', { packages: ['bizar'] });
192
+ assert.equal(res.status, 202);
193
+ assert.deepEqual(res.body.started, ['bizar']);
194
+
195
+ // The first event should be a `starting` progress for `bizar`
196
+ await waitFor(() => broadcast.length >= 1);
197
+ assert.equal(broadcast[0].type, 'update:progress');
198
+ assert.equal(broadcast[0].pkg, 'bizar');
199
+ assert.equal(broadcast[0].status, 'starting');
200
+
201
+ // Now drive the fake spawn to emit log lines + finish
202
+ assert.ok(fakeProc, 'fakeProc should exist after request');
203
+ fakeProc.emitStdout('npm WARN deprecated foo');
204
+ fakeProc.emitStdout('added 1 package in 2s');
205
+ fakeProc.finish(0);
206
+
207
+ // Wait for the complete event
208
+ await waitFor(() => broadcast.some((m) => m.type === 'update:complete'), { timeoutMs: 5000 });
209
+
210
+ // Verify ordering and types
211
+ const types = broadcast.map((m) => `${m.type}${m.pkg ? `:${m.pkg}` : ''}:${m.status ?? ''}`);
212
+ assert.ok(types.includes('update:progress:bizar:starting'), 'should have starting progress');
213
+ assert.ok(types.includes('update:log:bizar:'), 'should have at least one log');
214
+ assert.ok(types.includes('update:progress:bizar:done'), 'should have done progress');
215
+ assert.ok(types.at(-1) === 'update:complete:', 'last event should be update:complete');
216
+
217
+ // Done event should carry newVersion
218
+ const doneEvent = broadcast.find((m) => m.type === 'update:progress' && m.status === 'done');
219
+ assert.ok(doneEvent, 'done event should exist');
220
+ assert.ok('newVersion' in doneEvent, 'done event should carry newVersion field');
221
+
222
+ unpatchSpawn();
223
+ });
224
+
225
+ it('emits error progress + complete when npm exits non-zero', async () => {
226
+ const { app } = await setupApp((msg) => broadcast.push(msg));
227
+ const res = await request(app, 'POST', '/api/updates/apply', { packages: ['bizar'] });
228
+ assert.equal(res.status, 202);
229
+
230
+ await waitFor(() => broadcast.length >= 1);
231
+ assert.ok(fakeProc, 'fakeProc should exist');
232
+ fakeProc.emitStderr('npm ERR! code EACCES');
233
+ fakeProc.finish(1);
234
+
235
+ await waitFor(() => broadcast.some((m) => m.type === 'update:complete'));
236
+
237
+ const errorEvent = broadcast.find((m) => m.type === 'update:progress' && m.status === 'error');
238
+ assert.ok(errorEvent, 'should have error progress event');
239
+ assert.ok(errorEvent.error, 'error event should have error message');
240
+ assert.match(errorEvent.error, /exited with code 1/);
241
+
242
+ const lastEvent = broadcast.at(-1);
243
+ assert.equal(lastEvent.type, 'update:complete');
244
+
245
+ unpatchSpawn();
246
+ });
247
+
248
+ it('returns 409 when an apply is already running', async () => {
249
+ const { app } = await setupApp(() => {});
250
+ // First call — kicks off the run but the fake proc never closes,
251
+ // so the run is "active" for the duration of the test.
252
+ const first = await request(app, 'POST', '/api/updates/apply', { packages: ['bizar'] });
253
+ assert.equal(first.status, 202);
254
+
255
+ // Second call — should hit the concurrency guard
256
+ const second = await request(app, 'POST', '/api/updates/apply', { packages: ['bizar'] });
257
+ assert.equal(second.status, 409);
258
+ assert.equal(second.body.error, 'already_running');
259
+
260
+ // Cleanup: close the fake proc so the run's finally clears the guard
261
+ if (fakeProc) fakeProc.finish(0);
262
+ // Give the async run a moment to clear
263
+ await new Promise((r) => setTimeout(r, 50));
264
+ unpatchSpawn();
265
+ });
266
+ });