@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,95 @@
1
+ import { 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', () => ({
6
+ runBd: vi.fn(),
7
+ runBdJson: vi.fn()
8
+ }));
9
+
10
+ function makeStubSocket() {
11
+ return {
12
+ sent: /** @type {string[]} */ ([]),
13
+ readyState: 1,
14
+ OPEN: 1,
15
+ /** @param {string} msg */
16
+ send(msg) {
17
+ this.sent.push(String(msg));
18
+ }
19
+ };
20
+ }
21
+
22
+ describe('ws labels handlers', () => {
23
+ test('label-add validates payload', async () => {
24
+ const ws = makeStubSocket();
25
+ await handleMessage(
26
+ /** @type {any} */ (ws),
27
+ Buffer.from(
28
+ JSON.stringify({
29
+ id: 'x',
30
+ type: /** @type {any} */ ('label-add'),
31
+ payload: {}
32
+ })
33
+ )
34
+ );
35
+ const obj = JSON.parse(ws.sent[0]);
36
+ expect(obj.ok).toBe(false);
37
+ expect(obj.error.code).toBe('bad_request');
38
+ });
39
+
40
+ test('label-add runs bd and replies with show', async () => {
41
+ const rb = /** @type {import('vitest').Mock} */ (runBd);
42
+ const rj = /** @type {import('vitest').Mock} */ (runBdJson);
43
+ rb.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
44
+ rj.mockResolvedValueOnce({
45
+ code: 0,
46
+ stdoutJson: { id: 'UI-1', labels: ['frontend'] }
47
+ });
48
+
49
+ const ws = makeStubSocket();
50
+ await handleMessage(
51
+ /** @type {any} */ (ws),
52
+ Buffer.from(
53
+ JSON.stringify({
54
+ id: 'a',
55
+ type: /** @type {any} */ ('label-add'),
56
+ payload: { id: 'UI-1', label: 'frontend' }
57
+ })
58
+ )
59
+ );
60
+
61
+ const call = rb.mock.calls[0][0];
62
+ expect(call.slice(0, 3)).toEqual(['label', 'add', 'UI-1']);
63
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
64
+ expect(obj.ok).toBe(true);
65
+ expect(obj.payload && obj.payload.id).toBe('UI-1');
66
+ });
67
+
68
+ test('label-remove runs bd and replies with show', async () => {
69
+ const rb = /** @type {import('vitest').Mock} */ (runBd);
70
+ const rj = /** @type {import('vitest').Mock} */ (runBdJson);
71
+ rb.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
72
+ rj.mockResolvedValueOnce({
73
+ code: 0,
74
+ stdoutJson: { id: 'UI-1', labels: [] }
75
+ });
76
+
77
+ const ws = makeStubSocket();
78
+ await handleMessage(
79
+ /** @type {any} */ (ws),
80
+ Buffer.from(
81
+ JSON.stringify({
82
+ id: 'b',
83
+ type: /** @type {any} */ ('label-remove'),
84
+ payload: { id: 'UI-1', label: 'frontend' }
85
+ })
86
+ )
87
+ );
88
+
89
+ const call = rb.mock.calls[rb.mock.calls.length - 1][0];
90
+ expect(call.slice(0, 3)).toEqual(['label', 'remove', 'UI-1']);
91
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
92
+ expect(obj.ok).toBe(true);
93
+ expect(obj.payload && obj.payload.id).toBe('UI-1');
94
+ });
95
+ });
@@ -0,0 +1,95 @@
1
+ import { createServer } from 'node:http';
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
3
+ import { fetchListForSubscription } from './list-adapters.js';
4
+ import { attachWsServer, handleMessage, scheduleListRefresh } from './ws.js';
5
+
6
+ vi.mock('./list-adapters.js', () => ({
7
+ fetchListForSubscription: vi.fn(async () => {
8
+ return {
9
+ ok: true,
10
+ items: [
11
+ { id: 'A', updated_at: 1, closed_at: null },
12
+ { id: 'B', updated_at: 1, closed_at: null }
13
+ ]
14
+ };
15
+ })
16
+ }));
17
+
18
+ beforeEach(() => {
19
+ vi.useFakeTimers();
20
+ });
21
+
22
+ describe('ws list refresh coalescing', () => {
23
+ test('schedules one refresh per burst for active specs', async () => {
24
+ const server = createServer();
25
+ const { wss } = attachWsServer(server, {
26
+ path: '/ws',
27
+ heartbeat_ms: 10000,
28
+ refresh_debounce_ms: 50
29
+ });
30
+
31
+ // Two connected clients
32
+ const a = {
33
+ sent: /** @type {string[]} */ ([]),
34
+ readyState: 1,
35
+ OPEN: 1,
36
+ /** @param {string} msg */
37
+ send(msg) {
38
+ this.sent.push(String(msg));
39
+ }
40
+ };
41
+ const b = {
42
+ sent: /** @type {string[]} */ ([]),
43
+ readyState: 1,
44
+ OPEN: 1,
45
+ /** @param {string} msg */
46
+ send(msg) {
47
+ this.sent.push(String(msg));
48
+ }
49
+ };
50
+ wss.clients.add(/** @type {any} */ (a));
51
+ wss.clients.add(/** @type {any} */ (b));
52
+
53
+ // Subscribe to two different lists
54
+ await handleMessage(
55
+ /** @type {any} */ (a),
56
+ Buffer.from(
57
+ JSON.stringify({
58
+ id: 'l1',
59
+ type: /** @type {any} */ ('subscribe-list'),
60
+ payload: { id: 'c1', type: 'all-issues' }
61
+ })
62
+ )
63
+ );
64
+ await handleMessage(
65
+ /** @type {any} */ (b),
66
+ Buffer.from(
67
+ JSON.stringify({
68
+ id: 'l2',
69
+ type: /** @type {any} */ ('subscribe-list'),
70
+ payload: { id: 'c2', type: 'in-progress-issues' }
71
+ })
72
+ )
73
+ );
74
+
75
+ // Clear initial refresh calls from subscribe-list
76
+ const mock = /** @type {import('vitest').Mock} */ (
77
+ fetchListForSubscription
78
+ );
79
+ mock.mockClear();
80
+
81
+ // Simulate a burst of DB change events
82
+ scheduleListRefresh();
83
+ scheduleListRefresh();
84
+ scheduleListRefresh();
85
+
86
+ // Before debounce, nothing ran
87
+ expect(mock.mock.calls.length).toBe(0);
88
+ await vi.advanceTimersByTimeAsync(49);
89
+ expect(mock.mock.calls.length).toBe(0);
90
+
91
+ // After debounce window, one refresh per active spec
92
+ await vi.advanceTimersByTimeAsync(1);
93
+ expect(mock.mock.calls.length).toBe(2);
94
+ });
95
+ });
@@ -0,0 +1,403 @@
1
+ import { createServer } from 'node:http';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { fetchListForSubscription } from './list-adapters.js';
4
+ import { keyOf, registry } from './subscriptions.js';
5
+ import { attachWsServer, handleMessage, scheduleListRefresh } from './ws.js';
6
+
7
+ // Mock adapters BEFORE importing ws.js to ensure the mock is applied
8
+ vi.mock('./list-adapters.js', () => ({
9
+ fetchListForSubscription: vi.fn(async () => {
10
+ // Return a simple, deterministic list for any spec
11
+ return {
12
+ ok: true,
13
+ items: [
14
+ { id: 'A', updated_at: 1, closed_at: null },
15
+ { id: 'B', updated_at: 1, closed_at: null }
16
+ ]
17
+ };
18
+ })
19
+ }));
20
+
21
+ describe('ws list subscriptions', () => {
22
+ test('refresh emits upsert/delete after subscribe', async () => {
23
+ vi.useFakeTimers();
24
+ const server = createServer();
25
+ const { wss } = attachWsServer(server, {
26
+ path: '/ws',
27
+ heartbeat_ms: 10000,
28
+ refresh_debounce_ms: 50
29
+ });
30
+ const sock = {
31
+ sent: /** @type {string[]} */ ([]),
32
+ readyState: 1,
33
+ OPEN: 1,
34
+ /** @param {string} msg */
35
+ send(msg) {
36
+ this.sent.push(String(msg));
37
+ }
38
+ };
39
+ wss.clients.add(/** @type {any} */ (sock));
40
+
41
+ // Initial subscribe
42
+ await handleMessage(
43
+ /** @type {any} */ (sock),
44
+ Buffer.from(
45
+ JSON.stringify({
46
+ id: 'sub-2',
47
+ type: /** @type {any} */ ('subscribe-list'),
48
+ payload: { id: 'c2', type: 'all-issues' }
49
+ })
50
+ )
51
+ );
52
+
53
+ // Clear initial snapshot
54
+ sock.sent = [];
55
+
56
+ // Change adapter to simulate one added, one updated, one removed
57
+ const mock = /** @type {import('vitest').Mock} */ (
58
+ fetchListForSubscription
59
+ );
60
+ mock.mockResolvedValueOnce({
61
+ ok: true,
62
+ items: [
63
+ { id: 'A', updated_at: 2, closed_at: null }, // updated
64
+ { id: 'C', updated_at: 1, closed_at: null } // added
65
+ ]
66
+ });
67
+
68
+ // Trigger refresh
69
+ scheduleListRefresh();
70
+ await vi.advanceTimersByTimeAsync(60);
71
+
72
+ const events = sock.sent
73
+ .map((m) => {
74
+ try {
75
+ return JSON.parse(m);
76
+ } catch {
77
+ return null;
78
+ }
79
+ })
80
+ .filter(Boolean);
81
+ const upserts = events.filter((e) => e && e.type === 'upsert');
82
+ const deletes = events.filter((e) => e && e.type === 'delete');
83
+ expect(upserts.length).toBeGreaterThan(0);
84
+ expect(deletes.length).toBeGreaterThan(0);
85
+ vi.useRealTimers();
86
+ });
87
+ test('subscribe-list attaches and publishes initial snapshot', async () => {
88
+ const sock = {
89
+ sent: /** @type {string[]} */ ([]),
90
+ readyState: 1,
91
+ OPEN: 1,
92
+ /** @param {string} msg */
93
+ send(msg) {
94
+ this.sent.push(String(msg));
95
+ }
96
+ };
97
+
98
+ const req = {
99
+ id: 'sub-1',
100
+ type: /** @type {any} */ ('subscribe-list'),
101
+ payload: { id: 'c1', type: 'in-progress-issues' }
102
+ };
103
+ await handleMessage(
104
+ /** @type {any} */ (sock),
105
+ Buffer.from(JSON.stringify(req))
106
+ );
107
+
108
+ // Expect an OK reply for subscribe-list
109
+ const last = sock.sent[sock.sent.length - 1];
110
+ const reply = JSON.parse(last);
111
+ expect(reply && reply.ok).toBe(true);
112
+ expect(reply && reply.type).toBe('subscribe-list');
113
+
114
+ // Expect a snapshot event was sent containing issues
115
+ const snapshot_envelope = sock.sent
116
+ .map((m) => {
117
+ try {
118
+ return JSON.parse(m);
119
+ } catch {
120
+ return null;
121
+ }
122
+ })
123
+ .find((o) => o && o.type === 'snapshot');
124
+ expect(!!snapshot_envelope).toBe(true);
125
+ expect(snapshot_envelope.payload && snapshot_envelope.payload.id).toBe(
126
+ 'c1'
127
+ );
128
+ expect(Array.isArray(snapshot_envelope.payload.issues)).toBe(true);
129
+ expect(snapshot_envelope.payload.issues.length).toBeGreaterThan(0);
130
+
131
+ const key = keyOf({ type: 'in-progress-issues' });
132
+ const entry = registry.get(key);
133
+ const before_size = entry ? entry.subscribers.size : 0;
134
+ expect(before_size).toBeGreaterThanOrEqual(1);
135
+ });
136
+
137
+ test('subscribe-list returns bd_error payload when adapter fails', async () => {
138
+ const mock = /** @type {import('vitest').Mock} */ (
139
+ fetchListForSubscription
140
+ );
141
+ mock.mockResolvedValueOnce({
142
+ ok: false,
143
+ error: {
144
+ code: 'bd_error',
145
+ message: 'bd failed: out of sync',
146
+ details: { exit_code: 1 }
147
+ }
148
+ });
149
+
150
+ const sock = {
151
+ sent: /** @type {string[]} */ ([]),
152
+ readyState: 1,
153
+ OPEN: 1,
154
+ /** @param {string} msg */
155
+ send(msg) {
156
+ this.sent.push(String(msg));
157
+ }
158
+ };
159
+
160
+ const req = {
161
+ id: 'sub-error',
162
+ type: /** @type {any} */ ('subscribe-list'),
163
+ payload: { id: 'c-err', type: 'all-issues' }
164
+ };
165
+
166
+ await handleMessage(
167
+ /** @type {any} */ (sock),
168
+ Buffer.from(JSON.stringify(req))
169
+ );
170
+
171
+ const last = sock.sent[sock.sent.length - 1];
172
+ const reply = JSON.parse(last);
173
+ expect(reply && reply.ok).toBe(false);
174
+ expect(reply && reply.error && reply.error.code).toBe('bd_error');
175
+ expect(reply && reply.error && reply.error.message).toContain(
176
+ 'out of sync'
177
+ );
178
+ expect(reply && reply.error && reply.error.details.exit_code).toBe(1);
179
+ });
180
+
181
+ test('unsubscribe-list detaches and disconnect sweep evicts entry', async () => {
182
+ const sock = {
183
+ sent: /** @type {string[]} */ ([]),
184
+ readyState: 1,
185
+ OPEN: 1,
186
+ /** @param {string} msg */
187
+ send(msg) {
188
+ this.sent.push(String(msg));
189
+ }
190
+ };
191
+
192
+ // Subscribe first
193
+ await handleMessage(
194
+ /** @type {any} */ (sock),
195
+ Buffer.from(
196
+ JSON.stringify({
197
+ id: 'sub-1',
198
+ type: /** @type {any} */ ('subscribe-list'),
199
+ payload: { id: 'c1', type: 'all-issues' }
200
+ })
201
+ )
202
+ );
203
+
204
+ const key = keyOf({ type: 'all-issues' });
205
+ const entry = registry.get(key);
206
+ const before = entry ? entry.subscribers.size : 0;
207
+ expect(before).toBeGreaterThanOrEqual(1);
208
+
209
+ // Now unsubscribe
210
+ await handleMessage(
211
+ /** @type {any} */ (sock),
212
+ Buffer.from(
213
+ JSON.stringify({
214
+ id: 'unsub-1',
215
+ type: /** @type {any} */ ('unsubscribe-list'),
216
+ payload: { id: 'c1' }
217
+ })
218
+ )
219
+ );
220
+
221
+ const entry2 = registry.get(key);
222
+ const after_size = entry2 ? entry2.subscribers.size : 0;
223
+ expect(after_size).toBeLessThan(before);
224
+
225
+ // Do not assert full eviction here due to global registry used across tests
226
+ });
227
+
228
+ test('closed-issues pre-filter applies before diff', async () => {
229
+ const now = Date.now();
230
+ // Configure adapter mock for this test case
231
+ const mock = /** @type {import('vitest').Mock} */ (
232
+ fetchListForSubscription
233
+ );
234
+ mock.mockResolvedValueOnce({
235
+ ok: true,
236
+ items: [
237
+ { id: 'old', updated_at: now - 3000, closed_at: now - 2000 },
238
+ { id: 'recent', updated_at: now - 100, closed_at: now - 100 },
239
+ { id: 'open', updated_at: now - 50, closed_at: null }
240
+ ]
241
+ });
242
+
243
+ const sock = {
244
+ sent: /** @type {string[]} */ ([]),
245
+ readyState: 1,
246
+ OPEN: 1,
247
+ /** @param {string} msg */
248
+ send(msg) {
249
+ this.sent.push(String(msg));
250
+ }
251
+ };
252
+
253
+ const since = now - 1000;
254
+ await handleMessage(
255
+ /** @type {any} */ (sock),
256
+ Buffer.from(
257
+ JSON.stringify({
258
+ id: 'sub-closed',
259
+ type: /** @type {any} */ ('subscribe-list'),
260
+ payload: { id: 'c-closed', type: 'closed-issues', params: { since } }
261
+ })
262
+ )
263
+ );
264
+
265
+ const key = keyOf({ type: 'closed-issues', params: { since } });
266
+ const entry = registry.get(key);
267
+ const ids = entry ? Array.from(entry.itemsById.keys()).sort() : [];
268
+ expect(ids).toEqual(['recent']);
269
+ });
270
+
271
+ test('subscribe-list rejects unknown subscription type', async () => {
272
+ const sock = {
273
+ sent: /** @type {string[]} */ ([]),
274
+ readyState: 1,
275
+ OPEN: 1,
276
+ /** @param {string} msg */
277
+ send(msg) {
278
+ this.sent.push(String(msg));
279
+ }
280
+ };
281
+
282
+ await handleMessage(
283
+ /** @type {any} */ (sock),
284
+ Buffer.from(
285
+ JSON.stringify({
286
+ id: 'bad-sub',
287
+ type: /** @type {any} */ ('subscribe-list'),
288
+ payload: { id: 'c-bad', type: 'not-supported' }
289
+ })
290
+ )
291
+ );
292
+
293
+ const last = sock.sent[sock.sent.length - 1];
294
+ const reply = JSON.parse(last);
295
+ expect(reply && reply.ok).toBe(false);
296
+ expect(reply && reply.error && reply.error.code).toBe('bad_request');
297
+ });
298
+
299
+ test('subscribe-list accepts issue-detail with id and publishes snapshot', async () => {
300
+ const sock = {
301
+ sent: /** @type {string[]} */ ([]),
302
+ readyState: 1,
303
+ OPEN: 1,
304
+ /** @param {string} msg */
305
+ send(msg) {
306
+ this.sent.push(String(msg));
307
+ }
308
+ };
309
+
310
+ await handleMessage(
311
+ /** @type {any} */ (sock),
312
+ Buffer.from(
313
+ JSON.stringify({
314
+ id: 'sub-detail-1',
315
+ type: /** @type {any} */ ('subscribe-list'),
316
+ payload: {
317
+ id: 'detail:UI-1',
318
+ type: 'issue-detail',
319
+ params: { id: 'UI-1' }
320
+ }
321
+ })
322
+ )
323
+ );
324
+
325
+ const last = sock.sent[sock.sent.length - 1];
326
+ const reply = JSON.parse(last);
327
+ expect(reply && reply.ok).toBe(true);
328
+ expect(reply && reply.type).toBe('subscribe-list');
329
+
330
+ const snapshot_envelope = sock.sent
331
+ .map((m) => {
332
+ try {
333
+ return JSON.parse(m);
334
+ } catch {
335
+ return null;
336
+ }
337
+ })
338
+ .find((o) => o && o.type === 'snapshot');
339
+ expect(!!snapshot_envelope).toBe(true);
340
+ expect(snapshot_envelope.payload && snapshot_envelope.payload.id).toBe(
341
+ 'detail:UI-1'
342
+ );
343
+ expect(Array.isArray(snapshot_envelope.payload.issues)).toBe(true);
344
+ });
345
+
346
+ test('subscribe-list issue-detail enforces id', async () => {
347
+ const sock = {
348
+ sent: /** @type {string[]} */ ([]),
349
+ readyState: 1,
350
+ OPEN: 1,
351
+ /** @param {string} msg */
352
+ send(msg) {
353
+ this.sent.push(String(msg));
354
+ }
355
+ };
356
+
357
+ await handleMessage(
358
+ /** @type {any} */ (sock),
359
+ Buffer.from(
360
+ JSON.stringify({
361
+ id: 'bad-detail',
362
+ type: /** @type {any} */ ('subscribe-list'),
363
+ payload: { id: 'detail:UI-X', type: 'issue-detail' }
364
+ })
365
+ )
366
+ );
367
+ const last = sock.sent[sock.sent.length - 1];
368
+ const reply = JSON.parse(last);
369
+ expect(reply && reply.ok).toBe(false);
370
+ expect(reply && reply.error && reply.error.code).toBe('bad_request');
371
+ });
372
+
373
+ test('subscribe-list closed-issues validates since param', async () => {
374
+ const sock = {
375
+ sent: /** @type {string[]} */ ([]),
376
+ readyState: 1,
377
+ OPEN: 1,
378
+ /** @param {string} msg */
379
+ send(msg) {
380
+ this.sent.push(String(msg));
381
+ }
382
+ };
383
+
384
+ await handleMessage(
385
+ /** @type {any} */ (sock),
386
+ Buffer.from(
387
+ JSON.stringify({
388
+ id: 'bad-since',
389
+ type: /** @type {any} */ ('subscribe-list'),
390
+ payload: {
391
+ id: 'c-closed',
392
+ type: 'closed-issues',
393
+ params: { since: 'yesterday' }
394
+ }
395
+ })
396
+ )
397
+ );
398
+ const last = sock.sent[sock.sent.length - 1];
399
+ const reply = JSON.parse(last);
400
+ expect(reply && reply.ok).toBe(false);
401
+ expect(reply && reply.error && reply.error.code).toBe('bad_request');
402
+ });
403
+ });