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 (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -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,261 @@
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 invalid payload yields bad_request', async () => {
50
+ const ws = makeStubSocket();
51
+ const req = {
52
+ id: 'r2',
53
+ type: 'update-status',
54
+ payload: { id: 'UI-7', status: 'bogus' }
55
+ };
56
+ await handleMessage(
57
+ /** @type {any} */ (ws),
58
+ Buffer.from(JSON.stringify(req))
59
+ );
60
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
61
+ expect(obj.ok).toBe(false);
62
+ expect(obj.error.code).toBe('bad_request');
63
+ });
64
+
65
+ test('update-priority success path', async () => {
66
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
67
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
68
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
69
+ mJson.mockResolvedValueOnce({
70
+ code: 0,
71
+ stdoutJson: { id: 'UI-7', priority: 1 }
72
+ });
73
+ const ws = makeStubSocket();
74
+ const req = {
75
+ id: 'r3',
76
+ type: 'update-priority',
77
+ payload: { id: 'UI-7', priority: 1 }
78
+ };
79
+ await handleMessage(
80
+ /** @type {any} */ (ws),
81
+ Buffer.from(JSON.stringify(req))
82
+ );
83
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
84
+ expect(obj.ok).toBe(true);
85
+ expect(obj.payload.priority).toBe(1);
86
+ });
87
+
88
+ test('edit-text title success', async () => {
89
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
90
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
91
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
92
+ mJson.mockResolvedValueOnce({
93
+ code: 0,
94
+ stdoutJson: { id: 'UI-7', title: 'New' }
95
+ });
96
+ const ws = makeStubSocket();
97
+ const req = {
98
+ id: 'r4',
99
+ type: 'edit-text',
100
+ payload: { id: 'UI-7', field: 'title', value: 'New' }
101
+ };
102
+ await handleMessage(
103
+ /** @type {any} */ (ws),
104
+ Buffer.from(JSON.stringify(req))
105
+ );
106
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
107
+ expect(obj.ok).toBe(true);
108
+ expect(obj.payload.title).toBe('New');
109
+ });
110
+
111
+ // update-type removed; no server handler remains
112
+
113
+ test('update-assignee validates and returns updated issue', async () => {
114
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
115
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
116
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
117
+ mJson.mockResolvedValueOnce({ code: 0, stdoutJson: { id: 'UI-2' } });
118
+ const ws = makeStubSocket();
119
+ const req = {
120
+ id: 'rua',
121
+ type: /** @type {any} */ ('update-assignee'),
122
+ payload: { id: 'UI-2', assignee: 'max' }
123
+ };
124
+ await handleMessage(
125
+ /** @type {any} */ (ws),
126
+ Buffer.from(JSON.stringify(req))
127
+ );
128
+ const call = mRun.mock.calls[mRun.mock.calls.length - 1];
129
+ expect(call[0][0]).toBe('update');
130
+ expect(call[0].includes('--assignee')).toBe(true);
131
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
132
+ expect(obj.ok).toBe(true);
133
+ expect(obj.payload.id).toBe('UI-2');
134
+ });
135
+
136
+ test('update-assignee allows clearing with empty string', async () => {
137
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
138
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
139
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
140
+ mJson.mockResolvedValueOnce({ code: 0, stdoutJson: { id: 'UI-31' } });
141
+ const ws = makeStubSocket();
142
+ const req = {
143
+ id: 'rua2',
144
+ type: /** @type {any} */ ('update-assignee'),
145
+ payload: { id: 'UI-31', assignee: '' }
146
+ };
147
+ await handleMessage(
148
+ /** @type {any} */ (ws),
149
+ Buffer.from(JSON.stringify(req))
150
+ );
151
+ const call = mRun.mock.calls[mRun.mock.calls.length - 1];
152
+ expect(call[0]).toEqual(['update', 'UI-31', '--assignee', '']);
153
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
154
+ expect(obj.ok).toBe(true);
155
+ expect(obj.payload.id).toBe('UI-31');
156
+ });
157
+
158
+ test('edit-text acceptance success', async () => {
159
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
160
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
161
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
162
+ mJson.mockResolvedValueOnce({
163
+ code: 0,
164
+ stdoutJson: { id: 'UI-7', acceptance: 'Done when...' }
165
+ });
166
+ const ws = makeStubSocket();
167
+ const req = {
168
+ id: 'r4a',
169
+ type: 'edit-text',
170
+ payload: { id: 'UI-7', field: 'acceptance', value: 'Done when...' }
171
+ };
172
+ await handleMessage(
173
+ /** @type {any} */ (ws),
174
+ Buffer.from(JSON.stringify(req))
175
+ );
176
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
177
+ expect(obj.ok).toBe(true);
178
+ expect(obj.payload.acceptance).toBe('Done when...');
179
+ // Verify correct flag mapping for acceptance
180
+ expect(mRun.mock.calls[0][0]).toEqual([
181
+ 'update',
182
+ 'UI-7',
183
+ '--acceptance-criteria',
184
+ 'Done when...'
185
+ ]);
186
+ });
187
+
188
+ test('edit-text description yields bd_error (unsupported)', async () => {
189
+ const ws = makeStubSocket();
190
+ const req = {
191
+ id: 'r4b',
192
+ type: 'edit-text',
193
+ payload: { id: 'UI-7', field: 'description', value: 'New desc' }
194
+ };
195
+ await handleMessage(
196
+ /** @type {any} */ (ws),
197
+ Buffer.from(JSON.stringify(req))
198
+ );
199
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
200
+ expect(obj.ok).toBe(false);
201
+ expect(obj.error.code).toBe('bd_error');
202
+ });
203
+
204
+ test('dep-add returns updated issue (view_id)', async () => {
205
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
206
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
207
+ mRun.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
208
+ mJson.mockResolvedValueOnce({
209
+ code: 0,
210
+ stdoutJson: { id: 'UI-7', dependencies: [] }
211
+ });
212
+ const ws = makeStubSocket();
213
+ const req = {
214
+ id: 'r5',
215
+ type: 'dep-add',
216
+ payload: { a: 'UI-7', b: 'UI-1', view_id: 'UI-7' }
217
+ };
218
+ await handleMessage(
219
+ /** @type {any} */ (ws),
220
+ Buffer.from(JSON.stringify(req))
221
+ );
222
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
223
+ expect(obj.ok).toBe(true);
224
+ expect(obj.payload.id).toBe('UI-7');
225
+ });
226
+
227
+ test('dep-remove bad payload yields bad_request', async () => {
228
+ const ws = makeStubSocket();
229
+ const req = { id: 'r6', type: 'dep-remove', payload: { a: '' } };
230
+ await handleMessage(
231
+ /** @type {any} */ (ws),
232
+ Buffer.from(JSON.stringify(req))
233
+ );
234
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
235
+ expect(obj.ok).toBe(false);
236
+ expect(obj.error.code).toBe('bad_request');
237
+ });
238
+
239
+ test('create-issue acks on success', async () => {
240
+ const mRun = /** @type {import('vitest').Mock} */ (runBd);
241
+ mRun.mockResolvedValueOnce({ code: 0, stdout: 'UI-99', stderr: '' });
242
+ const ws = makeStubSocket();
243
+ const req = {
244
+ id: 'r7',
245
+ type: 'create-issue',
246
+ payload: {
247
+ title: 'New item',
248
+ type: 'task',
249
+ priority: 2,
250
+ description: 'x'
251
+ }
252
+ };
253
+ await handleMessage(
254
+ /** @type {any} */ (ws),
255
+ Buffer.from(JSON.stringify(req))
256
+ );
257
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
258
+ expect(obj.ok).toBe(true);
259
+ expect(obj.payload && obj.payload.created).toBe(true);
260
+ });
261
+ });
@@ -0,0 +1,116 @@
1
+ import { createServer } from 'node:http';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { runBdJson } from './bd.js';
4
+ import { attachWsServer, handleMessage, notifyIssuesChanged } from './ws.js';
5
+
6
+ vi.mock('./bd.js', () => ({ runBdJson: vi.fn(), runBd: vi.fn() }));
7
+
8
+ describe('ws subscriptions + targeted fanout', () => {
9
+ test('subscribe-updates ack and targeted issues-changed by show id', async () => {
10
+ const mJson = /** @type {import('vitest').Mock} */ (runBdJson);
11
+ // show-issue → return object with id
12
+ mJson.mockImplementation(async (args) => {
13
+ if (Array.isArray(args) && args[0] === 'show') {
14
+ return { code: 0, stdoutJson: { id: String(args[1]), status: 'open' } };
15
+ }
16
+ return { code: 0, stdoutJson: [] };
17
+ });
18
+
19
+ // Create an http server object but do not listen; just to satisfy attachWsServer
20
+ const server = createServer();
21
+ const { wss } = attachWsServer(server, {
22
+ path: '/ws',
23
+ heartbeat_ms: 10000
24
+ });
25
+
26
+ // Create two stub sockets and register them as connected clients
27
+ const a = {
28
+ sent: /** @type {string[]} */ ([]),
29
+ readyState: 1,
30
+ OPEN: 1,
31
+ /** @param {string} msg */
32
+ send(msg) {
33
+ this.sent.push(String(msg));
34
+ }
35
+ };
36
+ const b = {
37
+ sent: /** @type {string[]} */ ([]),
38
+ readyState: 1,
39
+ OPEN: 1,
40
+ /** @param {string} msg */
41
+ send(msg) {
42
+ this.sent.push(String(msg));
43
+ }
44
+ };
45
+ wss.clients.add(/** @type {any} */ (a));
46
+ wss.clients.add(/** @type {any} */ (b));
47
+
48
+ // Subscribe both and set show-issue for A
49
+ await handleMessage(
50
+ /** @type {any} */ (a),
51
+ Buffer.from(JSON.stringify({ id: 's1', type: 'subscribe-updates' }))
52
+ );
53
+ await handleMessage(
54
+ /** @type {any} */ (b),
55
+ Buffer.from(JSON.stringify({ id: 's2', type: 'subscribe-updates' }))
56
+ );
57
+ await handleMessage(
58
+ /** @type {any} */ (a),
59
+ Buffer.from(
60
+ JSON.stringify({
61
+ id: 'q1',
62
+ type: 'show-issue',
63
+ payload: { id: 'UI-1' }
64
+ })
65
+ )
66
+ );
67
+
68
+ // Now emit a targeted change for UI-1
69
+ notifyIssuesChanged(
70
+ { hint: { ids: ['UI-1'] } },
71
+ { issue: { id: 'UI-1', status: 'open' } }
72
+ );
73
+
74
+ const aHas = a.sent.some((m) => {
75
+ try {
76
+ const o = JSON.parse(m);
77
+ return o && o.type === 'issues-changed';
78
+ } catch {
79
+ return false;
80
+ }
81
+ });
82
+ const bHas = b.sent.some((m) => {
83
+ try {
84
+ const o = JSON.parse(m);
85
+ return o && o.type === 'issues-changed';
86
+ } catch {
87
+ return false;
88
+ }
89
+ });
90
+
91
+ expect(aHas).toBe(true);
92
+ expect(bHas).toBe(false);
93
+ });
94
+
95
+ test('subscribe-updates handler replies ok for bare ws', async () => {
96
+ const ws = {
97
+ sent: /** @type {string[]} */ ([]),
98
+ readyState: 1,
99
+ OPEN: 1,
100
+ /** @param {string} msg */
101
+ send(msg) {
102
+ this.sent.push(String(msg));
103
+ }
104
+ };
105
+ const req = { id: 'sub1', type: 'subscribe-updates', payload: {} };
106
+ await handleMessage(
107
+ /** @type {any} */ (ws),
108
+ Buffer.from(JSON.stringify(req))
109
+ );
110
+ const last = ws.sent[ws.sent.length - 1];
111
+ const obj = JSON.parse(last);
112
+ expect(obj.ok).toBe(true);
113
+ expect(obj.type).toBe('subscribe-updates');
114
+ expect(obj.payload && obj.payload.subscribed).toBe(true);
115
+ });
116
+ });
@@ -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.
@@ -0,0 +1,12 @@
1
+ /* global console */
2
+ // Suppress Lit dev-mode warning in Vitest
3
+ // Provided snippet: overrides console.warn but forwards all other messages
4
+ const { warn } = console;
5
+ console.warn = /** @type {function(...*): void} */ (
6
+ (...args) => {
7
+ // Filter out the noisy Lit dev-mode banner in tests
8
+ if (!args[0].startsWith('Lit is in dev mode.')) {
9
+ warn.call(console, ...args);
10
+ }
11
+ }
12
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowJs": true,
7
+ "checkJs": true,
8
+ "noEmit": true,
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "resolveJsonModule": true,
14
+ "lib": ["ES2022", "DOM"],
15
+ "types": ["vitest/globals", "node"]
16
+ },
17
+ "include": [
18
+ "app/**/*.js",
19
+ "server/**/*.js",
20
+ "test/**/*.js",
21
+ "vitest.config.mjs"
22
+ ]
23
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['**/*.test.js'],
7
+ setupFiles: ['test/setup-vitest.js'],
8
+ environmentMatchGlobs: [['app/**/*.test.js', 'jsdom']],
9
+ reporters: 'default',
10
+ coverage: {
11
+ reporter: ['text', 'html']
12
+ }
13
+ }
14
+ });