@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.
- package/.github/workflows/publish.yml +28 -0
- package/app/protocol.js +216 -0
- package/bin/bdui +19 -0
- package/client/index.html +12 -0
- package/client/postcss.config.js +11 -0
- package/client/src/App.tsx +35 -0
- package/client/src/components/IssueCard.tsx +73 -0
- package/client/src/components/Layout.tsx +175 -0
- package/client/src/components/Markdown.tsx +77 -0
- package/client/src/components/PriorityBadge.tsx +26 -0
- package/client/src/components/SearchDialog.tsx +137 -0
- package/client/src/components/SectionEditor.tsx +212 -0
- package/client/src/components/StatusBadge.tsx +64 -0
- package/client/src/components/TypeBadge.tsx +26 -0
- package/client/src/hooks/use-mutation.ts +55 -0
- package/client/src/hooks/use-search.ts +19 -0
- package/client/src/hooks/use-subscription.ts +187 -0
- package/client/src/index.css +133 -0
- package/client/src/lib/avatar.ts +17 -0
- package/client/src/lib/types.ts +115 -0
- package/client/src/lib/ws-client.ts +214 -0
- package/client/src/lib/ws-context.tsx +28 -0
- package/client/src/main.tsx +10 -0
- package/client/src/views/Board.tsx +200 -0
- package/client/src/views/Detail.tsx +398 -0
- package/client/src/views/List.tsx +461 -0
- package/client/tailwind.config.ts +68 -0
- package/client/tsconfig.json +16 -0
- package/client/vite.config.ts +20 -0
- package/package.json +43 -0
- package/server/app.js +120 -0
- package/server/app.test.js +30 -0
- package/server/bd.js +227 -0
- package/server/bd.test.js +194 -0
- package/server/cli/cli.test.js +207 -0
- package/server/cli/commands.integration.test.js +148 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/commands.unit.test.js +408 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/daemon.test.js +31 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/db.test.js +169 -0
- package/server/dolt-pool.js +257 -0
- package/server/dolt-queries.js +646 -0
- package/server/index.js +97 -0
- package/server/list-adapters.js +395 -0
- package/server/list-adapters.test.js +208 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/subscriptions.test.js +128 -0
- package/server/validators.js +124 -0
- package/server/watcher.js +139 -0
- package/server/watcher.test.js +120 -0
- package/server/ws.comments.test.js +262 -0
- package/server/ws.delete.test.js +119 -0
- package/server/ws.js +1309 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.list-refresh.coalesce.test.js +95 -0
- package/server/ws.list-subscriptions.test.js +403 -0
- package/server/ws.mutation-window.test.js +147 -0
- package/server/ws.mutations.test.js +389 -0
- 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.
|