@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,124 @@
1
+ /**
2
+ * Validation helpers for protocol payloads.
3
+ *
4
+ * Provides schema checks for subscription specs and selected mutations.
5
+ */
6
+
7
+ /**
8
+ * Known subscription types supported by the server.
9
+ *
10
+ * @type {Set<string>}
11
+ */
12
+ const SUBSCRIPTION_TYPES = new Set([
13
+ 'all-issues',
14
+ 'epics',
15
+ 'blocked-issues',
16
+ 'ready-issues',
17
+ 'in-progress-issues',
18
+ 'closed-issues',
19
+ 'search-issues',
20
+ 'issue-detail'
21
+ ]);
22
+
23
+ /**
24
+ * Validate a subscribe-list payload and normalize to a SubscriptionSpec.
25
+ *
26
+ * @param {unknown} payload
27
+ * @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
28
+ */
29
+ export function validateSubscribeListPayload(payload) {
30
+ if (!payload || typeof payload !== 'object') {
31
+ return {
32
+ ok: false,
33
+ code: 'bad_request',
34
+ message: 'payload must be an object'
35
+ };
36
+ }
37
+ const any =
38
+ /** @type {{ id?: unknown, type?: unknown, params?: unknown }} */ (payload);
39
+
40
+ const id = typeof any.id === 'string' ? any.id : '';
41
+ if (id.length === 0) {
42
+ return {
43
+ ok: false,
44
+ code: 'bad_request',
45
+ message: 'payload.id must be a non-empty string'
46
+ };
47
+ }
48
+
49
+ const type = typeof any.type === 'string' ? any.type : '';
50
+ if (type.length === 0 || !SUBSCRIPTION_TYPES.has(type)) {
51
+ return {
52
+ ok: false,
53
+ code: 'bad_request',
54
+ message: `payload.type must be one of: ${Array.from(SUBSCRIPTION_TYPES).join(', ')}`
55
+ };
56
+ }
57
+
58
+ /** @type {Record<string, string|number|boolean> | undefined} */
59
+ let params;
60
+ if (any.params !== undefined) {
61
+ if (
62
+ !any.params ||
63
+ typeof any.params !== 'object' ||
64
+ Array.isArray(any.params)
65
+ ) {
66
+ return {
67
+ ok: false,
68
+ code: 'bad_request',
69
+ message: 'payload.params must be an object when provided'
70
+ };
71
+ }
72
+ params = /** @type {Record<string, string|number|boolean>} */ (any.params);
73
+ }
74
+
75
+ // Per-type param schemas
76
+ if (type === 'issue-detail') {
77
+ const id = String(params?.id ?? '').trim();
78
+ if (id.length === 0) {
79
+ return {
80
+ ok: false,
81
+ code: 'bad_request',
82
+ message: 'params.id must be a non-empty string'
83
+ };
84
+ }
85
+ params = { id };
86
+ } else if (type === 'search-issues') {
87
+ /** @type {Record<string, string|number|boolean>} */
88
+ const cleaned = {};
89
+ if (params && typeof params.q === 'string') cleaned.q = params.q;
90
+ if (params && typeof params.status === 'string') cleaned.status = params.status;
91
+ if (params && typeof params.type === 'string') cleaned.type = params.type;
92
+ if (params && typeof params.limit === 'number') cleaned.limit = params.limit;
93
+ if (params && typeof params.offset === 'number') cleaned.offset = params.offset;
94
+ params = Object.keys(cleaned).length > 0 ? cleaned : undefined;
95
+ } else if (type === 'closed-issues') {
96
+ /** @type {Record<string, string|number|boolean>} */
97
+ const cleaned = {};
98
+ if (params && 'since' in params) {
99
+ const since = params.since;
100
+ const n = typeof since === 'number' ? since : Number.NaN;
101
+ if (!Number.isFinite(n) || n < 0) {
102
+ return {
103
+ ok: false,
104
+ code: 'bad_request',
105
+ message: 'params.since must be a non-negative number (epoch ms)'
106
+ };
107
+ }
108
+ cleaned.since = n;
109
+ }
110
+ // Allow pagination params
111
+ if (params && typeof params.limit === 'number') cleaned.limit = params.limit;
112
+ if (params && typeof params.offset === 'number') cleaned.offset = params.offset;
113
+ params = Object.keys(cleaned).length > 0 ? cleaned : undefined;
114
+ } else {
115
+ // Allow pagination params (limit, offset) for all list types
116
+ /** @type {Record<string, string|number|boolean>} */
117
+ const cleaned = {};
118
+ if (params && typeof params.limit === 'number') cleaned.limit = params.limit;
119
+ if (params && typeof params.offset === 'number') cleaned.offset = params.offset;
120
+ params = Object.keys(cleaned).length > 0 ? cleaned : undefined;
121
+ }
122
+
123
+ return { ok: true, id, spec: { type, params } };
124
+ }
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { resolveWorkspaceDatabase } from './db.js';
4
+ import { debug } from './logging.js';
5
+
6
+ /**
7
+ * Watch the resolved workspace database target and invoke a callback after a
8
+ * debounce window.
9
+ *
10
+ * For SQLite workspaces this watches the DB file's parent directory and filters
11
+ * by file name. For non-SQLite backends (for example Dolt), this watches the
12
+ * workspace `.beads` directory.
13
+ *
14
+ * @param {string} root_dir - Project root directory (starting point for resolution).
15
+ * @param {() => void} onChange - Called when changes are detected.
16
+ * @param {{ debounce_ms?: number, cooldown_ms?: number, explicit_db?: string }} [options]
17
+ * @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
18
+ */
19
+ export function watchDb(root_dir, onChange, options = {}) {
20
+ const debounce_ms = options.debounce_ms ?? 250;
21
+ const cooldown_ms = options.cooldown_ms ?? 1000;
22
+ const log = debug('watcher');
23
+
24
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
25
+ let timer;
26
+ /** @type {fs.FSWatcher | undefined} */
27
+ let watcher;
28
+ let cooldown_until = 0;
29
+ let current_path = '';
30
+ let current_dir = '';
31
+ let current_file = '';
32
+
33
+ /**
34
+ * Schedule the debounced onChange callback.
35
+ */
36
+ const schedule = () => {
37
+ if (timer) {
38
+ clearTimeout(timer);
39
+ }
40
+ timer = setTimeout(() => {
41
+ onChange();
42
+ cooldown_until = Date.now() + cooldown_ms;
43
+ }, debounce_ms);
44
+ timer.unref();
45
+ };
46
+
47
+ /**
48
+ * Attach a watcher to the directory containing the resolved DB path.
49
+ *
50
+ * @param {string} base_dir
51
+ * @param {string | undefined} explicit_db
52
+ */
53
+ const bind = (base_dir, explicit_db) => {
54
+ const resolved = resolveWorkspaceDatabase({ cwd: base_dir, explicit_db });
55
+ current_path = resolved.path;
56
+ if (pathIsDirectory(current_path)) {
57
+ current_dir = current_path;
58
+ current_file = '';
59
+ } else {
60
+ current_dir = path.dirname(current_path);
61
+ current_file = path.basename(current_path);
62
+ }
63
+ if (!resolved.exists) {
64
+ log(
65
+ 'resolved workspace database missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
66
+ current_path
67
+ );
68
+ }
69
+
70
+ // (Re)create watcher
71
+ try {
72
+ watcher = fs.watch(
73
+ current_dir,
74
+ { persistent: true },
75
+ (event_type, filename) => {
76
+ if (current_file && filename && String(filename) !== current_file) {
77
+ return;
78
+ }
79
+ if (event_type === 'change' || event_type === 'rename') {
80
+ if (Date.now() < cooldown_until) {
81
+ return;
82
+ }
83
+ log('fs %s %s', event_type, filename || '');
84
+ schedule();
85
+ }
86
+ }
87
+ );
88
+ } catch (err) {
89
+ log('unable to watch directory %s %o', current_dir, err);
90
+ }
91
+ };
92
+
93
+ // initial bind
94
+ bind(root_dir, options.explicit_db);
95
+
96
+ return {
97
+ get path() {
98
+ return current_path;
99
+ },
100
+ close() {
101
+ if (timer) {
102
+ clearTimeout(timer);
103
+ timer = undefined;
104
+ }
105
+ watcher?.close();
106
+ },
107
+ /**
108
+ * Re-resolve and reattach watcher when root_dir or explicit_db changes.
109
+ *
110
+ * @param {{ root_dir?: string, explicit_db?: string }} [opts]
111
+ */
112
+ rebind(opts = {}) {
113
+ const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
114
+ const next_explicit = opts.explicit_db ?? options.explicit_db;
115
+ const next_resolved = resolveWorkspaceDatabase({
116
+ cwd: next_root,
117
+ explicit_db: next_explicit
118
+ });
119
+ const next_path = next_resolved.path;
120
+ if (next_path !== current_path) {
121
+ // swap watcher
122
+ watcher?.close();
123
+ cooldown_until = 0;
124
+ bind(next_root, next_explicit);
125
+ }
126
+ }
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @param {string} file_path
132
+ */
133
+ function pathIsDirectory(file_path) {
134
+ try {
135
+ return fs.statSync(file_path).isDirectory();
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { watchDb } from './watcher.js';
3
+
4
+ /** @type {{ dir: string, cb: (event: string, filename?: string) => void, w: { close: () => void } }[]} */
5
+ const watchers = [];
6
+
7
+ vi.mock('node:fs', () => {
8
+ const watch = vi.fn((dir, _opts, cb) => {
9
+ // Minimal event emitter interface for FSWatcher
10
+ const handlers = /** @type {{ close: Array<() => void> }} */ ({
11
+ close: []
12
+ });
13
+ const w = {
14
+ close: () => handlers.close.forEach((fn) => fn())
15
+ };
16
+ watchers.push({ dir, cb, w });
17
+ return /** @type {any} */ (w);
18
+ });
19
+ return { default: { watch }, watch };
20
+ });
21
+
22
+ beforeEach(() => {
23
+ watchers.length = 0;
24
+ vi.useFakeTimers();
25
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
26
+ });
27
+
28
+ describe('watchDb', () => {
29
+ test('debounces rapid change events', () => {
30
+ const calls = [];
31
+ const handle = watchDb('/repo', () => calls.push(null), {
32
+ debounce_ms: 100,
33
+ explicit_db: '/repo/.beads/ui.db'
34
+ });
35
+ expect(watchers.length).toBe(1);
36
+ const { cb } = watchers[0];
37
+
38
+ // Fire multiple changes in quick succession
39
+ cb('change', 'ui.db');
40
+ cb('change', 'ui.db');
41
+ cb('rename', 'ui.db');
42
+
43
+ // Nothing yet until debounce passes
44
+ expect(calls.length).toBe(0);
45
+ vi.advanceTimersByTime(99);
46
+ expect(calls.length).toBe(0);
47
+ vi.advanceTimersByTime(1);
48
+ expect(calls.length).toBe(1);
49
+
50
+ // Cleanup
51
+ handle.close();
52
+ });
53
+
54
+ test('ignores other filenames', () => {
55
+ const calls = [];
56
+ const handle = watchDb('/repo', () => calls.push(null), {
57
+ debounce_ms: 50,
58
+ explicit_db: '/repo/.beads/ui.db'
59
+ });
60
+ const { cb } = watchers[0];
61
+ cb('change', 'something-else.db');
62
+ vi.advanceTimersByTime(60);
63
+ expect(calls.length).toBe(0);
64
+ handle.close();
65
+ });
66
+
67
+ test('rebind attaches to new db path', () => {
68
+ const calls = [];
69
+ const handle = watchDb('/repo', () => calls.push(null), {
70
+ debounce_ms: 50,
71
+ explicit_db: '/repo/.beads/ui.db'
72
+ });
73
+ expect(watchers.length).toBe(1);
74
+ const first = watchers[0];
75
+
76
+ // Rebind to a different DB path
77
+ handle.rebind({ explicit_db: '/other/.beads/alt.db' });
78
+
79
+ // A new watcher is created
80
+ expect(watchers.length).toBe(2);
81
+ const second = watchers[1];
82
+
83
+ // Old watcher should ignore new file name
84
+ first.cb('change', 'ui.db');
85
+ vi.advanceTimersByTime(60);
86
+ expect(calls.length).toBe(0);
87
+
88
+ // New watcher reacts
89
+ second.cb('change', 'alt.db');
90
+ vi.advanceTimersByTime(60);
91
+ expect(calls.length).toBe(1);
92
+
93
+ handle.close();
94
+ });
95
+
96
+ test('ignores changes during cooldown window', () => {
97
+ const calls = [];
98
+ const handle = watchDb('/repo', () => calls.push(null), {
99
+ debounce_ms: 10,
100
+ cooldown_ms: 100,
101
+ explicit_db: '/repo/.beads/ui.db'
102
+ });
103
+ const { cb } = watchers[0];
104
+
105
+ cb('change', 'ui.db');
106
+ vi.advanceTimersByTime(10);
107
+ expect(calls.length).toBe(1);
108
+
109
+ cb('change', 'ui.db');
110
+ vi.advanceTimersByTime(10);
111
+ expect(calls.length).toBe(1);
112
+
113
+ vi.advanceTimersByTime(100);
114
+ cb('change', 'ui.db');
115
+ vi.advanceTimersByTime(10);
116
+ expect(calls.length).toBe(2);
117
+
118
+ handle.close();
119
+ });
120
+ });
@@ -0,0 +1,262 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { getGitUserName, 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
+ getGitUserName: vi.fn()
9
+ }));
10
+
11
+ function makeStubSocket() {
12
+ return {
13
+ sent: /** @type {string[]} */ ([]),
14
+ readyState: 1,
15
+ OPEN: 1,
16
+ /** @param {string} msg */
17
+ send(msg) {
18
+ this.sent.push(String(msg));
19
+ }
20
+ };
21
+ }
22
+
23
+ describe('get-comments handler', () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ test('returns comments array on success', async () => {
29
+ const rj = /** @type {import('vitest').Mock} */ (runBdJson);
30
+ const comments = [
31
+ {
32
+ id: 1,
33
+ issue_id: 'UI-1',
34
+ author: 'alice',
35
+ text: 'First comment',
36
+ created_at: '2025-01-01T00:00:00Z'
37
+ },
38
+ {
39
+ id: 2,
40
+ issue_id: 'UI-1',
41
+ author: 'bob',
42
+ text: 'Second comment',
43
+ created_at: '2025-01-02T00:00:00Z'
44
+ }
45
+ ];
46
+ rj.mockResolvedValueOnce({ code: 0, stdoutJson: comments });
47
+
48
+ const ws = makeStubSocket();
49
+ await handleMessage(
50
+ /** @type {any} */ (ws),
51
+ Buffer.from(
52
+ JSON.stringify({
53
+ id: 'req-1',
54
+ type: /** @type {any} */ ('get-comments'),
55
+ payload: { id: 'UI-1' }
56
+ })
57
+ )
58
+ );
59
+
60
+ expect(ws.sent.length).toBe(1);
61
+ const reply = JSON.parse(ws.sent[0]);
62
+ expect(reply.ok).toBe(true);
63
+ expect(reply.payload).toEqual(comments);
64
+
65
+ // Verify bd was called with correct args
66
+ expect(rj).toHaveBeenCalledWith(['comments', 'UI-1', '--json']);
67
+ });
68
+
69
+ test('returns error when issue id missing', async () => {
70
+ const ws = makeStubSocket();
71
+ await handleMessage(
72
+ /** @type {any} */ (ws),
73
+ Buffer.from(
74
+ JSON.stringify({
75
+ id: 'req-2',
76
+ type: /** @type {any} */ ('get-comments'),
77
+ payload: {}
78
+ })
79
+ )
80
+ );
81
+
82
+ expect(ws.sent.length).toBe(1);
83
+ const reply = JSON.parse(ws.sent[0]);
84
+ expect(reply.ok).toBe(false);
85
+ expect(reply.error.code).toBe('bad_request');
86
+ });
87
+
88
+ test('returns error when bd command fails', async () => {
89
+ const rj = /** @type {import('vitest').Mock} */ (runBdJson);
90
+ rj.mockResolvedValueOnce({ code: 1, stderr: 'Issue not found' });
91
+
92
+ const ws = makeStubSocket();
93
+ await handleMessage(
94
+ /** @type {any} */ (ws),
95
+ Buffer.from(
96
+ JSON.stringify({
97
+ id: 'req-3',
98
+ type: /** @type {any} */ ('get-comments'),
99
+ payload: { id: 'UI-999' }
100
+ })
101
+ )
102
+ );
103
+
104
+ expect(ws.sent.length).toBe(1);
105
+ const reply = JSON.parse(ws.sent[0]);
106
+ expect(reply.ok).toBe(false);
107
+ expect(reply.error.code).toBe('bd_error');
108
+ });
109
+ });
110
+
111
+ describe('add-comment handler', () => {
112
+ beforeEach(() => {
113
+ vi.clearAllMocks();
114
+ });
115
+
116
+ test('adds comment with git author and returns updated comments', async () => {
117
+ const gitUser = /** @type {import('vitest').Mock} */ (getGitUserName);
118
+ const rb = /** @type {import('vitest').Mock} */ (runBd);
119
+ const rj = /** @type {import('vitest').Mock} */ (runBdJson);
120
+
121
+ // Mock git config user.name
122
+ gitUser.mockResolvedValueOnce('Test User');
123
+ // Mock bd comment command
124
+ rb.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
125
+ // Mock bd comments --json (returns updated list)
126
+ const updatedComments = [
127
+ {
128
+ id: 1,
129
+ issue_id: 'UI-1',
130
+ author: 'Test User',
131
+ text: 'New comment',
132
+ created_at: '2025-01-01T00:00:00Z'
133
+ }
134
+ ];
135
+ rj.mockResolvedValueOnce({ code: 0, stdoutJson: updatedComments });
136
+
137
+ const ws = makeStubSocket();
138
+ await handleMessage(
139
+ /** @type {any} */ (ws),
140
+ Buffer.from(
141
+ JSON.stringify({
142
+ id: 'req-4',
143
+ type: /** @type {any} */ ('add-comment'),
144
+ payload: { id: 'UI-1', text: 'New comment' }
145
+ })
146
+ )
147
+ );
148
+
149
+ expect(ws.sent.length).toBe(1);
150
+ const reply = JSON.parse(ws.sent[0]);
151
+ expect(reply.ok).toBe(true);
152
+ expect(reply.payload).toEqual(updatedComments);
153
+
154
+ // Verify bd was called with correct args including --author
155
+ expect(rb).toHaveBeenCalledWith([
156
+ 'comment',
157
+ 'UI-1',
158
+ 'New comment',
159
+ '--author',
160
+ 'Test User'
161
+ ]);
162
+ });
163
+
164
+ test('adds comment without author when git user name is empty', async () => {
165
+ const gitUser = /** @type {import('vitest').Mock} */ (getGitUserName);
166
+ const rb = /** @type {import('vitest').Mock} */ (runBd);
167
+ const rj = /** @type {import('vitest').Mock} */ (runBdJson);
168
+
169
+ // Mock empty git user name
170
+ gitUser.mockResolvedValueOnce('');
171
+ // Mock bd comment command
172
+ rb.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
173
+ // Mock bd comments --json
174
+ rj.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
175
+
176
+ const ws = makeStubSocket();
177
+ await handleMessage(
178
+ /** @type {any} */ (ws),
179
+ Buffer.from(
180
+ JSON.stringify({
181
+ id: 'req-5',
182
+ type: /** @type {any} */ ('add-comment'),
183
+ payload: { id: 'UI-1', text: 'Anonymous comment' }
184
+ })
185
+ )
186
+ );
187
+
188
+ expect(ws.sent.length).toBe(1);
189
+ const reply = JSON.parse(ws.sent[0]);
190
+ expect(reply.ok).toBe(true);
191
+
192
+ // Verify bd was called without --author
193
+ expect(rb).toHaveBeenCalledWith(['comment', 'UI-1', 'Anonymous comment']);
194
+ });
195
+
196
+ test('returns error when text is empty', async () => {
197
+ const ws = makeStubSocket();
198
+ await handleMessage(
199
+ /** @type {any} */ (ws),
200
+ Buffer.from(
201
+ JSON.stringify({
202
+ id: 'req-6',
203
+ type: /** @type {any} */ ('add-comment'),
204
+ payload: { id: 'UI-1', text: '' }
205
+ })
206
+ )
207
+ );
208
+
209
+ expect(ws.sent.length).toBe(1);
210
+ const reply = JSON.parse(ws.sent[0]);
211
+ expect(reply.ok).toBe(false);
212
+ expect(reply.error.code).toBe('bad_request');
213
+ });
214
+
215
+ test('returns error when id is missing', async () => {
216
+ const ws = makeStubSocket();
217
+ await handleMessage(
218
+ /** @type {any} */ (ws),
219
+ Buffer.from(
220
+ JSON.stringify({
221
+ id: 'req-7',
222
+ type: /** @type {any} */ ('add-comment'),
223
+ payload: { text: 'Some text' }
224
+ })
225
+ )
226
+ );
227
+
228
+ expect(ws.sent.length).toBe(1);
229
+ const reply = JSON.parse(ws.sent[0]);
230
+ expect(reply.ok).toBe(false);
231
+ expect(reply.error.code).toBe('bad_request');
232
+ });
233
+
234
+ test('returns error when bd comment command fails', async () => {
235
+ const gitUser = /** @type {import('vitest').Mock} */ (getGitUserName);
236
+ const rb = /** @type {import('vitest').Mock} */ (runBd);
237
+
238
+ gitUser.mockResolvedValueOnce('Test User');
239
+ rb.mockResolvedValueOnce({
240
+ code: 1,
241
+ stdout: '',
242
+ stderr: 'Issue not found'
243
+ });
244
+
245
+ const ws = makeStubSocket();
246
+ await handleMessage(
247
+ /** @type {any} */ (ws),
248
+ Buffer.from(
249
+ JSON.stringify({
250
+ id: 'req-8',
251
+ type: /** @type {any} */ ('add-comment'),
252
+ payload: { id: 'UI-999', text: 'Comment' }
253
+ })
254
+ )
255
+ );
256
+
257
+ expect(ws.sent.length).toBe(1);
258
+ const reply = JSON.parse(ws.sent[0]);
259
+ expect(reply.ok).toBe(false);
260
+ expect(reply.error.code).toBe('bd_error');
261
+ });
262
+ });