kanban-lite 1.0.4

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 (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,1916 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+ import * as os from 'os'
5
+ import * as http from 'http'
6
+ import { WebSocket } from 'ws'
7
+ import { startServer } from '../server'
8
+
9
+ // Helper: create a temp directory for features
10
+ function createTempDir(): string {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-test-'))
12
+ }
13
+
14
+ // Helper: write a feature markdown file
15
+ function writeFeatureFile(dir: string, filename: string, content: string, subfolder?: string): string {
16
+ const targetDir = subfolder ? path.join(dir, subfolder) : dir
17
+ fs.mkdirSync(targetDir, { recursive: true })
18
+ const filePath = path.join(targetDir, filename)
19
+ fs.writeFileSync(filePath, content, 'utf-8')
20
+ return filePath
21
+ }
22
+
23
+ // Helper: create a standard feature file content
24
+ function makeFeatureContent(opts: {
25
+ id: string
26
+ status?: string
27
+ priority?: string
28
+ title?: string
29
+ order?: string
30
+ assignee?: string | null
31
+ dueDate?: string | null
32
+ labels?: string[]
33
+ }): string {
34
+ const {
35
+ id,
36
+ status = 'backlog',
37
+ priority = 'medium',
38
+ title = 'Test Feature',
39
+ order = 'a0',
40
+ assignee = null,
41
+ dueDate = null,
42
+ labels = []
43
+ } = opts
44
+ return `---
45
+ id: "${id}"
46
+ status: "${status}"
47
+ priority: "${priority}"
48
+ assignee: ${assignee ? `"${assignee}"` : 'null'}
49
+ dueDate: ${dueDate ? `"${dueDate}"` : 'null'}
50
+ created: "2024-01-01T00:00:00.000Z"
51
+ modified: "2024-01-01T00:00:00.000Z"
52
+ completedAt: null
53
+ labels: [${labels.map(l => `"${l}"`).join(', ')}]
54
+ order: "${order}"
55
+ ---
56
+ # ${title}
57
+
58
+ Description here.`
59
+ }
60
+
61
+ // Helper: connect WebSocket and wait for open
62
+ function connectWs(port: number): Promise<WebSocket> {
63
+ return new Promise((resolve, reject) => {
64
+ const ws = new WebSocket(`ws://localhost:${port}/ws`)
65
+ ws.on('open', () => resolve(ws))
66
+ ws.on('error', reject)
67
+ })
68
+ }
69
+
70
+ // Helper: send a message and wait for a response of a specific type
71
+ function sendAndReceive(ws: WebSocket, message: unknown, expectedType: string, timeout = 5000): Promise<Record<string, unknown>> {
72
+ return new Promise((resolve, reject) => {
73
+ const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${expectedType}`)), timeout)
74
+
75
+ const handler = (data: Buffer | string) => {
76
+ try {
77
+ const parsed = JSON.parse(data.toString())
78
+ if (parsed.type === expectedType) {
79
+ clearTimeout(timer)
80
+ ws.off('message', handler)
81
+ resolve(parsed)
82
+ }
83
+ } catch {
84
+ // ignore parse errors
85
+ }
86
+ }
87
+
88
+ ws.on('message', handler)
89
+ ws.send(JSON.stringify(message))
90
+ })
91
+ }
92
+
93
+ // Helper: wait for a message of a specific type (no send)
94
+ function waitForMessage(ws: WebSocket, expectedType: string, timeout = 5000): Promise<Record<string, unknown>> {
95
+ return new Promise((resolve, reject) => {
96
+ const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${expectedType}`)), timeout)
97
+
98
+ const handler = (data: Buffer | string) => {
99
+ try {
100
+ const parsed = JSON.parse(data.toString())
101
+ if (parsed.type === expectedType) {
102
+ clearTimeout(timer)
103
+ ws.off('message', handler)
104
+ resolve(parsed)
105
+ }
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
110
+
111
+ ws.on('message', handler)
112
+ })
113
+ }
114
+
115
+ // Helper: fetch HTTP response
116
+ function httpGet(url: string): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
117
+ return new Promise((resolve, reject) => {
118
+ http.get(url, (res) => {
119
+ let body = ''
120
+ res.on('data', (chunk) => body += chunk)
121
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body }))
122
+ }).on('error', reject)
123
+ })
124
+ }
125
+
126
+ // Helper: make HTTP request with method, body, and headers
127
+ function httpRequest(
128
+ method: string,
129
+ url: string,
130
+ body?: unknown
131
+ ): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
132
+ return new Promise((resolve, reject) => {
133
+ const parsed = new URL(url)
134
+ const payload = body ? JSON.stringify(body) : undefined
135
+ const req = http.request(
136
+ {
137
+ hostname: parsed.hostname,
138
+ port: parsed.port,
139
+ path: parsed.pathname + parsed.search,
140
+ method,
141
+ headers: {
142
+ 'Content-Type': 'application/json',
143
+ ...(payload ? { 'Content-Length': Buffer.byteLength(payload).toString() } : {})
144
+ }
145
+ },
146
+ (res) => {
147
+ let data = ''
148
+ res.on('data', (chunk) => data += chunk)
149
+ res.on('end', () => resolve({ status: res.statusCode!, headers: res.headers, body: data }))
150
+ }
151
+ )
152
+ req.on('error', reject)
153
+ if (payload) req.write(payload)
154
+ req.end()
155
+ })
156
+ }
157
+
158
+ // Helper: find a free port
159
+ function getPort(): Promise<number> {
160
+ return new Promise((resolve) => {
161
+ const srv = http.createServer()
162
+ srv.listen(0, () => {
163
+ const port = (srv.address() as { port: number }).port
164
+ srv.close(() => resolve(port))
165
+ })
166
+ })
167
+ }
168
+
169
+ // Helper: wait a bit
170
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
171
+
172
+ // Helper: create a temp webview directory with dummy static files
173
+ function createTempWebviewDir(): string {
174
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-webview-'))
175
+ fs.writeFileSync(path.join(dir, 'index.js'), '// test js', 'utf-8')
176
+ fs.writeFileSync(path.join(dir, 'style.css'), '/* test css */', 'utf-8')
177
+ return dir
178
+ }
179
+
180
+ describe('Standalone Server Integration', () => {
181
+ let server: http.Server
182
+ let tempDir: string
183
+ let webviewDir: string
184
+ let port: number
185
+ let ws: WebSocket
186
+
187
+ beforeEach(async () => {
188
+ tempDir = createTempDir()
189
+ webviewDir = createTempWebviewDir()
190
+ port = await getPort()
191
+ })
192
+
193
+ afterEach(async () => {
194
+ if (ws && ws.readyState === WebSocket.OPEN) {
195
+ ws.close()
196
+ await sleep(50)
197
+ }
198
+ if (server) {
199
+ await new Promise<void>((resolve) => server.close(() => resolve()))
200
+ }
201
+ // Clean up temp dirs, config file, and webhooks file
202
+ fs.rmSync(tempDir, { recursive: true, force: true })
203
+ fs.rmSync(webviewDir, { recursive: true, force: true })
204
+ const workspaceRoot = path.dirname(tempDir)
205
+ const configFile = path.join(workspaceRoot, '.kanban.json')
206
+ if (fs.existsSync(configFile)) fs.rmSync(configFile)
207
+ const webhooksFile = path.join(workspaceRoot, '.kanban-webhooks.json')
208
+ if (fs.existsSync(webhooksFile)) fs.rmSync(webhooksFile)
209
+ })
210
+
211
+ // ── HTTP Tests ──
212
+
213
+ describe('HTTP server', () => {
214
+ it('should serve index.html at root', async () => {
215
+ server = startServer(tempDir, port, webviewDir)
216
+ await sleep(200)
217
+
218
+ const res = await httpGet(`http://localhost:${port}/`)
219
+ expect(res.status).toBe(200)
220
+ expect(res.headers['content-type']).toBe('text/html')
221
+ expect(res.body).toContain('<div id="root">')
222
+ expect(res.body).toContain('Kanban Board')
223
+ })
224
+
225
+ it('should serve static CSS files', async () => {
226
+ server = startServer(tempDir, port, webviewDir)
227
+ await sleep(200)
228
+
229
+ const res = await httpGet(`http://localhost:${port}/style.css`)
230
+ expect(res.status).toBe(200)
231
+ expect(res.headers['content-type']).toBe('text/css')
232
+ })
233
+
234
+ it('should serve static JS files', async () => {
235
+ server = startServer(tempDir, port, webviewDir)
236
+ await sleep(200)
237
+
238
+ const res = await httpGet(`http://localhost:${port}/index.js`)
239
+ expect(res.status).toBe(200)
240
+ expect(res.headers['content-type']).toBe('text/javascript')
241
+ })
242
+
243
+ it('should fall back to index.html for unknown paths', async () => {
244
+ server = startServer(tempDir, port, webviewDir)
245
+ await sleep(200)
246
+
247
+ const res = await httpGet(`http://localhost:${port}/some/unknown/route`)
248
+ expect(res.status).toBe(200)
249
+ expect(res.body).toContain('<div id="root">')
250
+ })
251
+ })
252
+
253
+ // ── WebSocket: Ready / Init ──
254
+
255
+ describe('ready message and init response', () => {
256
+ it('should return features and columns on ready', async () => {
257
+ // Pre-populate a feature file in its status subfolder
258
+ writeFeatureFile(tempDir, 'test-feature.md', makeFeatureContent({
259
+ id: 'test-feature',
260
+ status: 'backlog',
261
+ priority: 'high',
262
+ title: 'Test Feature'
263
+ }), 'backlog')
264
+
265
+ server = startServer(tempDir, port, webviewDir)
266
+ await sleep(200)
267
+ ws = await connectWs(port)
268
+
269
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
270
+
271
+ expect(response.type).toBe('init')
272
+ expect(Array.isArray(response.features)).toBe(true)
273
+ expect(Array.isArray(response.columns)).toBe(true)
274
+
275
+ const features = response.features as Array<Record<string, unknown>>
276
+ expect(features.length).toBe(1)
277
+ expect(features[0].id).toBe('test-feature')
278
+ expect(features[0].status).toBe('backlog')
279
+ expect(features[0].priority).toBe('high')
280
+
281
+ const columns = response.columns as Array<Record<string, unknown>>
282
+ expect(columns.length).toBe(5)
283
+ expect(columns.map(c => c.id)).toEqual(['backlog', 'todo', 'in-progress', 'review', 'done'])
284
+
285
+ expect(response.settings).toBeDefined()
286
+ })
287
+
288
+ it('should return empty features for empty directory', async () => {
289
+ server = startServer(tempDir, port, webviewDir)
290
+ await sleep(200)
291
+ ws = await connectWs(port)
292
+
293
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
294
+ const features = response.features as Array<unknown>
295
+ expect(features.length).toBe(0)
296
+ })
297
+
298
+ it('should load features from done/ subfolder', async () => {
299
+ writeFeatureFile(tempDir, 'done-feature.md', makeFeatureContent({
300
+ id: 'done-feature',
301
+ status: 'done',
302
+ title: 'Done Feature'
303
+ }), 'done')
304
+
305
+ server = startServer(tempDir, port, webviewDir)
306
+ await sleep(200)
307
+ ws = await connectWs(port)
308
+
309
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
310
+ const features = response.features as Array<Record<string, unknown>>
311
+ expect(features.length).toBe(1)
312
+ expect(features[0].id).toBe('done-feature')
313
+ expect(features[0].status).toBe('done')
314
+ })
315
+
316
+ it('should load multiple features sorted by order', async () => {
317
+ writeFeatureFile(tempDir, 'feature-b.md', makeFeatureContent({
318
+ id: 'feature-b',
319
+ order: 'b0'
320
+ }), 'backlog')
321
+ writeFeatureFile(tempDir, 'feature-a.md', makeFeatureContent({
322
+ id: 'feature-a',
323
+ order: 'a0'
324
+ }), 'backlog')
325
+
326
+ server = startServer(tempDir, port, webviewDir)
327
+ await sleep(200)
328
+ ws = await connectWs(port)
329
+
330
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
331
+ const features = response.features as Array<Record<string, unknown>>
332
+ expect(features.length).toBe(2)
333
+ expect(features[0].id).toBe('feature-a')
334
+ expect(features[1].id).toBe('feature-b')
335
+ })
336
+ })
337
+
338
+ // ── Create Feature ──
339
+
340
+ describe('createFeature', () => {
341
+ it('should create a feature file on disk', async () => {
342
+ server = startServer(tempDir, port, webviewDir)
343
+ await sleep(200)
344
+ ws = await connectWs(port)
345
+
346
+ // Init first
347
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
348
+
349
+ // Create feature
350
+ const response = await sendAndReceive(ws, {
351
+ type: 'createFeature',
352
+ data: {
353
+ status: 'todo',
354
+ priority: 'high',
355
+ content: '# My New Feature\n\nSome description',
356
+ assignee: null,
357
+ dueDate: null,
358
+ labels: ['frontend']
359
+ }
360
+ }, 'init')
361
+
362
+ const features = response.features as Array<Record<string, unknown>>
363
+ expect(features.length).toBe(1)
364
+ expect(features[0].status).toBe('todo')
365
+ expect(features[0].priority).toBe('high')
366
+ expect(features[0].content).toBe('# My New Feature\n\nSome description')
367
+ expect(features[0].labels).toEqual(['frontend'])
368
+
369
+ // Verify file exists on disk in todo/ subfolder
370
+ const todoDir = path.join(tempDir, 'todo')
371
+ const files = fs.readdirSync(todoDir).filter(f => f.endsWith('.md'))
372
+ expect(files.length).toBe(1)
373
+
374
+ const fileContent = fs.readFileSync(path.join(todoDir, files[0]), 'utf-8')
375
+ expect(fileContent).toContain('status: "todo"')
376
+ expect(fileContent).toContain('priority: "high"')
377
+ expect(fileContent).toContain('# My New Feature')
378
+ expect(fileContent).toContain('labels: ["frontend"]')
379
+ })
380
+
381
+ it('should create feature in its status subfolder', async () => {
382
+ server = startServer(tempDir, port, webviewDir)
383
+ await sleep(200)
384
+ ws = await connectWs(port)
385
+
386
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
387
+
388
+ const response = await sendAndReceive(ws, {
389
+ type: 'createFeature',
390
+ data: {
391
+ status: 'done',
392
+ priority: 'low',
393
+ content: '# Completed Thing',
394
+ assignee: null,
395
+ dueDate: null,
396
+ labels: []
397
+ }
398
+ }, 'init')
399
+
400
+ const features = response.features as Array<Record<string, unknown>>
401
+ expect(features.length).toBe(1)
402
+ expect(features[0].status).toBe('done')
403
+ expect(features[0].completedAt).toBeTruthy()
404
+
405
+ // File should be in done/ subfolder
406
+ const doneFiles = fs.readdirSync(path.join(tempDir, 'done')).filter(f => f.endsWith('.md'))
407
+ expect(doneFiles.length).toBe(1)
408
+ })
409
+
410
+ it('should assign correct order when creating in a populated column', async () => {
411
+ writeFeatureFile(tempDir, 'existing.md', makeFeatureContent({
412
+ id: 'existing',
413
+ status: 'backlog',
414
+ order: 'a0'
415
+ }), 'backlog')
416
+
417
+ server = startServer(tempDir, port, webviewDir)
418
+ await sleep(200)
419
+ ws = await connectWs(port)
420
+
421
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
422
+
423
+ const response = await sendAndReceive(ws, {
424
+ type: 'createFeature',
425
+ data: {
426
+ status: 'backlog',
427
+ priority: 'medium',
428
+ content: '# Second Feature',
429
+ assignee: null,
430
+ dueDate: null,
431
+ labels: []
432
+ }
433
+ }, 'init')
434
+
435
+ const features = response.features as Array<Record<string, unknown>>
436
+ const backlogFeatures = features.filter(f => f.status === 'backlog')
437
+ expect(backlogFeatures.length).toBe(2)
438
+ // New feature should come after existing (order > 'a0')
439
+ expect(backlogFeatures[1].order > backlogFeatures[0].order).toBe(true)
440
+ })
441
+
442
+ it('should preserve assignee and dueDate', async () => {
443
+ server = startServer(tempDir, port, webviewDir)
444
+ await sleep(200)
445
+ ws = await connectWs(port)
446
+
447
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
448
+
449
+ const response = await sendAndReceive(ws, {
450
+ type: 'createFeature',
451
+ data: {
452
+ status: 'todo',
453
+ priority: 'high',
454
+ content: '# Assigned Feature',
455
+ assignee: 'john',
456
+ dueDate: '2024-12-31',
457
+ labels: ['urgent', 'backend']
458
+ }
459
+ }, 'init')
460
+
461
+ const features = response.features as Array<Record<string, unknown>>
462
+ expect(features[0].assignee).toBe('john')
463
+ expect(features[0].dueDate).toBe('2024-12-31')
464
+ expect(features[0].labels).toEqual(['urgent', 'backend'])
465
+ })
466
+ })
467
+
468
+ // ── Move Feature ──
469
+
470
+ describe('moveFeature', () => {
471
+ it('should change status and move file to new status folder', async () => {
472
+ writeFeatureFile(tempDir, 'move-me.md', makeFeatureContent({
473
+ id: 'move-me',
474
+ status: 'backlog',
475
+ title: 'Move Me'
476
+ }), 'backlog')
477
+
478
+ server = startServer(tempDir, port, webviewDir)
479
+ await sleep(200)
480
+ ws = await connectWs(port)
481
+
482
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
483
+
484
+ const response = await sendAndReceive(ws, {
485
+ type: 'moveFeature',
486
+ featureId: 'move-me',
487
+ newStatus: 'in-progress',
488
+ newOrder: 0
489
+ }, 'init')
490
+
491
+ const features = response.features as Array<Record<string, unknown>>
492
+ expect(features[0].status).toBe('in-progress')
493
+
494
+ // Verify file was moved to in-progress/ subfolder
495
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'move-me.md'))).toBe(false)
496
+ const fileContent = fs.readFileSync(path.join(tempDir, 'in-progress', 'move-me.md'), 'utf-8')
497
+ expect(fileContent).toContain('status: "in-progress"')
498
+ })
499
+
500
+ it('should move file to done/ subfolder when status changes to done', async () => {
501
+ writeFeatureFile(tempDir, 'finish-me.md', makeFeatureContent({
502
+ id: 'finish-me',
503
+ status: 'review',
504
+ title: 'Finish Me'
505
+ }), 'review')
506
+
507
+ server = startServer(tempDir, port, webviewDir)
508
+ await sleep(200)
509
+ ws = await connectWs(port)
510
+
511
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
512
+
513
+ const response = await sendAndReceive(ws, {
514
+ type: 'moveFeature',
515
+ featureId: 'finish-me',
516
+ newStatus: 'done',
517
+ newOrder: 0
518
+ }, 'init')
519
+
520
+ const features = response.features as Array<Record<string, unknown>>
521
+ expect(features[0].status).toBe('done')
522
+ expect(features[0].completedAt).toBeTruthy()
523
+
524
+ // File should now be in done/ subfolder
525
+ expect(fs.existsSync(path.join(tempDir, 'review', 'finish-me.md'))).toBe(false)
526
+ expect(fs.existsSync(path.join(tempDir, 'done', 'finish-me.md'))).toBe(true)
527
+ })
528
+
529
+ it('should move file from done/ to target status folder', async () => {
530
+ writeFeatureFile(tempDir, 'reopen-me.md', makeFeatureContent({
531
+ id: 'reopen-me',
532
+ status: 'done',
533
+ title: 'Reopen Me'
534
+ }), 'done')
535
+
536
+ server = startServer(tempDir, port, webviewDir)
537
+ await sleep(200)
538
+ ws = await connectWs(port)
539
+
540
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
541
+
542
+ const response = await sendAndReceive(ws, {
543
+ type: 'moveFeature',
544
+ featureId: 'reopen-me',
545
+ newStatus: 'todo',
546
+ newOrder: 0
547
+ }, 'init')
548
+
549
+ const features = response.features as Array<Record<string, unknown>>
550
+ expect(features[0].status).toBe('todo')
551
+ expect(features[0].completedAt).toBeNull()
552
+
553
+ // File should be in todo/ subfolder
554
+ expect(fs.existsSync(path.join(tempDir, 'done', 'reopen-me.md'))).toBe(false)
555
+ expect(fs.existsSync(path.join(tempDir, 'todo', 'reopen-me.md'))).toBe(true)
556
+ })
557
+
558
+ it('should compute correct fractional order between neighbors', async () => {
559
+ writeFeatureFile(tempDir, 'feat-a.md', makeFeatureContent({
560
+ id: 'feat-a',
561
+ status: 'todo',
562
+ order: 'a0'
563
+ }), 'todo')
564
+ writeFeatureFile(tempDir, 'feat-c.md', makeFeatureContent({
565
+ id: 'feat-c',
566
+ status: 'todo',
567
+ order: 'a2'
568
+ }), 'todo')
569
+ writeFeatureFile(tempDir, 'feat-move.md', makeFeatureContent({
570
+ id: 'feat-move',
571
+ status: 'backlog',
572
+ order: 'a0'
573
+ }), 'backlog')
574
+
575
+ server = startServer(tempDir, port, webviewDir)
576
+ await sleep(200)
577
+ ws = await connectWs(port)
578
+
579
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
580
+
581
+ // Move feat-move to todo column between feat-a (position 0) and feat-c (position 1)
582
+ const response = await sendAndReceive(ws, {
583
+ type: 'moveFeature',
584
+ featureId: 'feat-move',
585
+ newStatus: 'todo',
586
+ newOrder: 1
587
+ }, 'init')
588
+
589
+ const features = response.features as Array<Record<string, unknown>>
590
+ const todoFeatures = features
591
+ .filter(f => f.status === 'todo')
592
+ .sort((a, b) => (a.order as string) < (b.order as string) ? -1 : 1)
593
+
594
+ expect(todoFeatures.length).toBe(3)
595
+ expect(todoFeatures[0].id).toBe('feat-a')
596
+ expect(todoFeatures[1].id).toBe('feat-move')
597
+ expect(todoFeatures[2].id).toBe('feat-c')
598
+ // Verify order is between a0 and a2
599
+ expect(todoFeatures[1].order > todoFeatures[0].order).toBe(true)
600
+ expect(todoFeatures[1].order < todoFeatures[2].order).toBe(true)
601
+ })
602
+ })
603
+
604
+ // ── Delete Feature ──
605
+
606
+ describe('deleteFeature', () => {
607
+ it('should delete feature file from disk', async () => {
608
+ writeFeatureFile(tempDir, 'delete-me.md', makeFeatureContent({
609
+ id: 'delete-me',
610
+ title: 'Delete Me'
611
+ }), 'backlog')
612
+
613
+ server = startServer(tempDir, port, webviewDir)
614
+ await sleep(200)
615
+ ws = await connectWs(port)
616
+
617
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
618
+
619
+ const response = await sendAndReceive(ws, {
620
+ type: 'deleteFeature',
621
+ featureId: 'delete-me'
622
+ }, 'init')
623
+
624
+ const features = response.features as Array<unknown>
625
+ expect(features.length).toBe(0)
626
+
627
+ // File should be removed
628
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'delete-me.md'))).toBe(false)
629
+ })
630
+
631
+ it('should only delete the targeted feature', async () => {
632
+ writeFeatureFile(tempDir, 'keep-me.md', makeFeatureContent({ id: 'keep-me' }), 'backlog')
633
+ writeFeatureFile(tempDir, 'remove-me.md', makeFeatureContent({ id: 'remove-me' }), 'backlog')
634
+
635
+ server = startServer(tempDir, port, webviewDir)
636
+ await sleep(200)
637
+ ws = await connectWs(port)
638
+
639
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
640
+
641
+ const response = await sendAndReceive(ws, {
642
+ type: 'deleteFeature',
643
+ featureId: 'remove-me'
644
+ }, 'init')
645
+
646
+ const features = response.features as Array<Record<string, unknown>>
647
+ expect(features.length).toBe(1)
648
+ expect(features[0].id).toBe('keep-me')
649
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'keep-me.md'))).toBe(true)
650
+ })
651
+
652
+ it('should handle deleting non-existent feature gracefully', async () => {
653
+ server = startServer(tempDir, port, webviewDir)
654
+ await sleep(200)
655
+ ws = await connectWs(port)
656
+
657
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
658
+
659
+ // This should not crash
660
+ ws.send(JSON.stringify({ type: 'deleteFeature', featureId: 'nonexistent' }))
661
+ await sleep(200)
662
+
663
+ // Connection should still be open
664
+ expect(ws.readyState).toBe(WebSocket.OPEN)
665
+ })
666
+ })
667
+
668
+ // ── Update Feature ──
669
+
670
+ describe('updateFeature', () => {
671
+ it('should update feature properties and persist', async () => {
672
+ writeFeatureFile(tempDir, 'update-me.md', makeFeatureContent({
673
+ id: 'update-me',
674
+ priority: 'low',
675
+ title: 'Update Me'
676
+ }), 'backlog')
677
+
678
+ server = startServer(tempDir, port, webviewDir)
679
+ await sleep(200)
680
+ ws = await connectWs(port)
681
+
682
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
683
+
684
+ const response = await sendAndReceive(ws, {
685
+ type: 'updateFeature',
686
+ featureId: 'update-me',
687
+ updates: {
688
+ priority: 'critical',
689
+ assignee: 'alice',
690
+ labels: ['urgent']
691
+ }
692
+ }, 'init')
693
+
694
+ const features = response.features as Array<Record<string, unknown>>
695
+ expect(features[0].priority).toBe('critical')
696
+ expect(features[0].assignee).toBe('alice')
697
+ expect(features[0].labels).toEqual(['urgent'])
698
+
699
+ // Verify persisted on disk
700
+ const fileContent = fs.readFileSync(path.join(tempDir, 'backlog', 'update-me.md'), 'utf-8')
701
+ expect(fileContent).toContain('priority: "critical"')
702
+ expect(fileContent).toContain('assignee: "alice"')
703
+ expect(fileContent).toContain('labels: ["urgent"]')
704
+ })
705
+
706
+ it('should set completedAt when status changes to done', async () => {
707
+ writeFeatureFile(tempDir, 'complete-me.md', makeFeatureContent({
708
+ id: 'complete-me',
709
+ status: 'review'
710
+ }), 'review')
711
+
712
+ server = startServer(tempDir, port, webviewDir)
713
+ await sleep(200)
714
+ ws = await connectWs(port)
715
+
716
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
717
+
718
+ const response = await sendAndReceive(ws, {
719
+ type: 'updateFeature',
720
+ featureId: 'complete-me',
721
+ updates: { status: 'done' }
722
+ }, 'init')
723
+
724
+ const features = response.features as Array<Record<string, unknown>>
725
+ expect(features[0].completedAt).toBeTruthy()
726
+ })
727
+ })
728
+
729
+ // ── Open Feature (inline editor) ──
730
+
731
+ describe('openFeature', () => {
732
+ it('should return feature content and frontmatter', async () => {
733
+ writeFeatureFile(tempDir, 'open-me.md', makeFeatureContent({
734
+ id: 'open-me',
735
+ status: 'in-progress',
736
+ priority: 'high',
737
+ title: 'Open Me',
738
+ assignee: 'bob',
739
+ labels: ['backend', 'api']
740
+ }), 'in-progress')
741
+
742
+ server = startServer(tempDir, port, webviewDir)
743
+ await sleep(200)
744
+ ws = await connectWs(port)
745
+
746
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
747
+
748
+ const response = await sendAndReceive(ws, {
749
+ type: 'openFeature',
750
+ featureId: 'open-me'
751
+ }, 'featureContent')
752
+
753
+ expect(response.type).toBe('featureContent')
754
+ expect(response.featureId).toBe('open-me')
755
+ expect(response.content).toContain('# Open Me')
756
+
757
+ const frontmatter = response.frontmatter as Record<string, unknown>
758
+ expect(frontmatter.id).toBe('open-me')
759
+ expect(frontmatter.status).toBe('in-progress')
760
+ expect(frontmatter.priority).toBe('high')
761
+ expect(frontmatter.assignee).toBe('bob')
762
+ expect(frontmatter.labels).toEqual(['backend', 'api'])
763
+ })
764
+ })
765
+
766
+ // ── Save Feature Content ──
767
+
768
+ describe('saveFeatureContent', () => {
769
+ it('should save updated content and frontmatter to disk', async () => {
770
+ writeFeatureFile(tempDir, 'save-me.md', makeFeatureContent({
771
+ id: 'save-me',
772
+ status: 'backlog',
773
+ priority: 'low',
774
+ title: 'Save Me'
775
+ }), 'backlog')
776
+
777
+ server = startServer(tempDir, port, webviewDir)
778
+ await sleep(200)
779
+ ws = await connectWs(port)
780
+
781
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
782
+
783
+ // Open the feature first
784
+ await sendAndReceive(ws, {
785
+ type: 'openFeature',
786
+ featureId: 'save-me'
787
+ }, 'featureContent')
788
+
789
+ // Save with updated content
790
+ const response = await sendAndReceive(ws, {
791
+ type: 'saveFeatureContent',
792
+ featureId: 'save-me',
793
+ content: '# Save Me Updated\n\nNew description here.',
794
+ frontmatter: {
795
+ id: 'save-me',
796
+ status: 'in-progress',
797
+ priority: 'high',
798
+ assignee: 'charlie',
799
+ dueDate: '2025-06-01',
800
+ created: '2024-01-01T00:00:00.000Z',
801
+ modified: '2024-01-01T00:00:00.000Z',
802
+ completedAt: null,
803
+ labels: ['updated'],
804
+ order: 'a0'
805
+ }
806
+ }, 'init')
807
+
808
+ const features = response.features as Array<Record<string, unknown>>
809
+ const saved = features.find(f => f.id === 'save-me')!
810
+ expect(saved.status).toBe('in-progress')
811
+ expect(saved.priority).toBe('high')
812
+ expect(saved.content).toBe('# Save Me Updated\n\nNew description here.')
813
+ expect(saved.assignee).toBe('charlie')
814
+ expect(saved.labels).toEqual(['updated'])
815
+
816
+ // Verify on disk — file moved from backlog/ to in-progress/
817
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'save-me.md'))).toBe(false)
818
+ const fileContent = fs.readFileSync(path.join(tempDir, 'in-progress', 'save-me.md'), 'utf-8')
819
+ expect(fileContent).toContain('status: "in-progress"')
820
+ expect(fileContent).toContain('# Save Me Updated')
821
+ expect(fileContent).toContain('assignee: "charlie"')
822
+ })
823
+
824
+ it('should move file to done/ when saved with done status', async () => {
825
+ writeFeatureFile(tempDir, 'save-done.md', makeFeatureContent({
826
+ id: 'save-done',
827
+ status: 'review',
828
+ title: 'Save Done'
829
+ }), 'review')
830
+
831
+ server = startServer(tempDir, port, webviewDir)
832
+ await sleep(200)
833
+ ws = await connectWs(port)
834
+
835
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
836
+ await sendAndReceive(ws, {
837
+ type: 'openFeature',
838
+ featureId: 'save-done'
839
+ }, 'featureContent')
840
+
841
+ await sendAndReceive(ws, {
842
+ type: 'saveFeatureContent',
843
+ featureId: 'save-done',
844
+ content: '# Save Done\n\nCompleted.',
845
+ frontmatter: {
846
+ id: 'save-done',
847
+ status: 'done',
848
+ priority: 'medium',
849
+ assignee: null,
850
+ dueDate: null,
851
+ created: '2024-01-01T00:00:00.000Z',
852
+ modified: '2024-01-01T00:00:00.000Z',
853
+ completedAt: null,
854
+ labels: [],
855
+ order: 'a0'
856
+ }
857
+ }, 'init')
858
+
859
+ expect(fs.existsSync(path.join(tempDir, 'review', 'save-done.md'))).toBe(false)
860
+ expect(fs.existsSync(path.join(tempDir, 'done', 'save-done.md'))).toBe(true)
861
+ })
862
+ })
863
+
864
+ // ── Close Feature ──
865
+
866
+ describe('closeFeature', () => {
867
+ it('should not crash when closing', async () => {
868
+ server = startServer(tempDir, port, webviewDir)
869
+ await sleep(200)
870
+ ws = await connectWs(port)
871
+
872
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
873
+
874
+ ws.send(JSON.stringify({ type: 'closeFeature' }))
875
+ await sleep(100)
876
+
877
+ expect(ws.readyState).toBe(WebSocket.OPEN)
878
+ })
879
+ })
880
+
881
+ // ── No-op VSCode messages ──
882
+
883
+ describe('VSCode-specific no-op messages', () => {
884
+ it('should handle openFile without crashing', async () => {
885
+ server = startServer(tempDir, port, webviewDir)
886
+ await sleep(200)
887
+ ws = await connectWs(port)
888
+
889
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
890
+ ws.send(JSON.stringify({ type: 'openFile', featureId: 'test' }))
891
+ await sleep(100)
892
+ expect(ws.readyState).toBe(WebSocket.OPEN)
893
+ })
894
+
895
+ it('should handle openSettings without crashing', async () => {
896
+ server = startServer(tempDir, port, webviewDir)
897
+ await sleep(200)
898
+ ws = await connectWs(port)
899
+
900
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
901
+ ws.send(JSON.stringify({ type: 'openSettings' }))
902
+ await sleep(100)
903
+ expect(ws.readyState).toBe(WebSocket.OPEN)
904
+ })
905
+
906
+ it('should handle focusMenuBar without crashing', async () => {
907
+ server = startServer(tempDir, port, webviewDir)
908
+ await sleep(200)
909
+ ws = await connectWs(port)
910
+
911
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
912
+ ws.send(JSON.stringify({ type: 'focusMenuBar' }))
913
+ await sleep(100)
914
+ expect(ws.readyState).toBe(WebSocket.OPEN)
915
+ })
916
+
917
+ it('should handle startWithAI without crashing', async () => {
918
+ server = startServer(tempDir, port, webviewDir)
919
+ await sleep(200)
920
+ ws = await connectWs(port)
921
+
922
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
923
+ ws.send(JSON.stringify({ type: 'startWithAI', agent: 'claude', permissionMode: 'default' }))
924
+ await sleep(100)
925
+ expect(ws.readyState).toBe(WebSocket.OPEN)
926
+ })
927
+ })
928
+
929
+ // ── File Watcher ──
930
+
931
+ describe('file watcher', () => {
932
+ it('should broadcast updates when a file is created externally', async () => {
933
+ server = startServer(tempDir, port, webviewDir)
934
+ await sleep(200)
935
+ ws = await connectWs(port)
936
+
937
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
938
+
939
+ // Let chokidar fully initialize before making external changes
940
+ await sleep(1000)
941
+
942
+ // Listen for the next init broadcast
943
+ const updatePromise = waitForMessage(ws, 'init', 10000)
944
+
945
+ writeFeatureFile(tempDir, 'external-feature.md', makeFeatureContent({
946
+ id: 'external-feature',
947
+ status: 'todo',
948
+ title: 'External Feature'
949
+ }), 'todo')
950
+
951
+ const response = await updatePromise
952
+ const features = response.features as Array<Record<string, unknown>>
953
+ const external = features.find(f => f.id === 'external-feature')
954
+ expect(external).toBeDefined()
955
+ expect(external!.status).toBe('todo')
956
+ })
957
+
958
+ it('should broadcast updates when a file is modified externally', async () => {
959
+ const filePath = writeFeatureFile(tempDir, 'modify-me.md', makeFeatureContent({
960
+ id: 'modify-me',
961
+ status: 'backlog',
962
+ priority: 'low',
963
+ title: 'Modify Me'
964
+ }), 'backlog')
965
+
966
+ server = startServer(tempDir, port, webviewDir)
967
+ await sleep(200)
968
+ ws = await connectWs(port)
969
+
970
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
971
+
972
+ // Let chokidar fully initialize
973
+ await sleep(1000)
974
+
975
+ const updatePromise = waitForMessage(ws, 'init', 10000)
976
+
977
+ fs.writeFileSync(filePath, makeFeatureContent({
978
+ id: 'modify-me',
979
+ status: 'backlog',
980
+ priority: 'critical',
981
+ title: 'Modified Feature'
982
+ }), 'utf-8')
983
+
984
+ const response = await updatePromise
985
+ const features = response.features as Array<Record<string, unknown>>
986
+ const modified = features.find(f => f.id === 'modify-me')
987
+ expect(modified).toBeDefined()
988
+ expect(modified!.priority).toBe('critical')
989
+ })
990
+
991
+ it('should broadcast updates when a file is deleted externally', async () => {
992
+ const filePath = writeFeatureFile(tempDir, 'vanish-me.md', makeFeatureContent({
993
+ id: 'vanish-me',
994
+ title: 'Vanish Me'
995
+ }), 'backlog')
996
+
997
+ server = startServer(tempDir, port, webviewDir)
998
+ await sleep(200)
999
+ ws = await connectWs(port)
1000
+
1001
+ const initResponse = await sendAndReceive(ws, { type: 'ready' }, 'init')
1002
+ expect((initResponse.features as Array<unknown>).length).toBe(1)
1003
+
1004
+ // Let chokidar fully initialize
1005
+ await sleep(1000)
1006
+
1007
+ const updatePromise = waitForMessage(ws, 'init', 10000)
1008
+
1009
+ fs.unlinkSync(filePath)
1010
+
1011
+ const response = await updatePromise
1012
+ const features = response.features as Array<unknown>
1013
+ expect(features.length).toBe(0)
1014
+ })
1015
+ })
1016
+
1017
+ // ── Multi-client broadcast ──
1018
+
1019
+ describe('multi-client broadcast', () => {
1020
+ it('should broadcast to all connected clients', async () => {
1021
+ writeFeatureFile(tempDir, 'broadcast-test.md', makeFeatureContent({
1022
+ id: 'broadcast-test',
1023
+ title: 'Broadcast Test'
1024
+ }), 'backlog')
1025
+
1026
+ server = startServer(tempDir, port, webviewDir)
1027
+ await sleep(200)
1028
+
1029
+ const ws1 = await connectWs(port)
1030
+ const ws2 = await connectWs(port)
1031
+
1032
+ // Init both clients
1033
+ await sendAndReceive(ws1, { type: 'ready' }, 'init')
1034
+ await sendAndReceive(ws2, { type: 'ready' }, 'init')
1035
+
1036
+ // Client 2 listens for update
1037
+ const ws2Update = waitForMessage(ws2, 'init', 3000)
1038
+
1039
+ // Client 1 creates a feature
1040
+ ws1.send(JSON.stringify({
1041
+ type: 'createFeature',
1042
+ data: {
1043
+ status: 'backlog',
1044
+ priority: 'medium',
1045
+ content: '# Broadcast Feature',
1046
+ assignee: null,
1047
+ dueDate: null,
1048
+ labels: []
1049
+ }
1050
+ }))
1051
+
1052
+ // Client 2 should receive the broadcast
1053
+ const response = await ws2Update
1054
+ const features = response.features as Array<Record<string, unknown>>
1055
+ expect(features.length).toBe(2) // original + new
1056
+
1057
+ ws1.close()
1058
+ ws2.close()
1059
+ await sleep(50)
1060
+ })
1061
+ })
1062
+
1063
+ // ── Migration: legacy integer orders ──
1064
+
1065
+ describe('legacy order migration', () => {
1066
+ it('should migrate integer order values to fractional indices', async () => {
1067
+ writeFeatureFile(tempDir, 'legacy-1.md', makeFeatureContent({
1068
+ id: 'legacy-1',
1069
+ status: 'backlog',
1070
+ order: '0'
1071
+ }), 'backlog')
1072
+ writeFeatureFile(tempDir, 'legacy-2.md', makeFeatureContent({
1073
+ id: 'legacy-2',
1074
+ status: 'backlog',
1075
+ order: '1'
1076
+ }), 'backlog')
1077
+
1078
+ server = startServer(tempDir, port, webviewDir)
1079
+ await sleep(200)
1080
+ ws = await connectWs(port)
1081
+
1082
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
1083
+ const features = response.features as Array<Record<string, unknown>>
1084
+
1085
+ // Orders should no longer be plain integers
1086
+ for (const f of features) {
1087
+ expect(/^\d+$/.test(f.order as string)).toBe(false)
1088
+ }
1089
+
1090
+ // Files on disk should be updated
1091
+ const file1 = fs.readFileSync(path.join(tempDir, 'backlog', 'legacy-1.md'), 'utf-8')
1092
+ const file2 = fs.readFileSync(path.join(tempDir, 'backlog', 'legacy-2.md'), 'utf-8')
1093
+ const orderMatch1 = file1.match(/order: "(.+)"/)
1094
+ const orderMatch2 = file2.match(/order: "(.+)"/)
1095
+ expect(orderMatch1).toBeTruthy()
1096
+ expect(orderMatch2).toBeTruthy()
1097
+ expect(/^\d+$/.test(orderMatch1![1])).toBe(false)
1098
+ expect(/^\d+$/.test(orderMatch2![1])).toBe(false)
1099
+ // First should come before second
1100
+ expect(orderMatch1![1] < orderMatch2![1]).toBe(true)
1101
+ })
1102
+ })
1103
+
1104
+ // ── Migration: reconcile done/non-done ──
1105
+
1106
+ describe('status/folder reconciliation', () => {
1107
+ it('should move root file with status:done to done/ subfolder (migration)', async () => {
1108
+ // Place a done-status file in root (mismatched — legacy flat layout)
1109
+ writeFeatureFile(tempDir, 'misplaced-done.md', makeFeatureContent({
1110
+ id: 'misplaced-done',
1111
+ status: 'done',
1112
+ title: 'Misplaced Done'
1113
+ }))
1114
+
1115
+ server = startServer(tempDir, port, webviewDir)
1116
+ await sleep(200)
1117
+ ws = await connectWs(port)
1118
+
1119
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1120
+
1121
+ // After load, file should have been migrated to done/
1122
+ expect(fs.existsSync(path.join(tempDir, 'misplaced-done.md'))).toBe(false)
1123
+ expect(fs.existsSync(path.join(tempDir, 'done', 'misplaced-done.md'))).toBe(true)
1124
+ })
1125
+
1126
+ it('should move mismatched file to correct status subfolder', async () => {
1127
+ // Place a backlog-status file in done/ (mismatched)
1128
+ writeFeatureFile(tempDir, 'misplaced-active.md', makeFeatureContent({
1129
+ id: 'misplaced-active',
1130
+ status: 'backlog',
1131
+ title: 'Misplaced Active'
1132
+ }), 'done')
1133
+
1134
+ server = startServer(tempDir, port, webviewDir)
1135
+ await sleep(200)
1136
+ ws = await connectWs(port)
1137
+
1138
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1139
+
1140
+ // After load, file should have been moved to backlog/
1141
+ expect(fs.existsSync(path.join(tempDir, 'done', 'misplaced-active.md'))).toBe(false)
1142
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'misplaced-active.md'))).toBe(true)
1143
+ })
1144
+ })
1145
+
1146
+ // ── Parsing edge cases ──
1147
+
1148
+ describe('parsing edge cases', () => {
1149
+ it('should skip non-markdown files', async () => {
1150
+ writeFeatureFile(tempDir, 'not-a-feature.txt', 'just some text', 'backlog')
1151
+ writeFeatureFile(tempDir, 'real-feature.md', makeFeatureContent({
1152
+ id: 'real-feature',
1153
+ title: 'Real Feature'
1154
+ }), 'backlog')
1155
+
1156
+ server = startServer(tempDir, port, webviewDir)
1157
+ await sleep(200)
1158
+ ws = await connectWs(port)
1159
+
1160
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
1161
+ const features = response.features as Array<Record<string, unknown>>
1162
+ expect(features.length).toBe(1)
1163
+ expect(features[0].id).toBe('real-feature')
1164
+ })
1165
+
1166
+ it('should skip files without valid frontmatter', async () => {
1167
+ writeFeatureFile(tempDir, 'no-frontmatter.md', '# Just a heading\n\nNo frontmatter here.', 'backlog')
1168
+ writeFeatureFile(tempDir, 'valid.md', makeFeatureContent({
1169
+ id: 'valid',
1170
+ title: 'Valid Feature'
1171
+ }), 'backlog')
1172
+
1173
+ server = startServer(tempDir, port, webviewDir)
1174
+ await sleep(200)
1175
+ ws = await connectWs(port)
1176
+
1177
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
1178
+ const features = response.features as Array<Record<string, unknown>>
1179
+ expect(features.length).toBe(1)
1180
+ expect(features[0].id).toBe('valid')
1181
+ })
1182
+
1183
+ it('should handle Windows-style line endings', async () => {
1184
+ const content = makeFeatureContent({
1185
+ id: 'crlf-feature',
1186
+ title: 'CRLF Feature'
1187
+ }).replace(/\n/g, '\r\n')
1188
+
1189
+ writeFeatureFile(tempDir, 'crlf-feature.md', content, 'backlog')
1190
+
1191
+ server = startServer(tempDir, port, webviewDir)
1192
+ await sleep(200)
1193
+ ws = await connectWs(port)
1194
+
1195
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
1196
+ const features = response.features as Array<Record<string, unknown>>
1197
+ expect(features.length).toBe(1)
1198
+ expect(features[0].id).toBe('crlf-feature')
1199
+ })
1200
+ })
1201
+
1202
+ // ── Settings ──
1203
+
1204
+ describe('settings', () => {
1205
+ it('should respond to openSettings with showSettings message', async () => {
1206
+ server = startServer(tempDir, port, webviewDir)
1207
+ await sleep(200)
1208
+ ws = await connectWs(port)
1209
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1210
+
1211
+ const response = await sendAndReceive(ws, { type: 'openSettings' }, 'showSettings')
1212
+ expect(response.type).toBe('showSettings')
1213
+ expect(response.settings).toBeDefined()
1214
+ const settings = response.settings as Record<string, unknown>
1215
+ expect(settings.showPriorityBadges).toBe(true)
1216
+ expect(settings.showBuildWithAI).toBe(false)
1217
+ expect(settings.markdownEditorMode).toBe(false)
1218
+ })
1219
+
1220
+ it('should persist settings to .kanban-settings.json', async () => {
1221
+ server = startServer(tempDir, port, webviewDir)
1222
+ await sleep(200)
1223
+ ws = await connectWs(port)
1224
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1225
+
1226
+ const response = await sendAndReceive(ws, {
1227
+ type: 'saveSettings',
1228
+ settings: {
1229
+ showPriorityBadges: true,
1230
+ showAssignee: true,
1231
+ showDueDate: true,
1232
+ showLabels: false,
1233
+ showBuildWithAI: false,
1234
+ showFileName: false,
1235
+ compactMode: true,
1236
+ markdownEditorMode: false,
1237
+ defaultPriority: 'high',
1238
+ defaultStatus: 'todo'
1239
+ }
1240
+ }, 'init')
1241
+
1242
+ // init broadcast should have updated settings
1243
+ const settings = response.settings as Record<string, unknown>
1244
+ expect(settings.compactMode).toBe(true)
1245
+ expect(settings.showLabels).toBe(false)
1246
+ expect(settings.defaultPriority).toBe('high')
1247
+ expect(settings.defaultStatus).toBe('todo')
1248
+
1249
+ // Verify file on disk (config is at workspace root, i.e. parent of features dir)
1250
+ const configFile = path.join(path.dirname(tempDir), '.kanban.json')
1251
+ expect(fs.existsSync(configFile)).toBe(true)
1252
+ const persisted = JSON.parse(fs.readFileSync(configFile, 'utf-8'))
1253
+ expect(persisted.compactMode).toBe(true)
1254
+ expect(persisted.showLabels).toBe(false)
1255
+ })
1256
+
1257
+ it('should load persisted settings on server restart', async () => {
1258
+ // Write config file at workspace root (parent of features dir)
1259
+ fs.mkdirSync(tempDir, { recursive: true })
1260
+ fs.writeFileSync(
1261
+ path.join(path.dirname(tempDir), '.kanban.json'),
1262
+ JSON.stringify({
1263
+ showPriorityBadges: false,
1264
+ compactMode: true,
1265
+ defaultPriority: 'low'
1266
+ }),
1267
+ 'utf-8'
1268
+ )
1269
+
1270
+ server = startServer(tempDir, port, webviewDir)
1271
+ await sleep(200)
1272
+ ws = await connectWs(port)
1273
+
1274
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
1275
+ const settings = response.settings as Record<string, unknown>
1276
+ expect(settings.showPriorityBadges).toBe(false)
1277
+ expect(settings.compactMode).toBe(true)
1278
+ expect(settings.defaultPriority).toBe('low')
1279
+ // Defaults for unspecified settings
1280
+ expect(settings.showAssignee).toBe(true)
1281
+ expect(settings.showDueDate).toBe(true)
1282
+ })
1283
+
1284
+ it('should broadcast settings to all connected clients', async () => {
1285
+ server = startServer(tempDir, port, webviewDir)
1286
+ await sleep(200)
1287
+
1288
+ const ws1 = await connectWs(port)
1289
+ const ws2 = await connectWs(port)
1290
+
1291
+ await sendAndReceive(ws1, { type: 'ready' }, 'init')
1292
+ await sendAndReceive(ws2, { type: 'ready' }, 'init')
1293
+
1294
+ const ws2Update = waitForMessage(ws2, 'init', 3000)
1295
+
1296
+ ws1.send(JSON.stringify({
1297
+ type: 'saveSettings',
1298
+ settings: {
1299
+ showPriorityBadges: true,
1300
+ showAssignee: true,
1301
+ showDueDate: true,
1302
+ showLabels: true,
1303
+ showBuildWithAI: false,
1304
+ showFileName: true,
1305
+ compactMode: true,
1306
+ markdownEditorMode: false,
1307
+ defaultPriority: 'medium',
1308
+ defaultStatus: 'backlog'
1309
+ }
1310
+ }))
1311
+
1312
+ const response = await ws2Update
1313
+ const settings = response.settings as Record<string, unknown>
1314
+ expect(settings.compactMode).toBe(true)
1315
+ expect(settings.showFileName).toBe(true)
1316
+
1317
+ ws1.close()
1318
+ ws2.close()
1319
+ await sleep(50)
1320
+ })
1321
+
1322
+ it('should force showBuildWithAI=false even if client sends true', async () => {
1323
+ server = startServer(tempDir, port, webviewDir)
1324
+ await sleep(200)
1325
+ ws = await connectWs(port)
1326
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1327
+
1328
+ const response = await sendAndReceive(ws, {
1329
+ type: 'saveSettings',
1330
+ settings: {
1331
+ showPriorityBadges: true,
1332
+ showAssignee: true,
1333
+ showDueDate: true,
1334
+ showLabels: true,
1335
+ showBuildWithAI: true,
1336
+ showFileName: false,
1337
+ compactMode: false,
1338
+ markdownEditorMode: true,
1339
+ defaultPriority: 'medium',
1340
+ defaultStatus: 'backlog'
1341
+ }
1342
+ }, 'init')
1343
+
1344
+ const settings = response.settings as Record<string, unknown>
1345
+ expect(settings.showBuildWithAI).toBe(false)
1346
+ expect(settings.markdownEditorMode).toBe(false)
1347
+ })
1348
+
1349
+ it('should handle corrupt settings file gracefully', async () => {
1350
+ fs.mkdirSync(tempDir, { recursive: true })
1351
+ fs.writeFileSync(path.join(tempDir, '.kanban-settings.json'), 'not valid json{{{', 'utf-8')
1352
+
1353
+ server = startServer(tempDir, port, webviewDir)
1354
+ await sleep(200)
1355
+ ws = await connectWs(port)
1356
+
1357
+ const response = await sendAndReceive(ws, { type: 'ready' }, 'init')
1358
+ const settings = response.settings as Record<string, unknown>
1359
+ // Should fall back to defaults
1360
+ expect(settings.showPriorityBadges).toBe(true)
1361
+ expect(settings.compactMode).toBe(false)
1362
+ })
1363
+ })
1364
+
1365
+ // ── REST API: Tasks ──
1366
+
1367
+ describe('REST API — Tasks', () => {
1368
+ it('GET /api/tasks should list tasks', async () => {
1369
+ writeFeatureFile(tempDir, 'api-task-1.md', makeFeatureContent({
1370
+ id: 'api-task-1',
1371
+ status: 'backlog',
1372
+ title: 'API Task 1'
1373
+ }), 'backlog')
1374
+ writeFeatureFile(tempDir, 'api-task-2.md', makeFeatureContent({
1375
+ id: 'api-task-2',
1376
+ status: 'todo',
1377
+ title: 'API Task 2'
1378
+ }), 'todo')
1379
+
1380
+ server = startServer(tempDir, port, webviewDir)
1381
+ await sleep(200)
1382
+ // Initialize via WS so server loads features
1383
+ ws = await connectWs(port)
1384
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1385
+
1386
+ const res = await httpGet(`http://localhost:${port}/api/tasks`)
1387
+ expect(res.status).toBe(200)
1388
+ const json = JSON.parse(res.body)
1389
+ expect(json.ok).toBe(true)
1390
+ expect(json.data.length).toBe(2)
1391
+ // Should not include filePath
1392
+ expect(json.data[0].filePath).toBeUndefined()
1393
+ })
1394
+
1395
+ it('GET /api/tasks should filter by status', async () => {
1396
+ writeFeatureFile(tempDir, 'filter-1.md', makeFeatureContent({
1397
+ id: 'filter-1',
1398
+ status: 'backlog'
1399
+ }), 'backlog')
1400
+ writeFeatureFile(tempDir, 'filter-2.md', makeFeatureContent({
1401
+ id: 'filter-2',
1402
+ status: 'todo'
1403
+ }), 'todo')
1404
+
1405
+ server = startServer(tempDir, port, webviewDir)
1406
+ await sleep(200)
1407
+ ws = await connectWs(port)
1408
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1409
+
1410
+ const res = await httpGet(`http://localhost:${port}/api/tasks?status=todo`)
1411
+ const json = JSON.parse(res.body)
1412
+ expect(json.ok).toBe(true)
1413
+ expect(json.data.length).toBe(1)
1414
+ expect(json.data[0].id).toBe('filter-2')
1415
+ })
1416
+
1417
+ it('GET /api/tasks should filter by priority', async () => {
1418
+ writeFeatureFile(tempDir, 'pri-high.md', makeFeatureContent({
1419
+ id: 'pri-high',
1420
+ priority: 'high'
1421
+ }), 'backlog')
1422
+ writeFeatureFile(tempDir, 'pri-low.md', makeFeatureContent({
1423
+ id: 'pri-low',
1424
+ priority: 'low'
1425
+ }), 'backlog')
1426
+
1427
+ server = startServer(tempDir, port, webviewDir)
1428
+ await sleep(200)
1429
+ ws = await connectWs(port)
1430
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1431
+
1432
+ const res = await httpGet(`http://localhost:${port}/api/tasks?priority=high`)
1433
+ const json = JSON.parse(res.body)
1434
+ expect(json.ok).toBe(true)
1435
+ expect(json.data.length).toBe(1)
1436
+ expect(json.data[0].id).toBe('pri-high')
1437
+ })
1438
+
1439
+ it('GET /api/tasks should filter by assignee', async () => {
1440
+ writeFeatureFile(tempDir, 'assign-alice.md', makeFeatureContent({
1441
+ id: 'assign-alice',
1442
+ assignee: 'alice'
1443
+ }), 'backlog')
1444
+ writeFeatureFile(tempDir, 'assign-bob.md', makeFeatureContent({
1445
+ id: 'assign-bob',
1446
+ assignee: 'bob'
1447
+ }), 'backlog')
1448
+
1449
+ server = startServer(tempDir, port, webviewDir)
1450
+ await sleep(200)
1451
+ ws = await connectWs(port)
1452
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1453
+
1454
+ const res = await httpGet(`http://localhost:${port}/api/tasks?assignee=alice`)
1455
+ const json = JSON.parse(res.body)
1456
+ expect(json.ok).toBe(true)
1457
+ expect(json.data.length).toBe(1)
1458
+ expect(json.data[0].id).toBe('assign-alice')
1459
+ })
1460
+
1461
+ it('GET /api/tasks should filter by label', async () => {
1462
+ writeFeatureFile(tempDir, 'label-fe.md', makeFeatureContent({
1463
+ id: 'label-fe',
1464
+ labels: ['frontend']
1465
+ }), 'backlog')
1466
+ writeFeatureFile(tempDir, 'label-be.md', makeFeatureContent({
1467
+ id: 'label-be',
1468
+ labels: ['backend']
1469
+ }), 'backlog')
1470
+
1471
+ server = startServer(tempDir, port, webviewDir)
1472
+ await sleep(200)
1473
+ ws = await connectWs(port)
1474
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1475
+
1476
+ const res = await httpGet(`http://localhost:${port}/api/tasks?label=frontend`)
1477
+ const json = JSON.parse(res.body)
1478
+ expect(json.ok).toBe(true)
1479
+ expect(json.data.length).toBe(1)
1480
+ expect(json.data[0].id).toBe('label-fe')
1481
+ })
1482
+
1483
+ it('GET /api/tasks/:id should return a single task', async () => {
1484
+ writeFeatureFile(tempDir, 'single-task.md', makeFeatureContent({
1485
+ id: 'single-task',
1486
+ status: 'todo',
1487
+ priority: 'high',
1488
+ title: 'Single Task'
1489
+ }), 'todo')
1490
+
1491
+ server = startServer(tempDir, port, webviewDir)
1492
+ await sleep(200)
1493
+ ws = await connectWs(port)
1494
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1495
+
1496
+ const res = await httpGet(`http://localhost:${port}/api/tasks/single-task`)
1497
+ const json = JSON.parse(res.body)
1498
+ expect(json.ok).toBe(true)
1499
+ expect(json.data.id).toBe('single-task')
1500
+ expect(json.data.status).toBe('todo')
1501
+ expect(json.data.filePath).toBeUndefined()
1502
+ })
1503
+
1504
+ it('GET /api/tasks/:id should return 404 for non-existent task', async () => {
1505
+ server = startServer(tempDir, port, webviewDir)
1506
+ await sleep(200)
1507
+ ws = await connectWs(port)
1508
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1509
+
1510
+ const res = await httpGet(`http://localhost:${port}/api/tasks/nonexistent`)
1511
+ expect(res.status).toBe(404)
1512
+ const json = JSON.parse(res.body)
1513
+ expect(json.ok).toBe(false)
1514
+ })
1515
+
1516
+ it('POST /api/tasks should create a task', async () => {
1517
+ server = startServer(tempDir, port, webviewDir)
1518
+ await sleep(200)
1519
+ ws = await connectWs(port)
1520
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1521
+
1522
+ const res = await httpRequest('POST', `http://localhost:${port}/api/tasks`, {
1523
+ content: '# API Created Task\n\nDescription.',
1524
+ status: 'todo',
1525
+ priority: 'high',
1526
+ assignee: 'alice',
1527
+ labels: ['api']
1528
+ })
1529
+ expect(res.status).toBe(201)
1530
+ const json = JSON.parse(res.body)
1531
+ expect(json.ok).toBe(true)
1532
+ expect(json.data.status).toBe('todo')
1533
+ expect(json.data.priority).toBe('high')
1534
+ expect(json.data.assignee).toBe('alice')
1535
+ expect(json.data.labels).toEqual(['api'])
1536
+ expect(json.data.filePath).toBeUndefined()
1537
+
1538
+ // Verify persisted on disk
1539
+ const todoDir = path.join(tempDir, 'todo')
1540
+ const files = fs.readdirSync(todoDir).filter(f => f.endsWith('.md'))
1541
+ expect(files.length).toBe(1)
1542
+ })
1543
+
1544
+ it('PUT /api/tasks/:id should update a task', async () => {
1545
+ writeFeatureFile(tempDir, 'update-api.md', makeFeatureContent({
1546
+ id: 'update-api',
1547
+ status: 'backlog',
1548
+ priority: 'low'
1549
+ }), 'backlog')
1550
+
1551
+ server = startServer(tempDir, port, webviewDir)
1552
+ await sleep(200)
1553
+ ws = await connectWs(port)
1554
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1555
+
1556
+ const res = await httpRequest('PUT', `http://localhost:${port}/api/tasks/update-api`, {
1557
+ priority: 'critical',
1558
+ assignee: 'bob'
1559
+ })
1560
+ expect(res.status).toBe(200)
1561
+ const json = JSON.parse(res.body)
1562
+ expect(json.ok).toBe(true)
1563
+ expect(json.data.priority).toBe('critical')
1564
+ expect(json.data.assignee).toBe('bob')
1565
+ })
1566
+
1567
+ it('PUT /api/tasks/:id should return 404 for non-existent task', async () => {
1568
+ server = startServer(tempDir, port, webviewDir)
1569
+ await sleep(200)
1570
+ ws = await connectWs(port)
1571
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1572
+
1573
+ const res = await httpRequest('PUT', `http://localhost:${port}/api/tasks/nonexistent`, {
1574
+ priority: 'high'
1575
+ })
1576
+ expect(res.status).toBe(404)
1577
+ })
1578
+
1579
+ it('PATCH /api/tasks/:id/move should move a task', async () => {
1580
+ writeFeatureFile(tempDir, 'move-api.md', makeFeatureContent({
1581
+ id: 'move-api',
1582
+ status: 'backlog'
1583
+ }), 'backlog')
1584
+
1585
+ server = startServer(tempDir, port, webviewDir)
1586
+ await sleep(200)
1587
+ ws = await connectWs(port)
1588
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1589
+
1590
+ const res = await httpRequest('PATCH', `http://localhost:${port}/api/tasks/move-api/move`, {
1591
+ status: 'in-progress',
1592
+ position: 0
1593
+ })
1594
+ expect(res.status).toBe(200)
1595
+ const json = JSON.parse(res.body)
1596
+ expect(json.ok).toBe(true)
1597
+ expect(json.data.status).toBe('in-progress')
1598
+
1599
+ // File should be moved
1600
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'move-api.md'))).toBe(false)
1601
+ expect(fs.existsSync(path.join(tempDir, 'in-progress', 'move-api.md'))).toBe(true)
1602
+ })
1603
+
1604
+ it('DELETE /api/tasks/:id should delete a task', async () => {
1605
+ writeFeatureFile(tempDir, 'delete-api.md', makeFeatureContent({
1606
+ id: 'delete-api'
1607
+ }), 'backlog')
1608
+
1609
+ server = startServer(tempDir, port, webviewDir)
1610
+ await sleep(200)
1611
+ ws = await connectWs(port)
1612
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1613
+
1614
+ const res = await httpRequest('DELETE', `http://localhost:${port}/api/tasks/delete-api`)
1615
+ expect(res.status).toBe(200)
1616
+ const json = JSON.parse(res.body)
1617
+ expect(json.ok).toBe(true)
1618
+
1619
+ // File should be gone
1620
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'delete-api.md'))).toBe(false)
1621
+ })
1622
+
1623
+ it('DELETE /api/tasks/:id should return 404 for non-existent task', async () => {
1624
+ server = startServer(tempDir, port, webviewDir)
1625
+ await sleep(200)
1626
+ ws = await connectWs(port)
1627
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1628
+
1629
+ const res = await httpRequest('DELETE', `http://localhost:${port}/api/tasks/nonexistent`)
1630
+ expect(res.status).toBe(404)
1631
+ })
1632
+ })
1633
+
1634
+ // ── REST API: Columns ──
1635
+
1636
+ describe('REST API — Columns', () => {
1637
+ it('GET /api/columns should list columns', async () => {
1638
+ server = startServer(tempDir, port, webviewDir)
1639
+ await sleep(200)
1640
+ ws = await connectWs(port)
1641
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1642
+
1643
+ const res = await httpGet(`http://localhost:${port}/api/columns`)
1644
+ const json = JSON.parse(res.body)
1645
+ expect(json.ok).toBe(true)
1646
+ expect(json.data.length).toBe(5)
1647
+ expect(json.data.map((c: Record<string, unknown>) => c.id)).toEqual([
1648
+ 'backlog', 'todo', 'in-progress', 'review', 'done'
1649
+ ])
1650
+ })
1651
+
1652
+ it('POST /api/columns should add a column', async () => {
1653
+ server = startServer(tempDir, port, webviewDir)
1654
+ await sleep(200)
1655
+ ws = await connectWs(port)
1656
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1657
+
1658
+ const res = await httpRequest('POST', `http://localhost:${port}/api/columns`, {
1659
+ name: 'Testing',
1660
+ color: '#ff9900'
1661
+ })
1662
+ expect(res.status).toBe(201)
1663
+ const json = JSON.parse(res.body)
1664
+ expect(json.ok).toBe(true)
1665
+ expect(json.data.name).toBe('Testing')
1666
+ expect(json.data.color).toBe('#ff9900')
1667
+
1668
+ // Verify column was added
1669
+ const listRes = await httpGet(`http://localhost:${port}/api/columns`)
1670
+ const listJson = JSON.parse(listRes.body)
1671
+ expect(listJson.data.length).toBe(6)
1672
+ const testing = listJson.data.find((c: Record<string, unknown>) => c.id === json.data.id)
1673
+ expect(testing).toBeDefined()
1674
+ expect(testing.name).toBe('Testing')
1675
+ })
1676
+
1677
+ it('PUT /api/columns/:id should update a column', async () => {
1678
+ server = startServer(tempDir, port, webviewDir)
1679
+ await sleep(200)
1680
+ ws = await connectWs(port)
1681
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1682
+
1683
+ const res = await httpRequest('PUT', `http://localhost:${port}/api/columns/review`, {
1684
+ name: 'QA Review',
1685
+ color: '#ff0000'
1686
+ })
1687
+ expect(res.status).toBe(200)
1688
+ const json = JSON.parse(res.body)
1689
+ expect(json.ok).toBe(true)
1690
+
1691
+ // Verify update
1692
+ const listRes = await httpGet(`http://localhost:${port}/api/columns`)
1693
+ const listJson = JSON.parse(listRes.body)
1694
+ const review = listJson.data.find((c: Record<string, unknown>) => c.id === 'review')
1695
+ expect(review.name).toBe('QA Review')
1696
+ expect(review.color).toBe('#ff0000')
1697
+ })
1698
+
1699
+ it('PUT /api/columns/:id should return 404 for non-existent column', async () => {
1700
+ server = startServer(tempDir, port, webviewDir)
1701
+ await sleep(200)
1702
+ ws = await connectWs(port)
1703
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1704
+
1705
+ const res = await httpRequest('PUT', `http://localhost:${port}/api/columns/nonexistent`, {
1706
+ name: 'Nope'
1707
+ })
1708
+ expect(res.status).toBe(404)
1709
+ })
1710
+
1711
+ it('DELETE /api/columns/:id should remove an empty column', async () => {
1712
+ server = startServer(tempDir, port, webviewDir)
1713
+ await sleep(200)
1714
+ ws = await connectWs(port)
1715
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1716
+
1717
+ // Add a column first, then remove it
1718
+ const createRes = await httpRequest('POST', `http://localhost:${port}/api/columns`, {
1719
+ name: 'Temp Col',
1720
+ color: '#000'
1721
+ })
1722
+ const createdCol = JSON.parse(createRes.body).data
1723
+ const colId = createdCol.id
1724
+
1725
+ const res = await httpRequest('DELETE', `http://localhost:${port}/api/columns/${colId}`)
1726
+ expect(res.status).toBe(200)
1727
+
1728
+ // Verify removal
1729
+ const listRes = await httpGet(`http://localhost:${port}/api/columns`)
1730
+ const listJson = JSON.parse(listRes.body)
1731
+ expect(listJson.data.find((c: Record<string, unknown>) => c.id === colId)).toBeUndefined()
1732
+ })
1733
+ })
1734
+
1735
+ // ── REST API: Settings ──
1736
+
1737
+ describe('REST API — Settings', () => {
1738
+ it('GET /api/settings should return settings', async () => {
1739
+ server = startServer(tempDir, port, webviewDir)
1740
+ await sleep(200)
1741
+ ws = await connectWs(port)
1742
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1743
+
1744
+ const res = await httpGet(`http://localhost:${port}/api/settings`)
1745
+ const json = JSON.parse(res.body)
1746
+ expect(json.ok).toBe(true)
1747
+ expect(json.data.showPriorityBadges).toBe(true)
1748
+ expect(json.data.showBuildWithAI).toBe(false)
1749
+ })
1750
+
1751
+ it('PUT /api/settings should update settings', async () => {
1752
+ server = startServer(tempDir, port, webviewDir)
1753
+ await sleep(200)
1754
+ ws = await connectWs(port)
1755
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1756
+
1757
+ const res = await httpRequest('PUT', `http://localhost:${port}/api/settings`, {
1758
+ showPriorityBadges: false,
1759
+ compactMode: true,
1760
+ showAssignee: true,
1761
+ showDueDate: true,
1762
+ showLabels: true,
1763
+ showBuildWithAI: false,
1764
+ showFileName: false,
1765
+ markdownEditorMode: false,
1766
+ defaultPriority: 'high',
1767
+ defaultStatus: 'todo'
1768
+ })
1769
+ expect(res.status).toBe(200)
1770
+ const json = JSON.parse(res.body)
1771
+ expect(json.ok).toBe(true)
1772
+ expect(json.data.showPriorityBadges).toBe(false)
1773
+ expect(json.data.compactMode).toBe(true)
1774
+
1775
+ // Verify via GET
1776
+ const getRes = await httpGet(`http://localhost:${port}/api/settings`)
1777
+ const getJson = JSON.parse(getRes.body)
1778
+ expect(getJson.data.showPriorityBadges).toBe(false)
1779
+ expect(getJson.data.compactMode).toBe(true)
1780
+ })
1781
+ })
1782
+
1783
+ // ── REST API: Webhooks ──
1784
+
1785
+ describe('REST API — Webhooks', () => {
1786
+ it('GET /api/webhooks should return empty list initially', async () => {
1787
+ server = startServer(tempDir, port, webviewDir)
1788
+ await sleep(200)
1789
+ ws = await connectWs(port)
1790
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1791
+
1792
+ const res = await httpGet(`http://localhost:${port}/api/webhooks`)
1793
+ const json = JSON.parse(res.body)
1794
+ expect(json.ok).toBe(true)
1795
+ expect(json.data).toEqual([])
1796
+ })
1797
+
1798
+ it('POST /api/webhooks should register a webhook', async () => {
1799
+ server = startServer(tempDir, port, webviewDir)
1800
+ await sleep(200)
1801
+ ws = await connectWs(port)
1802
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1803
+
1804
+ const res = await httpRequest('POST', `http://localhost:${port}/api/webhooks`, {
1805
+ url: 'https://example.com/hook',
1806
+ events: ['task.created', 'task.moved'],
1807
+ secret: 'test-secret'
1808
+ })
1809
+ expect(res.status).toBe(201)
1810
+ const json = JSON.parse(res.body)
1811
+ expect(json.ok).toBe(true)
1812
+ expect(json.data.url).toBe('https://example.com/hook')
1813
+ expect(json.data.events).toEqual(['task.created', 'task.moved'])
1814
+ expect(json.data.id).toMatch(/^wh_/)
1815
+
1816
+ // Verify via GET
1817
+ const listRes = await httpGet(`http://localhost:${port}/api/webhooks`)
1818
+ const listJson = JSON.parse(listRes.body)
1819
+ expect(listJson.data.length).toBe(1)
1820
+ })
1821
+
1822
+ it('DELETE /api/webhooks/:id should remove a webhook', async () => {
1823
+ server = startServer(tempDir, port, webviewDir)
1824
+ await sleep(200)
1825
+ ws = await connectWs(port)
1826
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1827
+
1828
+ // Create first
1829
+ const createRes = await httpRequest('POST', `http://localhost:${port}/api/webhooks`, {
1830
+ url: 'https://example.com/hook',
1831
+ events: ['*']
1832
+ })
1833
+ const webhookId = JSON.parse(createRes.body).data.id
1834
+
1835
+ // Delete
1836
+ const res = await httpRequest('DELETE', `http://localhost:${port}/api/webhooks/${webhookId}`)
1837
+ expect(res.status).toBe(200)
1838
+
1839
+ // Verify removed
1840
+ const listRes = await httpGet(`http://localhost:${port}/api/webhooks`)
1841
+ const listJson = JSON.parse(listRes.body)
1842
+ expect(listJson.data.length).toBe(0)
1843
+ })
1844
+
1845
+ it('DELETE /api/webhooks/:id should return 404 for non-existent webhook', async () => {
1846
+ server = startServer(tempDir, port, webviewDir)
1847
+ await sleep(200)
1848
+ ws = await connectWs(port)
1849
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1850
+
1851
+ const res = await httpRequest('DELETE', `http://localhost:${port}/api/webhooks/wh_nonexistent`)
1852
+ expect(res.status).toBe(404)
1853
+ })
1854
+ })
1855
+
1856
+ // ── REST API: CORS & Error Handling ──
1857
+
1858
+ describe('REST API — CORS & Error Handling', () => {
1859
+ it('should include CORS headers on API responses', async () => {
1860
+ server = startServer(tempDir, port, webviewDir)
1861
+ await sleep(200)
1862
+ ws = await connectWs(port)
1863
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1864
+
1865
+ const res = await httpGet(`http://localhost:${port}/api/tasks`)
1866
+ expect(res.headers['access-control-allow-origin']).toBe('*')
1867
+ })
1868
+
1869
+ it('should handle OPTIONS preflight for CORS', async () => {
1870
+ server = startServer(tempDir, port, webviewDir)
1871
+ await sleep(200)
1872
+ ws = await connectWs(port)
1873
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1874
+
1875
+ const res = await httpRequest('OPTIONS', `http://localhost:${port}/api/tasks`)
1876
+ expect(res.status).toBe(204)
1877
+ expect(res.headers['access-control-allow-origin']).toBe('*')
1878
+ expect(res.headers['access-control-allow-methods']).toBeDefined()
1879
+ })
1880
+
1881
+ it('should return 404 for unknown API paths', async () => {
1882
+ server = startServer(tempDir, port, webviewDir)
1883
+ await sleep(200)
1884
+ ws = await connectWs(port)
1885
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1886
+
1887
+ const res = await httpGet(`http://localhost:${port}/api/nonexistent`)
1888
+ expect(res.status).toBe(404)
1889
+ const json = JSON.parse(res.body)
1890
+ expect(json.ok).toBe(false)
1891
+ })
1892
+
1893
+ it('REST API changes should broadcast to WebSocket clients', async () => {
1894
+ server = startServer(tempDir, port, webviewDir)
1895
+ await sleep(200)
1896
+ ws = await connectWs(port)
1897
+ await sendAndReceive(ws, { type: 'ready' }, 'init')
1898
+
1899
+ // Listen for init broadcast from WS
1900
+ const wsUpdate = waitForMessage(ws, 'init', 5000)
1901
+
1902
+ // Create task via API
1903
+ await httpRequest('POST', `http://localhost:${port}/api/tasks`, {
1904
+ content: '# Broadcast Test',
1905
+ status: 'backlog',
1906
+ priority: 'medium'
1907
+ })
1908
+
1909
+ // WS client should receive broadcast
1910
+ const response = await wsUpdate
1911
+ const features = response.features as Array<Record<string, unknown>>
1912
+ expect(features.length).toBe(1)
1913
+ expect(features[0].content).toContain('Broadcast Test')
1914
+ })
1915
+ })
1916
+ })