@rsktash/beads-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,147 @@
1
+ import { createServer } from 'node:http';
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
3
+ import { runBd } from './bd.js';
4
+ import { fetchListForSubscription } from './list-adapters.js';
5
+ import { attachWsServer, handleMessage, scheduleListRefresh } from './ws.js';
6
+
7
+ vi.mock('./bd.js', () => ({ runBdJson: vi.fn(), runBd: vi.fn() }));
8
+ vi.mock('./list-adapters.js', () => ({
9
+ fetchListForSubscription: vi.fn(async () => {
10
+ return {
11
+ ok: true,
12
+ items: [
13
+ { id: 'A', updated_at: 1, closed_at: null },
14
+ { id: 'B', updated_at: 1, closed_at: null }
15
+ ]
16
+ };
17
+ })
18
+ }));
19
+
20
+ beforeEach(() => {
21
+ vi.useFakeTimers();
22
+ });
23
+
24
+ function makeSocket() {
25
+ return {
26
+ sent: /** @type {string[]} */ ([]),
27
+ readyState: 1,
28
+ OPEN: 1,
29
+ /** @param {string} msg */
30
+ send(msg) {
31
+ this.sent.push(String(msg));
32
+ }
33
+ };
34
+ }
35
+
36
+ /**
37
+ * @param {import('ws').WebSocketServer} wss
38
+ */
39
+ async function subscribeTwoLists(wss) {
40
+ const a = makeSocket();
41
+ const b = makeSocket();
42
+ wss.clients.add(/** @type {any} */ (a));
43
+ wss.clients.add(/** @type {any} */ (b));
44
+ await handleMessage(
45
+ /** @type {any} */ (a),
46
+ Buffer.from(
47
+ JSON.stringify({
48
+ id: 'l1',
49
+ type: /** @type {any} */ ('subscribe-list'),
50
+ payload: { id: 'c1', type: 'all-issues' }
51
+ })
52
+ )
53
+ );
54
+ await handleMessage(
55
+ /** @type {any} */ (b),
56
+ Buffer.from(
57
+ JSON.stringify({
58
+ id: 'l2',
59
+ type: /** @type {any} */ ('subscribe-list'),
60
+ payload: { id: 'c2', type: 'in-progress-issues' }
61
+ })
62
+ )
63
+ );
64
+ }
65
+
66
+ describe('mutation window gating', () => {
67
+ test('watcher-first resolves gate and refreshes once', async () => {
68
+ const server = createServer();
69
+ const { wss } = attachWsServer(server, {
70
+ path: '/ws',
71
+ refresh_debounce_ms: 50
72
+ });
73
+
74
+ await subscribeTwoLists(wss);
75
+
76
+ // Clear any refresh calls from initial subscriptions
77
+ const mFetch = /** @type {import('vitest').Mock} */ (
78
+ fetchListForSubscription
79
+ );
80
+ mFetch.mockClear();
81
+
82
+ // Prepare mutation stubs
83
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
84
+ mRun.mockResolvedValueOnce({ code: 0, stdout: 'UI-99', stderr: '' });
85
+
86
+ // Fire a mutation
87
+ const ws = makeSocket();
88
+ await handleMessage(
89
+ /** @type {any} */ (ws),
90
+ Buffer.from(
91
+ JSON.stringify({
92
+ id: 'create1',
93
+ type: /** @type {any} */ ('create-issue'),
94
+ payload: { title: 'X' }
95
+ })
96
+ )
97
+ );
98
+
99
+ // Simulate watcher event arriving before timeout
100
+ scheduleListRefresh();
101
+
102
+ // Allow pending promises and microtasks to flush
103
+ await vi.advanceTimersByTimeAsync(0);
104
+ await Promise.resolve();
105
+
106
+ // Exactly one refresh pass over active specs
107
+ expect(mFetch.mock.calls.length).toBe(2);
108
+ });
109
+
110
+ test('timeout-first triggers refresh after 500ms', async () => {
111
+ const server = createServer();
112
+ const { wss } = attachWsServer(server, {
113
+ path: '/ws',
114
+ refresh_debounce_ms: 50
115
+ });
116
+
117
+ await subscribeTwoLists(wss);
118
+
119
+ const mFetch = /** @type {import('vitest').Mock} */ (
120
+ fetchListForSubscription
121
+ );
122
+ mFetch.mockClear();
123
+
124
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
125
+ mRun.mockResolvedValueOnce({ code: 0, stdout: 'UI-100', stderr: '' });
126
+
127
+ const ws = makeSocket();
128
+ await handleMessage(
129
+ /** @type {any} */ (ws),
130
+ Buffer.from(
131
+ JSON.stringify({
132
+ id: 'create2',
133
+ type: /** @type {any} */ ('create-issue'),
134
+ payload: { title: 'Y' }
135
+ })
136
+ )
137
+ );
138
+
139
+ // Before timeout, no refreshes triggered
140
+ await vi.advanceTimersByTimeAsync(499);
141
+ expect(mFetch.mock.calls.length).toBe(0);
142
+
143
+ // After timeout, one refresh per active spec
144
+ await vi.advanceTimersByTimeAsync(1);
145
+ expect(mFetch.mock.calls.length).toBe(2);
146
+ });
147
+ });
@@ -0,0 +1,389 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { runBd, runBdJson } from './bd.js';
3
+ import { handleMessage } from './ws.js';
4
+
5
+ vi.mock('./bd.js', () => ({ runBdJson: vi.fn(), runBd: vi.fn() }));
6
+
7
+ // Ensure clean mock state for each test
8
+ beforeEach(() => {
9
+ /** @type {import('vitest').Mock} */ (runBd).mockReset();
10
+ /** @type {import('vitest').Mock} */ (runBdJson).mockReset();
11
+ });
12
+
13
+ function makeStubSocket() {
14
+ return {
15
+ sent: /** @type {string[]} */ ([]),
16
+ readyState: 1,
17
+ OPEN: 1,
18
+ /** @param {string} msg */
19
+ send(msg) {
20
+ this.sent.push(String(msg));
21
+ }
22
+ };
23
+ }
24
+
25
+ describe('ws mutation handlers', () => {
26
+ test('update-status validates and returns updated issue', async () => {
27
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
28
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
29
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
30
+ mJson.mockResolvedValueOnce({
31
+ code: 0,
32
+ stdoutJson: { id: 'UI-7', status: 'in_progress' }
33
+ });
34
+ const ws = makeStubSocket();
35
+ const req = {
36
+ id: 'r1',
37
+ type: 'update-status',
38
+ payload: { id: 'UI-7', status: 'in_progress' }
39
+ };
40
+ await handleMessage(
41
+ /** @type {any} */ (ws),
42
+ Buffer.from(JSON.stringify(req))
43
+ );
44
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
45
+ expect(obj.ok).toBe(true);
46
+ expect(obj.payload.status).toBe('in_progress');
47
+ });
48
+
49
+ test('update-status bd fallback hydrates parent context in response payload', async () => {
50
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
51
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
52
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
53
+ mJson
54
+ .mockResolvedValueOnce({
55
+ code: 0,
56
+ stdoutJson: {
57
+ id: 'UI-7',
58
+ status: 'in_progress',
59
+ parent: 'EP-1',
60
+ updated_at: '2024-01-01T00:00:00.000Z'
61
+ }
62
+ })
63
+ .mockResolvedValueOnce({
64
+ code: 0,
65
+ stdoutJson: {
66
+ id: 'EP-1',
67
+ title: 'Parent epic',
68
+ status: 'open',
69
+ issue_type: 'epic'
70
+ }
71
+ });
72
+
73
+ const ws = makeStubSocket();
74
+ const req = {
75
+ id: 'r1-parent',
76
+ type: 'update-status',
77
+ payload: { id: 'UI-7', status: 'in_progress' }
78
+ };
79
+ await handleMessage(
80
+ /** @type {any} */ (ws),
81
+ Buffer.from(JSON.stringify(req))
82
+ );
83
+
84
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
85
+ expect(obj.ok).toBe(true);
86
+ expect(obj.payload).toMatchObject({
87
+ id: 'UI-7',
88
+ parent: 'EP-1',
89
+ parent_id: 'EP-1',
90
+ parent_title: 'Parent epic',
91
+ parent_status: 'open',
92
+ parent_type: 'epic'
93
+ });
94
+ });
95
+
96
+ test('update-status invalid payload yields bad_request', async () => {
97
+ const ws = makeStubSocket();
98
+ const req = {
99
+ id: 'r2',
100
+ type: 'update-status',
101
+ payload: { id: 'UI-7', status: 'bogus' }
102
+ };
103
+ await handleMessage(
104
+ /** @type {any} */ (ws),
105
+ Buffer.from(JSON.stringify(req))
106
+ );
107
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
108
+ expect(obj.ok).toBe(false);
109
+ expect(obj.error.code).toBe('bad_request');
110
+ });
111
+
112
+ test('update-priority success path', async () => {
113
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
114
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
115
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
116
+ mJson.mockResolvedValueOnce({
117
+ code: 0,
118
+ stdoutJson: { id: 'UI-7', priority: 1 }
119
+ });
120
+ const ws = makeStubSocket();
121
+ const req = {
122
+ id: 'r3',
123
+ type: 'update-priority',
124
+ payload: { id: 'UI-7', priority: 1 }
125
+ };
126
+ await handleMessage(
127
+ /** @type {any} */ (ws),
128
+ Buffer.from(JSON.stringify(req))
129
+ );
130
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
131
+ expect(obj.ok).toBe(true);
132
+ expect(obj.payload.priority).toBe(1);
133
+ });
134
+
135
+ test('update-priority invalid payload yields bad_request', async () => {
136
+ const ws = makeStubSocket();
137
+ const req = {
138
+ id: 'r3bad',
139
+ type: 'update-priority',
140
+ payload: { id: 'UI-7', priority: 9 }
141
+ };
142
+ await handleMessage(
143
+ /** @type {any} */ (ws),
144
+ Buffer.from(JSON.stringify(req))
145
+ );
146
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
147
+ expect(obj.ok).toBe(false);
148
+ expect(obj.error && obj.error.code).toBe('bad_request');
149
+ });
150
+
151
+ test('edit-text title success', async () => {
152
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
153
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
154
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
155
+ mJson.mockResolvedValueOnce({
156
+ code: 0,
157
+ stdoutJson: { id: 'UI-7', title: 'New' }
158
+ });
159
+ const ws = makeStubSocket();
160
+ const req = {
161
+ id: 'r4',
162
+ type: 'edit-text',
163
+ payload: { id: 'UI-7', field: 'title', value: 'New' }
164
+ };
165
+ await handleMessage(
166
+ /** @type {any} */ (ws),
167
+ Buffer.from(JSON.stringify(req))
168
+ );
169
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
170
+ expect(obj.ok).toBe(true);
171
+ expect(obj.payload.title).toBe('New');
172
+ });
173
+
174
+ // update-type removed; no server handler remains
175
+
176
+ test('update-assignee validates and returns updated issue', async () => {
177
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
178
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
179
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
180
+ mJson.mockResolvedValueOnce({ code: 0, stdoutJson: { id: 'UI-2' } });
181
+ const ws = makeStubSocket();
182
+ const req = {
183
+ id: 'rua',
184
+ type: /** @type {any} */ ('update-assignee'),
185
+ payload: { id: 'UI-2', assignee: 'max' }
186
+ };
187
+ await handleMessage(
188
+ /** @type {any} */ (ws),
189
+ Buffer.from(JSON.stringify(req))
190
+ );
191
+ const call = mRun.mock.calls[mRun.mock.calls.length - 1];
192
+ expect(call[0][0]).toBe('update');
193
+ expect(call[0].includes('--assignee')).toBe(true);
194
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
195
+ expect(obj.ok).toBe(true);
196
+ expect(obj.payload.id).toBe('UI-2');
197
+ });
198
+
199
+ test('update-assignee allows clearing with empty string', async () => {
200
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
201
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
202
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
203
+ mJson.mockResolvedValueOnce({ code: 0, stdoutJson: { id: 'UI-31' } });
204
+ const ws = makeStubSocket();
205
+ const req = {
206
+ id: 'rua2',
207
+ type: /** @type {any} */ ('update-assignee'),
208
+ payload: { id: 'UI-31', assignee: '' }
209
+ };
210
+ await handleMessage(
211
+ /** @type {any} */ (ws),
212
+ Buffer.from(JSON.stringify(req))
213
+ );
214
+ const call = mRun.mock.calls[mRun.mock.calls.length - 1];
215
+ expect(call[0]).toEqual(['update', 'UI-31', '--assignee', '']);
216
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
217
+ expect(obj.ok).toBe(true);
218
+ expect(obj.payload.id).toBe('UI-31');
219
+ });
220
+
221
+ test('edit-text acceptance success', async () => {
222
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
223
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
224
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
225
+ mJson.mockResolvedValueOnce({
226
+ code: 0,
227
+ stdoutJson: { id: 'UI-7', acceptance: 'Done when...' }
228
+ });
229
+ const ws = makeStubSocket();
230
+ const req = {
231
+ id: 'r4a',
232
+ type: 'edit-text',
233
+ payload: { id: 'UI-7', field: 'acceptance', value: 'Done when...' }
234
+ };
235
+ await handleMessage(
236
+ /** @type {any} */ (ws),
237
+ Buffer.from(JSON.stringify(req))
238
+ );
239
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
240
+ expect(obj.ok).toBe(true);
241
+ expect(obj.payload.acceptance).toBe('Done when...');
242
+ // Verify correct flag mapping for acceptance
243
+ expect(mRun.mock.calls[0][0]).toEqual([
244
+ 'update',
245
+ 'UI-7',
246
+ '--acceptance-criteria',
247
+ 'Done when...'
248
+ ]);
249
+ });
250
+
251
+ test('edit-text notes success', async () => {
252
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
253
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
254
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
255
+ mJson.mockResolvedValueOnce({
256
+ code: 0,
257
+ stdoutJson: { id: 'UI-12', notes: 'Some note' }
258
+ });
259
+ const ws = makeStubSocket();
260
+ const req = {
261
+ id: 'r4n',
262
+ type: 'edit-text',
263
+ payload: { id: 'UI-12', field: 'notes', value: 'Some note' }
264
+ };
265
+ await handleMessage(
266
+ /** @type {any} */ (ws),
267
+ Buffer.from(JSON.stringify(req))
268
+ );
269
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
270
+ expect(obj.ok).toBe(true);
271
+ expect(obj.payload.notes).toBe('Some note');
272
+ // Verify correct flag mapping for notes
273
+ expect(mRun.mock.calls[0][0]).toEqual([
274
+ 'update',
275
+ 'UI-12',
276
+ '--notes',
277
+ 'Some note'
278
+ ]);
279
+ });
280
+
281
+ test('edit-text description success and flag mapping', async () => {
282
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
283
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
284
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
285
+ mJson.mockResolvedValueOnce({
286
+ code: 0,
287
+ stdoutJson: { id: 'UI-7', description: 'New desc' }
288
+ });
289
+ const ws = makeStubSocket();
290
+ const req = {
291
+ id: 'r4b',
292
+ type: 'edit-text',
293
+ payload: { id: 'UI-7', field: 'description', value: 'New desc' }
294
+ };
295
+ await handleMessage(
296
+ /** @type {any} */ (ws),
297
+ Buffer.from(JSON.stringify(req))
298
+ );
299
+ // Verify bd call flag mapping
300
+ const call = mRun.mock.calls[mRun.mock.calls.length - 1][0];
301
+ expect(call).toEqual(['update', 'UI-7', '--description', 'New desc']);
302
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
303
+ expect(obj.ok).toBe(true);
304
+ expect(obj.payload.description).toBe('New desc');
305
+ });
306
+
307
+ test('edit-text design success and flag mapping', async () => {
308
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
309
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
310
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
311
+ mJson.mockResolvedValueOnce({
312
+ code: 0,
313
+ stdoutJson: { id: 'UI-8', design: 'New design' }
314
+ });
315
+ const ws = makeStubSocket();
316
+ const req = {
317
+ id: 'r4d',
318
+ type: 'edit-text',
319
+ payload: { id: 'UI-8', field: 'design', value: 'New design' }
320
+ };
321
+ await handleMessage(
322
+ /** @type {any} */ (ws),
323
+ Buffer.from(JSON.stringify(req))
324
+ );
325
+ const call = mRun.mock.calls[mRun.mock.calls.length - 1][0];
326
+ expect(call).toEqual(['update', 'UI-8', '--design', 'New design']);
327
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
328
+ expect(obj.ok).toBe(true);
329
+ expect(obj.payload.design).toBe('New design');
330
+ });
331
+
332
+ test('dep-add returns updated issue (view_id)', async () => {
333
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
334
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
335
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
336
+ mJson.mockResolvedValueOnce({
337
+ code: 0,
338
+ stdoutJson: { id: 'UI-7', dependencies: [] }
339
+ });
340
+ const ws = makeStubSocket();
341
+ const req = {
342
+ id: 'r5',
343
+ type: 'dep-add',
344
+ payload: { a: 'UI-7', b: 'UI-1', view_id: 'UI-7' }
345
+ };
346
+ await handleMessage(
347
+ /** @type {any} */ (ws),
348
+ Buffer.from(JSON.stringify(req))
349
+ );
350
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
351
+ expect(obj.ok).toBe(true);
352
+ expect(obj.payload.id).toBe('UI-7');
353
+ });
354
+
355
+ test('dep-remove bad payload yields bad_request', async () => {
356
+ const ws = makeStubSocket();
357
+ const req = { id: 'r6', type: 'dep-remove', payload: { a: '' } };
358
+ await handleMessage(
359
+ /** @type {any} */ (ws),
360
+ Buffer.from(JSON.stringify(req))
361
+ );
362
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
363
+ expect(obj.ok).toBe(false);
364
+ expect(obj.error.code).toBe('bad_request');
365
+ });
366
+
367
+ test('create-issue acks on success', async () => {
368
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
369
+ mRun.mockResolvedValueOnce({ code: 0, stdout: 'UI-99', stderr: '' });
370
+ const ws = makeStubSocket();
371
+ const req = {
372
+ id: 'r7',
373
+ type: 'create-issue',
374
+ payload: {
375
+ title: 'New item',
376
+ type: 'task',
377
+ priority: 2,
378
+ description: 'x'
379
+ }
380
+ };
381
+ await handleMessage(
382
+ /** @type {any} */ (ws),
383
+ Buffer.from(JSON.stringify(req))
384
+ );
385
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
386
+ expect(obj.ok).toBe(true);
387
+ expect(obj.payload && obj.payload.created).toBe(true);
388
+ });
389
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { handleMessage } from './ws.js';
3
+
4
+ /** @returns {any} */
5
+ function makeStubSocket() {
6
+ return {
7
+ sent: /** @type {string[]} */ ([]),
8
+ readyState: 1,
9
+ OPEN: 1,
10
+ /** @param {string} msg */
11
+ send(msg) {
12
+ this.sent.push(String(msg));
13
+ },
14
+ ping: vi.fn(),
15
+ terminate: vi.fn()
16
+ };
17
+ }
18
+
19
+ describe('ws message handling', () => {
20
+ test('invalid JSON yields bad_json error', () => {
21
+ const ws = makeStubSocket();
22
+ handleMessage(/** @type {any} */ (ws), Buffer.from('{oops'));
23
+ expect(ws.sent.length).toBe(1);
24
+ const obj = JSON.parse(ws.sent[0]);
25
+ expect(obj.ok).toBe(false);
26
+ expect(obj.error.code).toBe('bad_json');
27
+ });
28
+
29
+ test('invalid envelope yields bad_request', () => {
30
+ const ws = makeStubSocket();
31
+ handleMessage(
32
+ /** @type {any} */ (ws),
33
+ Buffer.from(JSON.stringify({ not: 'a request' }))
34
+ );
35
+ const last = ws.sent[ws.sent.length - 1];
36
+ const obj = JSON.parse(last);
37
+ expect(obj.ok).toBe(false);
38
+ expect(obj.error.code).toBe('bad_request');
39
+ });
40
+
41
+ test('unknown message type returns unknown_type error', () => {
42
+ const ws = makeStubSocket();
43
+ const req = { id: '1', type: 'some-unknown', payload: {} };
44
+ handleMessage(/** @type {any} */ (ws), Buffer.from(JSON.stringify(req)));
45
+ const last = ws.sent[ws.sent.length - 1];
46
+ const obj = JSON.parse(last);
47
+ expect(obj.ok).toBe(false);
48
+ expect(obj.error.code).toBe('unknown_type');
49
+ });
50
+ });
51
+
52
+ // Note: broadcast behavior is integration-tested later when a full server can run.