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/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, 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
|
+
afterEach(() => {
|
|
29
|
+
vi.useRealTimers();
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('watchDb', () => {
|
|
34
|
+
test('debounces rapid change events', () => {
|
|
35
|
+
const calls = [];
|
|
36
|
+
const handle = watchDb('/repo', (p) => calls.push(p), {
|
|
37
|
+
debounce_ms: 100,
|
|
38
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
39
|
+
});
|
|
40
|
+
expect(watchers.length).toBe(1);
|
|
41
|
+
const { cb } = watchers[0];
|
|
42
|
+
|
|
43
|
+
// Fire multiple changes in quick succession
|
|
44
|
+
cb('change', 'ui.db');
|
|
45
|
+
cb('change', 'ui.db');
|
|
46
|
+
cb('rename', 'ui.db');
|
|
47
|
+
|
|
48
|
+
// Nothing yet until debounce passes
|
|
49
|
+
expect(calls.length).toBe(0);
|
|
50
|
+
vi.advanceTimersByTime(99);
|
|
51
|
+
expect(calls.length).toBe(0);
|
|
52
|
+
vi.advanceTimersByTime(1);
|
|
53
|
+
expect(calls.length).toBe(1);
|
|
54
|
+
|
|
55
|
+
// Cleanup
|
|
56
|
+
handle.close();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('ignores other filenames', () => {
|
|
60
|
+
const calls = [];
|
|
61
|
+
const handle = watchDb('/repo', (p) => calls.push(p), {
|
|
62
|
+
debounce_ms: 50,
|
|
63
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
64
|
+
});
|
|
65
|
+
const { cb } = watchers[0];
|
|
66
|
+
cb('change', 'something-else.db');
|
|
67
|
+
vi.advanceTimersByTime(60);
|
|
68
|
+
expect(calls.length).toBe(0);
|
|
69
|
+
handle.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('rebind attaches to new db path', () => {
|
|
73
|
+
const calls = [];
|
|
74
|
+
const handle = watchDb('/repo', (p) => calls.push(p), {
|
|
75
|
+
debounce_ms: 50,
|
|
76
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
77
|
+
});
|
|
78
|
+
expect(watchers.length).toBe(1);
|
|
79
|
+
const first = watchers[0];
|
|
80
|
+
|
|
81
|
+
// Rebind to a different DB path
|
|
82
|
+
handle.rebind({ explicit_db: '/other/.beads/alt.db' });
|
|
83
|
+
|
|
84
|
+
// A new watcher is created
|
|
85
|
+
expect(watchers.length).toBe(2);
|
|
86
|
+
const second = watchers[1];
|
|
87
|
+
|
|
88
|
+
// Old watcher should ignore new file name
|
|
89
|
+
first.cb('change', 'ui.db');
|
|
90
|
+
vi.advanceTimersByTime(60);
|
|
91
|
+
expect(calls.length).toBe(0);
|
|
92
|
+
|
|
93
|
+
// New watcher reacts
|
|
94
|
+
second.cb('change', 'alt.db');
|
|
95
|
+
vi.advanceTimersByTime(60);
|
|
96
|
+
expect(calls.length).toBe(1);
|
|
97
|
+
|
|
98
|
+
handle.close();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { runBdJson } from './bd.js';
|
|
3
|
+
import { handleMessage } from './ws.js';
|
|
4
|
+
|
|
5
|
+
vi.mock('./bd.js', () => ({ runBdJson: vi.fn() }));
|
|
6
|
+
|
|
7
|
+
function makeStubSocket() {
|
|
8
|
+
return {
|
|
9
|
+
sent: /** @type {string[]} */ ([]),
|
|
10
|
+
readyState: 1,
|
|
11
|
+
OPEN: 1,
|
|
12
|
+
/** @param {string} msg */
|
|
13
|
+
send(msg) {
|
|
14
|
+
this.sent.push(String(msg));
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ws handlers: list/show', () => {
|
|
20
|
+
test('list-issues forwards payload from bd', async () => {
|
|
21
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
22
|
+
mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [{ id: 'UI-1' }] });
|
|
23
|
+
const ws = makeStubSocket();
|
|
24
|
+
const req = {
|
|
25
|
+
id: 'r1',
|
|
26
|
+
type: 'list-issues',
|
|
27
|
+
payload: { filters: { status: 'open' } }
|
|
28
|
+
};
|
|
29
|
+
await handleMessage(
|
|
30
|
+
/** @type {any} */ (ws),
|
|
31
|
+
Buffer.from(JSON.stringify(req))
|
|
32
|
+
);
|
|
33
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
34
|
+
expect(obj.ok).toBe(true);
|
|
35
|
+
expect(Array.isArray(obj.payload)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('list-issues with filters.ready uses bd "ready"', async () => {
|
|
39
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
40
|
+
mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [{ id: 'UI-2' }] });
|
|
41
|
+
const ws = makeStubSocket();
|
|
42
|
+
const req = {
|
|
43
|
+
id: 'r1a',
|
|
44
|
+
type: 'list-issues',
|
|
45
|
+
payload: { filters: { ready: true } }
|
|
46
|
+
};
|
|
47
|
+
await handleMessage(
|
|
48
|
+
/** @type {any} */ (ws),
|
|
49
|
+
Buffer.from(JSON.stringify(req))
|
|
50
|
+
);
|
|
51
|
+
// Ensure we called the ready command
|
|
52
|
+
const call = mocked.mock.calls[mocked.mock.calls.length - 1];
|
|
53
|
+
expect(Array.isArray(call[0])).toBe(true);
|
|
54
|
+
expect(call[0][0]).toBe('ready');
|
|
55
|
+
|
|
56
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
57
|
+
expect(obj.ok).toBe(true);
|
|
58
|
+
expect(Array.isArray(obj.payload)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('show-issue returns error on missing id', async () => {
|
|
62
|
+
const ws = makeStubSocket();
|
|
63
|
+
const req = { id: 'r2', type: 'show-issue', payload: {} };
|
|
64
|
+
await handleMessage(
|
|
65
|
+
/** @type {any} */ (ws),
|
|
66
|
+
Buffer.from(JSON.stringify(req))
|
|
67
|
+
);
|
|
68
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
69
|
+
expect(obj.ok).toBe(false);
|
|
70
|
+
expect(obj.error.code).toBe('bad_request');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('show-issue forwards bd JSON on success', async () => {
|
|
74
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
75
|
+
mocked.mockResolvedValueOnce({
|
|
76
|
+
code: 0,
|
|
77
|
+
stdoutJson: { id: 'UI-9', title: 'X' }
|
|
78
|
+
});
|
|
79
|
+
const ws = makeStubSocket();
|
|
80
|
+
const req = { id: 'r3', type: 'show-issue', payload: { id: 'UI-9' } };
|
|
81
|
+
await handleMessage(
|
|
82
|
+
/** @type {any} */ (ws),
|
|
83
|
+
Buffer.from(JSON.stringify(req))
|
|
84
|
+
);
|
|
85
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
86
|
+
expect(obj.ok).toBe(true);
|
|
87
|
+
expect(obj.payload.id).toBe('UI-9');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('show-issue unwraps single-element arrays from bd', async () => {
|
|
91
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
92
|
+
mocked.mockResolvedValueOnce({
|
|
93
|
+
code: 0,
|
|
94
|
+
stdoutJson: [{ id: 'UI-9', title: 'X' }]
|
|
95
|
+
});
|
|
96
|
+
const ws = makeStubSocket();
|
|
97
|
+
const req = { id: 'r3a', type: 'show-issue', payload: { id: 'UI-9' } };
|
|
98
|
+
await handleMessage(
|
|
99
|
+
/** @type {any} */ (ws),
|
|
100
|
+
Buffer.from(JSON.stringify(req))
|
|
101
|
+
);
|
|
102
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
103
|
+
expect(obj.ok).toBe(true);
|
|
104
|
+
expect(obj.payload && obj.payload.id).toBe('UI-9');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('show-issue returns not_found when bd returns empty array', async () => {
|
|
108
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
109
|
+
mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
|
|
110
|
+
const ws = makeStubSocket();
|
|
111
|
+
const req = { id: 'r3b', type: 'show-issue', payload: { id: 'X' } };
|
|
112
|
+
await handleMessage(
|
|
113
|
+
/** @type {any} */ (ws),
|
|
114
|
+
Buffer.from(JSON.stringify(req))
|
|
115
|
+
);
|
|
116
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
117
|
+
expect(obj.ok).toBe(false);
|
|
118
|
+
expect(obj.error && obj.error.code).toBe('not_found');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('epic-status forwards bd epic status', async () => {
|
|
122
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
123
|
+
mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
|
|
124
|
+
const ws = makeStubSocket();
|
|
125
|
+
const req = { id: 're', type: /** @type {any} */ ('epic-status') };
|
|
126
|
+
await handleMessage(
|
|
127
|
+
/** @type {any} */ (ws),
|
|
128
|
+
Buffer.from(JSON.stringify(req))
|
|
129
|
+
);
|
|
130
|
+
const call = mocked.mock.calls[mocked.mock.calls.length - 1];
|
|
131
|
+
expect(Array.isArray(call[0])).toBe(true);
|
|
132
|
+
expect(call[0][0]).toBe('epic');
|
|
133
|
+
expect(call[0][1]).toBe('status');
|
|
134
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
135
|
+
expect(obj.ok).toBe(true);
|
|
136
|
+
expect(Array.isArray(obj.payload)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('list-issues supports limit passthrough', async () => {
|
|
140
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
141
|
+
mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
|
|
142
|
+
const ws = makeStubSocket();
|
|
143
|
+
const req = {
|
|
144
|
+
id: 'rlim',
|
|
145
|
+
type: 'list-issues',
|
|
146
|
+
payload: { filters: { status: 'closed', limit: 10 } }
|
|
147
|
+
};
|
|
148
|
+
await handleMessage(
|
|
149
|
+
/** @type {any} */ (ws),
|
|
150
|
+
Buffer.from(JSON.stringify(req))
|
|
151
|
+
);
|
|
152
|
+
const call = mocked.mock.calls[mocked.mock.calls.length - 1];
|
|
153
|
+
const args = /** @type {string[]} */ (call[0]);
|
|
154
|
+
expect(args.includes('--limit')).toBe(true);
|
|
155
|
+
expect(args.includes('10')).toBe(true);
|
|
156
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
157
|
+
expect(obj.ok).toBe(true);
|
|
158
|
+
expect(Array.isArray(obj.payload)).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('bd error propagates as bd_error reply', async () => {
|
|
162
|
+
const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
163
|
+
mocked.mockResolvedValueOnce({ code: 1, stderr: 'boom' });
|
|
164
|
+
const ws = makeStubSocket();
|
|
165
|
+
const req = { id: 'r4', type: 'list-issues', payload: {} };
|
|
166
|
+
await handleMessage(
|
|
167
|
+
/** @type {any} */ (ws),
|
|
168
|
+
Buffer.from(JSON.stringify(req))
|
|
169
|
+
);
|
|
170
|
+
const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
|
|
171
|
+
expect(obj.ok).toBe(false);
|
|
172
|
+
expect(obj.error.code).toBe('bd_error');
|
|
173
|
+
});
|
|
174
|
+
});
|