@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,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,95 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { fetchListForSubscription } from './list-adapters.js';
|
|
4
|
+
import { attachWsServer, handleMessage, scheduleListRefresh } from './ws.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('./list-adapters.js', () => ({
|
|
7
|
+
fetchListForSubscription: vi.fn(async () => {
|
|
8
|
+
return {
|
|
9
|
+
ok: true,
|
|
10
|
+
items: [
|
|
11
|
+
{ id: 'A', updated_at: 1, closed_at: null },
|
|
12
|
+
{ id: 'B', updated_at: 1, closed_at: null }
|
|
13
|
+
]
|
|
14
|
+
};
|
|
15
|
+
})
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('ws list refresh coalescing', () => {
|
|
23
|
+
test('schedules one refresh per burst for active specs', async () => {
|
|
24
|
+
const server = createServer();
|
|
25
|
+
const { wss } = attachWsServer(server, {
|
|
26
|
+
path: '/ws',
|
|
27
|
+
heartbeat_ms: 10000,
|
|
28
|
+
refresh_debounce_ms: 50
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Two connected clients
|
|
32
|
+
const a = {
|
|
33
|
+
sent: /** @type {string[]} */ ([]),
|
|
34
|
+
readyState: 1,
|
|
35
|
+
OPEN: 1,
|
|
36
|
+
/** @param {string} msg */
|
|
37
|
+
send(msg) {
|
|
38
|
+
this.sent.push(String(msg));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const b = {
|
|
42
|
+
sent: /** @type {string[]} */ ([]),
|
|
43
|
+
readyState: 1,
|
|
44
|
+
OPEN: 1,
|
|
45
|
+
/** @param {string} msg */
|
|
46
|
+
send(msg) {
|
|
47
|
+
this.sent.push(String(msg));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
wss.clients.add(/** @type {any} */ (a));
|
|
51
|
+
wss.clients.add(/** @type {any} */ (b));
|
|
52
|
+
|
|
53
|
+
// Subscribe to two different lists
|
|
54
|
+
await handleMessage(
|
|
55
|
+
/** @type {any} */ (a),
|
|
56
|
+
Buffer.from(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
id: 'l1',
|
|
59
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
60
|
+
payload: { id: 'c1', type: 'all-issues' }
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
await handleMessage(
|
|
65
|
+
/** @type {any} */ (b),
|
|
66
|
+
Buffer.from(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
id: 'l2',
|
|
69
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
70
|
+
payload: { id: 'c2', type: 'in-progress-issues' }
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Clear initial refresh calls from subscribe-list
|
|
76
|
+
const mock = /** @type {import('vitest').Mock} */ (
|
|
77
|
+
fetchListForSubscription
|
|
78
|
+
);
|
|
79
|
+
mock.mockClear();
|
|
80
|
+
|
|
81
|
+
// Simulate a burst of DB change events
|
|
82
|
+
scheduleListRefresh();
|
|
83
|
+
scheduleListRefresh();
|
|
84
|
+
scheduleListRefresh();
|
|
85
|
+
|
|
86
|
+
// Before debounce, nothing ran
|
|
87
|
+
expect(mock.mock.calls.length).toBe(0);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(49);
|
|
89
|
+
expect(mock.mock.calls.length).toBe(0);
|
|
90
|
+
|
|
91
|
+
// After debounce window, one refresh per active spec
|
|
92
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
93
|
+
expect(mock.mock.calls.length).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { fetchListForSubscription } from './list-adapters.js';
|
|
4
|
+
import { keyOf, registry } from './subscriptions.js';
|
|
5
|
+
import { attachWsServer, handleMessage, scheduleListRefresh } from './ws.js';
|
|
6
|
+
|
|
7
|
+
// Mock adapters BEFORE importing ws.js to ensure the mock is applied
|
|
8
|
+
vi.mock('./list-adapters.js', () => ({
|
|
9
|
+
fetchListForSubscription: vi.fn(async () => {
|
|
10
|
+
// Return a simple, deterministic list for any spec
|
|
11
|
+
return {
|
|
12
|
+
ok: true,
|
|
13
|
+
items: [
|
|
14
|
+
{ id: 'A', updated_at: 1, closed_at: null },
|
|
15
|
+
{ id: 'B', updated_at: 1, closed_at: null }
|
|
16
|
+
]
|
|
17
|
+
};
|
|
18
|
+
})
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe('ws list subscriptions', () => {
|
|
22
|
+
test('refresh emits upsert/delete after subscribe', async () => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
const server = createServer();
|
|
25
|
+
const { wss } = attachWsServer(server, {
|
|
26
|
+
path: '/ws',
|
|
27
|
+
heartbeat_ms: 10000,
|
|
28
|
+
refresh_debounce_ms: 50
|
|
29
|
+
});
|
|
30
|
+
const sock = {
|
|
31
|
+
sent: /** @type {string[]} */ ([]),
|
|
32
|
+
readyState: 1,
|
|
33
|
+
OPEN: 1,
|
|
34
|
+
/** @param {string} msg */
|
|
35
|
+
send(msg) {
|
|
36
|
+
this.sent.push(String(msg));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
wss.clients.add(/** @type {any} */ (sock));
|
|
40
|
+
|
|
41
|
+
// Initial subscribe
|
|
42
|
+
await handleMessage(
|
|
43
|
+
/** @type {any} */ (sock),
|
|
44
|
+
Buffer.from(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
id: 'sub-2',
|
|
47
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
48
|
+
payload: { id: 'c2', type: 'all-issues' }
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Clear initial snapshot
|
|
54
|
+
sock.sent = [];
|
|
55
|
+
|
|
56
|
+
// Change adapter to simulate one added, one updated, one removed
|
|
57
|
+
const mock = /** @type {import('vitest').Mock} */ (
|
|
58
|
+
fetchListForSubscription
|
|
59
|
+
);
|
|
60
|
+
mock.mockResolvedValueOnce({
|
|
61
|
+
ok: true,
|
|
62
|
+
items: [
|
|
63
|
+
{ id: 'A', updated_at: 2, closed_at: null }, // updated
|
|
64
|
+
{ id: 'C', updated_at: 1, closed_at: null } // added
|
|
65
|
+
]
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Trigger refresh
|
|
69
|
+
scheduleListRefresh();
|
|
70
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
71
|
+
|
|
72
|
+
const events = sock.sent
|
|
73
|
+
.map((m) => {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(m);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
const upserts = events.filter((e) => e && e.type === 'upsert');
|
|
82
|
+
const deletes = events.filter((e) => e && e.type === 'delete');
|
|
83
|
+
expect(upserts.length).toBeGreaterThan(0);
|
|
84
|
+
expect(deletes.length).toBeGreaterThan(0);
|
|
85
|
+
vi.useRealTimers();
|
|
86
|
+
});
|
|
87
|
+
test('subscribe-list attaches and publishes initial snapshot', async () => {
|
|
88
|
+
const sock = {
|
|
89
|
+
sent: /** @type {string[]} */ ([]),
|
|
90
|
+
readyState: 1,
|
|
91
|
+
OPEN: 1,
|
|
92
|
+
/** @param {string} msg */
|
|
93
|
+
send(msg) {
|
|
94
|
+
this.sent.push(String(msg));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const req = {
|
|
99
|
+
id: 'sub-1',
|
|
100
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
101
|
+
payload: { id: 'c1', type: 'in-progress-issues' }
|
|
102
|
+
};
|
|
103
|
+
await handleMessage(
|
|
104
|
+
/** @type {any} */ (sock),
|
|
105
|
+
Buffer.from(JSON.stringify(req))
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Expect an OK reply for subscribe-list
|
|
109
|
+
const last = sock.sent[sock.sent.length - 1];
|
|
110
|
+
const reply = JSON.parse(last);
|
|
111
|
+
expect(reply && reply.ok).toBe(true);
|
|
112
|
+
expect(reply && reply.type).toBe('subscribe-list');
|
|
113
|
+
|
|
114
|
+
// Expect a snapshot event was sent containing issues
|
|
115
|
+
const snapshot_envelope = sock.sent
|
|
116
|
+
.map((m) => {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(m);
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
.find((o) => o && o.type === 'snapshot');
|
|
124
|
+
expect(!!snapshot_envelope).toBe(true);
|
|
125
|
+
expect(snapshot_envelope.payload && snapshot_envelope.payload.id).toBe(
|
|
126
|
+
'c1'
|
|
127
|
+
);
|
|
128
|
+
expect(Array.isArray(snapshot_envelope.payload.issues)).toBe(true);
|
|
129
|
+
expect(snapshot_envelope.payload.issues.length).toBeGreaterThan(0);
|
|
130
|
+
|
|
131
|
+
const key = keyOf({ type: 'in-progress-issues' });
|
|
132
|
+
const entry = registry.get(key);
|
|
133
|
+
const before_size = entry ? entry.subscribers.size : 0;
|
|
134
|
+
expect(before_size).toBeGreaterThanOrEqual(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('subscribe-list returns bd_error payload when adapter fails', async () => {
|
|
138
|
+
const mock = /** @type {import('vitest').Mock} */ (
|
|
139
|
+
fetchListForSubscription
|
|
140
|
+
);
|
|
141
|
+
mock.mockResolvedValueOnce({
|
|
142
|
+
ok: false,
|
|
143
|
+
error: {
|
|
144
|
+
code: 'bd_error',
|
|
145
|
+
message: 'bd failed: out of sync',
|
|
146
|
+
details: { exit_code: 1 }
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const sock = {
|
|
151
|
+
sent: /** @type {string[]} */ ([]),
|
|
152
|
+
readyState: 1,
|
|
153
|
+
OPEN: 1,
|
|
154
|
+
/** @param {string} msg */
|
|
155
|
+
send(msg) {
|
|
156
|
+
this.sent.push(String(msg));
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const req = {
|
|
161
|
+
id: 'sub-error',
|
|
162
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
163
|
+
payload: { id: 'c-err', type: 'all-issues' }
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await handleMessage(
|
|
167
|
+
/** @type {any} */ (sock),
|
|
168
|
+
Buffer.from(JSON.stringify(req))
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const last = sock.sent[sock.sent.length - 1];
|
|
172
|
+
const reply = JSON.parse(last);
|
|
173
|
+
expect(reply && reply.ok).toBe(false);
|
|
174
|
+
expect(reply && reply.error && reply.error.code).toBe('bd_error');
|
|
175
|
+
expect(reply && reply.error && reply.error.message).toContain(
|
|
176
|
+
'out of sync'
|
|
177
|
+
);
|
|
178
|
+
expect(reply && reply.error && reply.error.details.exit_code).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('unsubscribe-list detaches and disconnect sweep evicts entry', async () => {
|
|
182
|
+
const sock = {
|
|
183
|
+
sent: /** @type {string[]} */ ([]),
|
|
184
|
+
readyState: 1,
|
|
185
|
+
OPEN: 1,
|
|
186
|
+
/** @param {string} msg */
|
|
187
|
+
send(msg) {
|
|
188
|
+
this.sent.push(String(msg));
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Subscribe first
|
|
193
|
+
await handleMessage(
|
|
194
|
+
/** @type {any} */ (sock),
|
|
195
|
+
Buffer.from(
|
|
196
|
+
JSON.stringify({
|
|
197
|
+
id: 'sub-1',
|
|
198
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
199
|
+
payload: { id: 'c1', type: 'all-issues' }
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const key = keyOf({ type: 'all-issues' });
|
|
205
|
+
const entry = registry.get(key);
|
|
206
|
+
const before = entry ? entry.subscribers.size : 0;
|
|
207
|
+
expect(before).toBeGreaterThanOrEqual(1);
|
|
208
|
+
|
|
209
|
+
// Now unsubscribe
|
|
210
|
+
await handleMessage(
|
|
211
|
+
/** @type {any} */ (sock),
|
|
212
|
+
Buffer.from(
|
|
213
|
+
JSON.stringify({
|
|
214
|
+
id: 'unsub-1',
|
|
215
|
+
type: /** @type {any} */ ('unsubscribe-list'),
|
|
216
|
+
payload: { id: 'c1' }
|
|
217
|
+
})
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const entry2 = registry.get(key);
|
|
222
|
+
const after_size = entry2 ? entry2.subscribers.size : 0;
|
|
223
|
+
expect(after_size).toBeLessThan(before);
|
|
224
|
+
|
|
225
|
+
// Do not assert full eviction here due to global registry used across tests
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('closed-issues pre-filter applies before diff', async () => {
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
// Configure adapter mock for this test case
|
|
231
|
+
const mock = /** @type {import('vitest').Mock} */ (
|
|
232
|
+
fetchListForSubscription
|
|
233
|
+
);
|
|
234
|
+
mock.mockResolvedValueOnce({
|
|
235
|
+
ok: true,
|
|
236
|
+
items: [
|
|
237
|
+
{ id: 'old', updated_at: now - 3000, closed_at: now - 2000 },
|
|
238
|
+
{ id: 'recent', updated_at: now - 100, closed_at: now - 100 },
|
|
239
|
+
{ id: 'open', updated_at: now - 50, closed_at: null }
|
|
240
|
+
]
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const sock = {
|
|
244
|
+
sent: /** @type {string[]} */ ([]),
|
|
245
|
+
readyState: 1,
|
|
246
|
+
OPEN: 1,
|
|
247
|
+
/** @param {string} msg */
|
|
248
|
+
send(msg) {
|
|
249
|
+
this.sent.push(String(msg));
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const since = now - 1000;
|
|
254
|
+
await handleMessage(
|
|
255
|
+
/** @type {any} */ (sock),
|
|
256
|
+
Buffer.from(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
id: 'sub-closed',
|
|
259
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
260
|
+
payload: { id: 'c-closed', type: 'closed-issues', params: { since } }
|
|
261
|
+
})
|
|
262
|
+
)
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const key = keyOf({ type: 'closed-issues', params: { since } });
|
|
266
|
+
const entry = registry.get(key);
|
|
267
|
+
const ids = entry ? Array.from(entry.itemsById.keys()).sort() : [];
|
|
268
|
+
expect(ids).toEqual(['recent']);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('subscribe-list rejects unknown subscription type', async () => {
|
|
272
|
+
const sock = {
|
|
273
|
+
sent: /** @type {string[]} */ ([]),
|
|
274
|
+
readyState: 1,
|
|
275
|
+
OPEN: 1,
|
|
276
|
+
/** @param {string} msg */
|
|
277
|
+
send(msg) {
|
|
278
|
+
this.sent.push(String(msg));
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
await handleMessage(
|
|
283
|
+
/** @type {any} */ (sock),
|
|
284
|
+
Buffer.from(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
id: 'bad-sub',
|
|
287
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
288
|
+
payload: { id: 'c-bad', type: 'not-supported' }
|
|
289
|
+
})
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const last = sock.sent[sock.sent.length - 1];
|
|
294
|
+
const reply = JSON.parse(last);
|
|
295
|
+
expect(reply && reply.ok).toBe(false);
|
|
296
|
+
expect(reply && reply.error && reply.error.code).toBe('bad_request');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('subscribe-list accepts issue-detail with id and publishes snapshot', async () => {
|
|
300
|
+
const sock = {
|
|
301
|
+
sent: /** @type {string[]} */ ([]),
|
|
302
|
+
readyState: 1,
|
|
303
|
+
OPEN: 1,
|
|
304
|
+
/** @param {string} msg */
|
|
305
|
+
send(msg) {
|
|
306
|
+
this.sent.push(String(msg));
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
await handleMessage(
|
|
311
|
+
/** @type {any} */ (sock),
|
|
312
|
+
Buffer.from(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
id: 'sub-detail-1',
|
|
315
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
316
|
+
payload: {
|
|
317
|
+
id: 'detail:UI-1',
|
|
318
|
+
type: 'issue-detail',
|
|
319
|
+
params: { id: 'UI-1' }
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const last = sock.sent[sock.sent.length - 1];
|
|
326
|
+
const reply = JSON.parse(last);
|
|
327
|
+
expect(reply && reply.ok).toBe(true);
|
|
328
|
+
expect(reply && reply.type).toBe('subscribe-list');
|
|
329
|
+
|
|
330
|
+
const snapshot_envelope = sock.sent
|
|
331
|
+
.map((m) => {
|
|
332
|
+
try {
|
|
333
|
+
return JSON.parse(m);
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
.find((o) => o && o.type === 'snapshot');
|
|
339
|
+
expect(!!snapshot_envelope).toBe(true);
|
|
340
|
+
expect(snapshot_envelope.payload && snapshot_envelope.payload.id).toBe(
|
|
341
|
+
'detail:UI-1'
|
|
342
|
+
);
|
|
343
|
+
expect(Array.isArray(snapshot_envelope.payload.issues)).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('subscribe-list issue-detail enforces id', async () => {
|
|
347
|
+
const sock = {
|
|
348
|
+
sent: /** @type {string[]} */ ([]),
|
|
349
|
+
readyState: 1,
|
|
350
|
+
OPEN: 1,
|
|
351
|
+
/** @param {string} msg */
|
|
352
|
+
send(msg) {
|
|
353
|
+
this.sent.push(String(msg));
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await handleMessage(
|
|
358
|
+
/** @type {any} */ (sock),
|
|
359
|
+
Buffer.from(
|
|
360
|
+
JSON.stringify({
|
|
361
|
+
id: 'bad-detail',
|
|
362
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
363
|
+
payload: { id: 'detail:UI-X', type: 'issue-detail' }
|
|
364
|
+
})
|
|
365
|
+
)
|
|
366
|
+
);
|
|
367
|
+
const last = sock.sent[sock.sent.length - 1];
|
|
368
|
+
const reply = JSON.parse(last);
|
|
369
|
+
expect(reply && reply.ok).toBe(false);
|
|
370
|
+
expect(reply && reply.error && reply.error.code).toBe('bad_request');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('subscribe-list closed-issues validates since param', async () => {
|
|
374
|
+
const sock = {
|
|
375
|
+
sent: /** @type {string[]} */ ([]),
|
|
376
|
+
readyState: 1,
|
|
377
|
+
OPEN: 1,
|
|
378
|
+
/** @param {string} msg */
|
|
379
|
+
send(msg) {
|
|
380
|
+
this.sent.push(String(msg));
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
await handleMessage(
|
|
385
|
+
/** @type {any} */ (sock),
|
|
386
|
+
Buffer.from(
|
|
387
|
+
JSON.stringify({
|
|
388
|
+
id: 'bad-since',
|
|
389
|
+
type: /** @type {any} */ ('subscribe-list'),
|
|
390
|
+
payload: {
|
|
391
|
+
id: 'c-closed',
|
|
392
|
+
type: 'closed-issues',
|
|
393
|
+
params: { since: 'yesterday' }
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
)
|
|
397
|
+
);
|
|
398
|
+
const last = sock.sent[sock.sent.length - 1];
|
|
399
|
+
const reply = JSON.parse(last);
|
|
400
|
+
expect(reply && reply.ok).toBe(false);
|
|
401
|
+
expect(reply && reply.error && reply.error.code).toBe('bad_request');
|
|
402
|
+
});
|
|
403
|
+
});
|