beads-ui 0.1.0 → 0.1.2

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 (57) hide show
  1. package/CHANGES.md +8 -0
  2. package/README.md +7 -3
  3. package/package.json +12 -2
  4. package/.beads/issues.jsonl +0 -107
  5. package/.editorconfig +0 -10
  6. package/.eslintrc.json +0 -36
  7. package/.github/workflows/ci.yml +0 -38
  8. package/.prettierignore +0 -5
  9. package/AGENTS.md +0 -85
  10. package/app/data/providers.test.js +0 -126
  11. package/app/main.board-switch.test.js +0 -94
  12. package/app/main.deep-link.test.js +0 -64
  13. package/app/main.live-updates.test.js +0 -229
  14. package/app/main.test.js +0 -17
  15. package/app/main.theme.test.js +0 -41
  16. package/app/main.view-sync.test.js +0 -54
  17. package/app/protocol.test.js +0 -57
  18. package/app/router.test.js +0 -34
  19. package/app/state.test.js +0 -21
  20. package/app/utils/markdown.test.js +0 -103
  21. package/app/utils/type-badge.test.js +0 -30
  22. package/app/views/board.test.js +0 -184
  23. package/app/views/detail.acceptance-notes.test.js +0 -67
  24. package/app/views/detail.assignee.test.js +0 -161
  25. package/app/views/detail.deps.test.js +0 -97
  26. package/app/views/detail.edits.test.js +0 -146
  27. package/app/views/detail.labels.test.js +0 -73
  28. package/app/views/detail.priority.test.js +0 -86
  29. package/app/views/detail.test.js +0 -188
  30. package/app/views/detail.ui47.test.js +0 -78
  31. package/app/views/epics.test.js +0 -283
  32. package/app/views/list.inline-edits.test.js +0 -84
  33. package/app/views/list.test.js +0 -479
  34. package/app/views/nav.test.js +0 -43
  35. package/app/ws.test.js +0 -168
  36. package/eslint.config.js +0 -59
  37. package/media/bdui-board.png +0 -0
  38. package/media/bdui-epics.png +0 -0
  39. package/media/bdui-issues.png +0 -0
  40. package/prettier.config.js +0 -13
  41. package/server/app.test.js +0 -29
  42. package/server/bd.test.js +0 -93
  43. package/server/cli/cli.test.js +0 -109
  44. package/server/cli/commands.integration.test.js +0 -155
  45. package/server/cli/commands.unit.test.js +0 -94
  46. package/server/cli/open.test.js +0 -26
  47. package/server/db.test.js +0 -70
  48. package/server/protocol.test.js +0 -87
  49. package/server/watcher.test.js +0 -100
  50. package/server/ws.handlers.test.js +0 -174
  51. package/server/ws.labels.test.js +0 -95
  52. package/server/ws.mutations.test.js +0 -261
  53. package/server/ws.subscriptions.test.js +0 -116
  54. package/server/ws.test.js +0 -52
  55. package/test/setup-vitest.js +0 -12
  56. package/tsconfig.json +0 -23
  57. package/vitest.config.mjs +0 -14
@@ -1,479 +0,0 @@
1
- import { describe, expect, test } from 'vitest';
2
- import { createListView } from './list.js';
3
-
4
- /** @type {(expected: any[]) => (type: string, payload?: unknown) => Promise<any[]>} */
5
- const stubSend = (expected) => async (type) => {
6
- if (type !== 'list-issues') {
7
- throw new Error('Unexpected type');
8
- }
9
- return expected;
10
- };
11
-
12
- describe('views/list', () => {
13
- test('renders issues in table and navigates on row click', async () => {
14
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
15
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
16
- const issues = [
17
- {
18
- id: 'UI-1',
19
- title: 'One',
20
- status: 'open',
21
- priority: 1,
22
- issue_type: 'task'
23
- },
24
- {
25
- id: 'UI-2',
26
- title: 'Two',
27
- status: 'closed',
28
- priority: 2,
29
- issue_type: 'bug'
30
- }
31
- ];
32
- const view = createListView(mount, stubSend(issues), (hash) => {
33
- window.location.hash = hash;
34
- });
35
- await view.load();
36
- const rows = mount.querySelectorAll('tr.issue-row');
37
- expect(rows.length).toBe(2);
38
-
39
- // badge present
40
- const badges = mount.querySelectorAll('.type-badge');
41
- expect(badges.length).toBeGreaterThanOrEqual(2);
42
-
43
- const first = /** @type {HTMLElement} */ (rows[0]);
44
- first.dispatchEvent(new MouseEvent('click', { bubbles: true }));
45
- expect(window.location.hash).toBe('#/issue/UI-1');
46
- });
47
-
48
- test('filters by status and search', async () => {
49
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
50
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
51
- const issues = [
52
- { id: 'UI-1', title: 'Alpha', status: 'open', priority: 1 },
53
- { id: 'UI-2', title: 'Beta', status: 'in_progress', priority: 2 },
54
- { id: 'UI-3', title: 'Gamma', status: 'closed', priority: 3 }
55
- ];
56
- const view = createListView(mount, stubSend(issues));
57
- await view.load();
58
- const select = /** @type {HTMLSelectElement} */ (
59
- mount.querySelector('select')
60
- );
61
- const input = /** @type {HTMLInputElement} */ (
62
- mount.querySelector('input[type="search"]')
63
- );
64
-
65
- // Filter by status
66
- select.value = 'open';
67
- select.dispatchEvent(new Event('change'));
68
- await Promise.resolve();
69
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(1);
70
-
71
- // Search filters further
72
- select.value = 'all';
73
- select.dispatchEvent(new Event('change'));
74
- input.value = 'ga';
75
- input.dispatchEvent(new Event('input'));
76
- const visible = Array.from(mount.querySelectorAll('tr.issue-row')).map(
77
- (el) => ({
78
- id: el.getAttribute('data-issue-id') || '',
79
- text: el.textContent || ''
80
- })
81
- );
82
- expect(visible.length).toBe(1);
83
- expect(visible[0].id).toBe('UI-3');
84
- expect(visible[0].text.toLowerCase()).toContain('gamma');
85
- });
86
-
87
- test('filters by issue type and combines with search', async () => {
88
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
89
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
90
- const issues = [
91
- {
92
- id: 'UI-1',
93
- title: 'Alpha',
94
- status: 'open',
95
- priority: 1,
96
- issue_type: 'bug'
97
- },
98
- {
99
- id: 'UI-2',
100
- title: 'Beta',
101
- status: 'open',
102
- priority: 2,
103
- issue_type: 'feature'
104
- },
105
- {
106
- id: 'UI-3',
107
- title: 'Gamma',
108
- status: 'open',
109
- priority: 3,
110
- issue_type: 'bug'
111
- },
112
- {
113
- id: 'UI-4',
114
- title: 'Delta',
115
- status: 'open',
116
- priority: 2,
117
- issue_type: 'task'
118
- }
119
- ];
120
- const view = createListView(mount, stubSend(issues));
121
- await view.load();
122
-
123
- // Initially shows all
124
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(4);
125
-
126
- const typeSelect = /** @type {HTMLSelectElement} */ (
127
- mount.querySelector('select[aria-label="Filter by type"]')
128
- );
129
- // Select bug
130
- typeSelect.value = 'bug';
131
- typeSelect.dispatchEvent(new Event('change'));
132
- await Promise.resolve();
133
- const bug_only = Array.from(mount.querySelectorAll('tr.issue-row')).map(
134
- (el) => el.getAttribute('data-issue-id') || ''
135
- );
136
- expect(bug_only).toEqual(['UI-1', 'UI-3']);
137
-
138
- // Switch to feature
139
- typeSelect.value = 'feature';
140
- typeSelect.dispatchEvent(new Event('change'));
141
- await Promise.resolve();
142
- const feature_only = Array.from(mount.querySelectorAll('tr.issue-row')).map(
143
- (el) => el.getAttribute('data-issue-id') || ''
144
- );
145
- expect(feature_only).toEqual(['UI-2']);
146
-
147
- // Combine with search while bug selected
148
- typeSelect.value = 'bug';
149
- typeSelect.dispatchEvent(new Event('change'));
150
- const input = /** @type {HTMLInputElement} */ (
151
- mount.querySelector('input[type="search"]')
152
- );
153
- input.value = 'ga';
154
- input.dispatchEvent(new Event('input'));
155
- await Promise.resolve();
156
- const filtered = Array.from(mount.querySelectorAll('tr.issue-row')).map(
157
- (el) => el.getAttribute('data-issue-id') || ''
158
- );
159
- expect(filtered).toEqual(['UI-3']);
160
- });
161
-
162
- test('applies type filters after Ready reload', async () => {
163
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
164
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
165
-
166
- const allIssues = [
167
- {
168
- id: 'UI-1',
169
- title: 'One',
170
- status: 'open',
171
- priority: 1,
172
- issue_type: 'task'
173
- },
174
- {
175
- id: 'UI-2',
176
- title: 'Two',
177
- status: 'open',
178
- priority: 2,
179
- issue_type: 'feature'
180
- },
181
- {
182
- id: 'UI-3',
183
- title: 'Three',
184
- status: 'open',
185
- priority: 2,
186
- issue_type: 'bug'
187
- }
188
- ];
189
- const readyIssues = [
190
- {
191
- id: 'UI-2',
192
- title: 'Two',
193
- status: 'open',
194
- priority: 2,
195
- issue_type: 'feature'
196
- },
197
- {
198
- id: 'UI-3',
199
- title: 'Three',
200
- status: 'open',
201
- priority: 2,
202
- issue_type: 'bug'
203
- }
204
- ];
205
-
206
- /** @type {{ calls: any[] }} */
207
- const spy = { calls: [] };
208
- /** @type {(type: string, payload?: unknown) => Promise<any[]>} */
209
- const send = async (type, payload) => {
210
- spy.calls.push({ type, payload });
211
- const p = /** @type {any} */ (payload);
212
- if (p && p.filters && p.filters.ready === true) {
213
- return readyIssues;
214
- }
215
- return allIssues;
216
- };
217
-
218
- const view = createListView(mount, send);
219
- await view.load();
220
- const statusSelect = /** @type {HTMLSelectElement} */ (
221
- mount.querySelector('select')
222
- );
223
- statusSelect.value = 'ready';
224
- statusSelect.dispatchEvent(new Event('change'));
225
- await Promise.resolve();
226
-
227
- // Apply type filter (feature)
228
- const typeSelect = /** @type {HTMLSelectElement} */ (
229
- mount.querySelector('select[aria-label="Filter by type"]')
230
- );
231
- typeSelect.value = 'feature';
232
- typeSelect.dispatchEvent(new Event('change'));
233
- await Promise.resolve();
234
-
235
- const rows = Array.from(mount.querySelectorAll('tr.issue-row')).map(
236
- (el) => el.getAttribute('data-issue-id') || ''
237
- );
238
- expect(rows).toEqual(['UI-2']);
239
-
240
- // Ensure ready call happened
241
- const has_ready = spy.calls.some(
242
- (c) =>
243
- c.type === 'list-issues' &&
244
- c.payload &&
245
- c.payload.filters &&
246
- c.payload.filters.ready === true
247
- );
248
- expect(has_ready).toBe(true);
249
- });
250
-
251
- test('initializes type filter from store and reflects in controls', async () => {
252
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
253
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
254
-
255
- const issues = [
256
- {
257
- id: 'UI-1',
258
- title: 'Alpha',
259
- status: 'open',
260
- priority: 1,
261
- issue_type: 'bug'
262
- },
263
- {
264
- id: 'UI-2',
265
- title: 'Beta',
266
- status: 'open',
267
- priority: 2,
268
- issue_type: 'feature'
269
- },
270
- {
271
- id: 'UI-3',
272
- title: 'Gamma closed',
273
- status: 'closed',
274
- priority: 3,
275
- issue_type: 'bug'
276
- }
277
- ];
278
-
279
- /** @type {{ state: any, subs: ((s:any)=>void)[], getState: () => any, setState: (patch:any)=>void, subscribe: (fn:(s:any)=>void)=>()=>void }} */
280
- const store = {
281
- state: {
282
- selected_id: null,
283
- filters: { status: 'all', search: '', type: 'bug' }
284
- },
285
- subs: [],
286
- getState() {
287
- return this.state;
288
- },
289
- setState(patch) {
290
- this.state = {
291
- ...this.state,
292
- ...(patch || {}),
293
- filters: { ...this.state.filters, ...(patch.filters || {}) }
294
- };
295
- for (const fn of this.subs) {
296
- fn(this.state);
297
- }
298
- },
299
- subscribe(fn) {
300
- this.subs.push(fn);
301
- return () => {
302
- this.subs = this.subs.filter((f) => f !== fn);
303
- };
304
- }
305
- };
306
-
307
- const view = createListView(mount, stubSend(issues), undefined, store);
308
- await view.load();
309
-
310
- // Only bug issues visible
311
- const rows = Array.from(mount.querySelectorAll('tr.issue-row')).map(
312
- (el) => el.getAttribute('data-issue-id') || ''
313
- );
314
- expect(rows).toEqual(['UI-1', 'UI-3']);
315
-
316
- const typeSelect = /** @type {HTMLSelectElement} */ (
317
- mount.querySelector('select[aria-label="Filter by type"]')
318
- );
319
- expect(typeSelect.value).toBe('bug');
320
- });
321
-
322
- test('ready filter via select triggers backend reload', async () => {
323
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
324
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
325
-
326
- const allIssues = [
327
- { id: 'UI-1', title: 'One', status: 'open', priority: 1 },
328
- { id: 'UI-2', title: 'Two', status: 'open', priority: 2 }
329
- ];
330
- const readyIssues = [
331
- { id: 'UI-2', title: 'Two', status: 'open', priority: 2 }
332
- ];
333
-
334
- /** @type {{ calls: any[] }} */
335
- const spy = { calls: [] };
336
- /** @type {(type: string, payload?: unknown) => Promise<any[]>} */
337
- const send = async (type, payload) => {
338
- spy.calls.push({ type, payload });
339
- const p = /** @type {any} */ (payload);
340
- if (p && p.filters && p.filters.ready === true) {
341
- return readyIssues;
342
- }
343
- return allIssues;
344
- };
345
-
346
- const view = createListView(mount, send);
347
- await view.load();
348
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(2);
349
-
350
- const select = /** @type {HTMLSelectElement} */ (
351
- mount.querySelector('select')
352
- );
353
- select.value = 'ready';
354
- select.dispatchEvent(new Event('change'));
355
- // Await a microtask to allow load to complete in jsdom
356
- await Promise.resolve();
357
-
358
- // A call should include filters.ready = true
359
- const has_ready = spy.calls.some(
360
- (c) =>
361
- c.type === 'list-issues' &&
362
- c.payload &&
363
- c.payload.filters &&
364
- c.payload.filters.ready === true
365
- );
366
- expect(has_ready).toBe(true);
367
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(1);
368
- });
369
-
370
- test('switching ready → all reloads full list', async () => {
371
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
372
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
373
-
374
- const allIssues = [
375
- { id: 'UI-1', title: 'One', status: 'open', priority: 1 },
376
- { id: 'UI-2', title: 'Two', status: 'closed', priority: 2 }
377
- ];
378
- const readyIssues = [
379
- { id: 'UI-2', title: 'Two', status: 'closed', priority: 2 }
380
- ];
381
-
382
- /** @type {{ calls: any[] }} */
383
- const spy = { calls: [] };
384
- /** @type {(type: string, payload?: unknown) => Promise<any[]>} */
385
- const send = async (type, payload) => {
386
- spy.calls.push({ type, payload });
387
- const p = /** @type {any} */ (payload);
388
- if (p && p.filters && p.filters.ready === true) {
389
- return readyIssues;
390
- }
391
- return allIssues;
392
- };
393
-
394
- const view = createListView(mount, send);
395
- await view.load();
396
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(2);
397
-
398
- const select = /** @type {HTMLSelectElement} */ (
399
- mount.querySelector('select')
400
- );
401
-
402
- // Switch to ready (backend should return the smaller set)
403
- select.value = 'ready';
404
- select.dispatchEvent(new Event('change'));
405
- await Promise.resolve();
406
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(1);
407
-
408
- // Switch back to all; view should reload full list from backend
409
- select.value = 'all';
410
- select.dispatchEvent(new Event('change'));
411
- await Promise.resolve();
412
- expect(mount.querySelectorAll('tr.issue-row').length).toBe(2);
413
-
414
- // Verify that a request without ready=true was made after switching to all
415
- const lastCall = spy.calls[spy.calls.length - 1];
416
- expect(lastCall.type).toBe('list-issues');
417
- const payload = /** @type {any} */ (lastCall.payload);
418
- expect(payload && payload.filters && payload.filters.ready).not.toBe(true);
419
- });
420
-
421
- test('applies persisted filters from store on initial load', async () => {
422
- document.body.innerHTML = '<aside id="mount" class="panel"></aside>';
423
- const mount = /** @type {HTMLElement} */ (document.getElementById('mount'));
424
-
425
- const issues = [
426
- { id: 'UI-1', title: 'Alpha', status: 'open', priority: 1 },
427
- { id: 'UI-2', title: 'Gamma', status: 'open', priority: 2 },
428
- { id: 'UI-3', title: 'Gamma closed', status: 'closed', priority: 3 }
429
- ];
430
-
431
- /** @type {{ state: any, subs: ((s:any)=>void)[], getState: () => any, setState: (patch:any)=>void, subscribe: (fn:(s:any)=>void)=>()=>void }} */
432
- const store = {
433
- state: { selected_id: null, filters: { status: 'open', search: 'ga' } },
434
- subs: [],
435
- getState() {
436
- return this.state;
437
- },
438
- setState(patch) {
439
- this.state = {
440
- ...this.state,
441
- ...(patch || {}),
442
- filters: { ...this.state.filters, ...(patch.filters || {}) }
443
- };
444
- for (const fn of this.subs) {
445
- fn(this.state);
446
- }
447
- },
448
- subscribe(fn) {
449
- this.subs.push(fn);
450
- return () => {
451
- this.subs = this.subs.filter((f) => f !== fn);
452
- };
453
- }
454
- };
455
-
456
- const view = createListView(mount, stubSend(issues), undefined, store);
457
- await view.load();
458
-
459
- // Expect only UI-2 ("Gamma" open) to be visible
460
- const items = Array.from(mount.querySelectorAll('tr.issue-row')).map(
461
- (el) => ({
462
- id: el.getAttribute('data-issue-id') || '',
463
- text: el.textContent || ''
464
- })
465
- );
466
- expect(items.length).toBe(1);
467
- expect(items[0].id).toBe('UI-2');
468
-
469
- // Controls reflect persisted filters
470
- const select = /** @type {HTMLSelectElement} */ (
471
- mount.querySelector('select')
472
- );
473
- const input = /** @type {HTMLInputElement} */ (
474
- mount.querySelector('input[type="search"]')
475
- );
476
- expect(select.value).toBe('open');
477
- expect(input.value).toBe('ga');
478
- });
479
- });
@@ -1,43 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createTopNav } from './nav.js';
3
-
4
- function setup() {
5
- document.body.innerHTML = '<div id="m"></div>';
6
- const mount = /** @type {HTMLElement} */ (document.getElementById('m'));
7
- const store = {
8
- state: { view: 'issues' },
9
- getState() {
10
- return this.state;
11
- },
12
- /** @param {any} v */
13
- set(v) {
14
- this.state = { ...this.state, ...v };
15
- },
16
- /** @param {(s: any) => void} fn */
17
- subscribe(fn) {
18
- // simplistic subscription for test
19
- this._fn = fn;
20
- return () => void 0;
21
- },
22
- _fn: /** @type {(s: any) => void} */ (() => {})
23
- };
24
- const router = { gotoView: vi.fn() };
25
- return { mount, store, router };
26
- }
27
-
28
- describe('views/nav', () => {
29
- test('renders and routes between tabs', async () => {
30
- const { mount, store, router } = setup();
31
- createTopNav(
32
- mount,
33
- /** @type {any} */ (store),
34
- /** @type {any} */ (router)
35
- );
36
- const links = mount.querySelectorAll('a.tab');
37
- expect(links.length).toBe(3);
38
- links[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
39
- expect(router.gotoView).toHaveBeenCalledWith('epics');
40
- links[2].dispatchEvent(new MouseEvent('click', { bubbles: true }));
41
- expect(router.gotoView).toHaveBeenCalledWith('board');
42
- });
43
- });
package/app/ws.test.js DELETED
@@ -1,168 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import { createWsClient } from './ws.js';
3
-
4
- /**
5
- * @returns {any[]}
6
- */
7
- function setupFakeWebSocket() {
8
- /** @type {any[]} */
9
- const sockets = [];
10
- class FakeWebSocket {
11
- /** @param {string} url */
12
- constructor(url) {
13
- this.url = url;
14
- this.readyState = 0; // CONNECTING
15
- this.OPEN = 1;
16
- this.CLOSING = 2;
17
- this.CLOSED = 3;
18
- /** @type {{ open: Array<(ev:any)=>void>, message: Array<(ev:any)=>void>, error: Array<(ev:any)=>void>, close: Array<(ev:any)=>void> }} */
19
- this._listeners = { open: [], message: [], error: [], close: [] };
20
- /** @type {string[]} */
21
- this.sent = [];
22
- sockets.push(this);
23
- }
24
- /**
25
- * @param {'open'|'message'|'error'|'close'} type
26
- * @param {(ev:any)=>void} fn
27
- */
28
- addEventListener(type, fn) {
29
- this._listeners[type].push(fn);
30
- }
31
- /**
32
- * @param {'open'|'message'|'error'|'close'} type
33
- * @param {(ev:any)=>void} fn
34
- */
35
- removeEventListener(type, fn) {
36
- const a = this._listeners[type];
37
- const i = a.indexOf(fn);
38
- if (i !== -1) {
39
- a.splice(i, 1);
40
- }
41
- }
42
- /**
43
- * @param {'open'|'message'|'error'|'close'} type
44
- * @param {any} ev
45
- */
46
- _dispatch(type, ev) {
47
- for (const fn of this._listeners[type]) {
48
- try {
49
- fn(ev);
50
- } catch {
51
- // ignore
52
- }
53
- }
54
- }
55
- openNow() {
56
- this.readyState = this.OPEN;
57
- this._dispatch('open', {});
58
- }
59
- /** @param {string} data */
60
- send(data) {
61
- this.sent.push(String(data));
62
- }
63
- /** @param {any} obj */
64
- emitMessage(obj) {
65
- this._dispatch('message', { data: JSON.stringify(obj) });
66
- }
67
- close() {
68
- this.readyState = this.CLOSED;
69
- this._dispatch('close', {});
70
- }
71
- }
72
- vi.stubGlobal('WebSocket', FakeWebSocket);
73
- return sockets;
74
- }
75
-
76
- describe('app/ws client', () => {
77
- test('correlates replies for concurrent sends', async () => {
78
- const sockets = setupFakeWebSocket();
79
- const client = createWsClient({
80
- backoff: { initialMs: 5, maxMs: 5, jitterRatio: 0 }
81
- });
82
- // open connection
83
- sockets[0].openNow();
84
-
85
- const p1 = client.send('list-issues', { filters: {} });
86
- const p2 = client.send('show-issue', { id: 'UI-1' });
87
-
88
- // Parse the last two frames to extract ids
89
- const frames = sockets[0].sent
90
- .slice(-2)
91
- .map((/** @type {string} */ s) => JSON.parse(s));
92
- const id1 = frames[0].id;
93
- const id2 = frames[1].id;
94
-
95
- // Reply out of order
96
- sockets[0].emitMessage({
97
- id: id2,
98
- ok: true,
99
- type: 'show-issue',
100
- payload: { id: 'UI-1' }
101
- });
102
- sockets[0].emitMessage({
103
- id: id1,
104
- ok: true,
105
- type: 'list-issues',
106
- payload: [{ id: 'UI-1' }]
107
- });
108
-
109
- await expect(p2).resolves.toEqual({ id: 'UI-1' });
110
- await expect(p1).resolves.toEqual([{ id: 'UI-1' }]);
111
- });
112
-
113
- test('reconnects and resubscribes after close', async () => {
114
- vi.useFakeTimers();
115
- const sockets = setupFakeWebSocket();
116
- const client = createWsClient({
117
- backoff: { initialMs: 10, maxMs: 10, jitterRatio: 0 }
118
- });
119
-
120
- // First connection opens
121
- sockets[0].openNow();
122
- // subscribe-updates should be first frame
123
- const firstFrame = JSON.parse(sockets[0].sent[0]);
124
- expect(firstFrame.type).toBe('subscribe-updates');
125
-
126
- // Close the socket to trigger reconnect
127
- sockets[0].close();
128
- // Advance timers for reconnect
129
- await vi.advanceTimersByTimeAsync(10);
130
-
131
- // Second socket should exist and open
132
- expect(sockets.length).toBeGreaterThan(1);
133
- sockets[1].openNow();
134
- const sub = JSON.parse(sockets[1].sent[0]);
135
- expect(sub.type).toBe('subscribe-updates');
136
-
137
- vi.useRealTimers();
138
- client.close();
139
- });
140
-
141
- test('dispatches server events and logs unknown types', async () => {
142
- const sockets = setupFakeWebSocket();
143
- const logger = { ...console, warn: vi.fn(), error: vi.fn() };
144
- const client = createWsClient({ logger });
145
- sockets[0].openNow();
146
-
147
- /** @type {any[]} */
148
- const events = [];
149
- client.on('issues-changed', (p) => events.push(p));
150
- sockets[0].emitMessage({
151
- id: 'evt-1',
152
- ok: true,
153
- type: 'issues-changed',
154
- payload: { ids: ['UI-1'] }
155
- });
156
- expect(events).toEqual([{ ids: ['UI-1'] }]);
157
-
158
- // No handler registered for create-issue -> warn
159
- sockets[0].emitMessage({
160
- id: 'evt-2',
161
- ok: true,
162
- type: 'create-issue',
163
- payload: {}
164
- });
165
- expect(logger.warn).toHaveBeenCalled();
166
- client.close();
167
- });
168
- });