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,468 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { KanbanSDK } from '../KanbanSDK'
6
+
7
+ function createTempDir(): string {
8
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-sdk-test-'))
9
+ }
10
+
11
+ function writeCardFile(dir: string, filename: string, content: string, subfolder?: string): void {
12
+ const targetDir = subfolder ? path.join(dir, subfolder) : dir
13
+ fs.mkdirSync(targetDir, { recursive: true })
14
+ fs.writeFileSync(path.join(targetDir, filename), content, 'utf-8')
15
+ }
16
+
17
+ function makeCardContent(opts: {
18
+ id: string
19
+ status?: string
20
+ priority?: string
21
+ title?: string
22
+ order?: string
23
+ assignee?: string | null
24
+ dueDate?: string | null
25
+ labels?: string[]
26
+ }): string {
27
+ const {
28
+ id,
29
+ status = 'backlog',
30
+ priority = 'medium',
31
+ title = 'Test Card',
32
+ order = 'a0',
33
+ assignee = null,
34
+ dueDate = null,
35
+ labels = []
36
+ } = opts
37
+ return `---
38
+ id: "${id}"
39
+ status: "${status}"
40
+ priority: "${priority}"
41
+ assignee: ${assignee ? `"${assignee}"` : 'null'}
42
+ dueDate: ${dueDate ? `"${dueDate}"` : 'null'}
43
+ created: "2025-01-01T00:00:00.000Z"
44
+ modified: "2025-01-01T00:00:00.000Z"
45
+ completedAt: null
46
+ labels: [${labels.map(l => `"${l}"`).join(', ')}]
47
+ order: "${order}"
48
+ ---
49
+ # ${title}
50
+
51
+ Description here.`
52
+ }
53
+
54
+ describe('KanbanSDK', () => {
55
+ let tempDir: string
56
+ let sdk: KanbanSDK
57
+
58
+ beforeEach(() => {
59
+ tempDir = createTempDir()
60
+ sdk = new KanbanSDK(tempDir)
61
+ })
62
+
63
+ afterEach(() => {
64
+ fs.rmSync(tempDir, { recursive: true, force: true })
65
+ })
66
+
67
+ describe('init', () => {
68
+ it('should create the features directory', async () => {
69
+ await sdk.init()
70
+ expect(fs.existsSync(tempDir)).toBe(true)
71
+ })
72
+ })
73
+
74
+ describe('listCards', () => {
75
+ it('should return empty array for empty directory', async () => {
76
+ const cards = await sdk.listCards()
77
+ expect(cards).toEqual([])
78
+ })
79
+
80
+ it('should list cards from status subfolders', async () => {
81
+ writeCardFile(tempDir, 'active.md', makeCardContent({ id: 'active', status: 'todo', order: 'a0' }), 'todo')
82
+ writeCardFile(tempDir, 'completed.md', makeCardContent({ id: 'completed', status: 'done', order: 'a1' }), 'done')
83
+
84
+ const cards = await sdk.listCards()
85
+ expect(cards.length).toBe(2)
86
+ expect(cards.map(c => c.id).sort()).toEqual(['active', 'completed'])
87
+ })
88
+
89
+ it('should return cards sorted by order', async () => {
90
+ writeCardFile(tempDir, 'b.md', makeCardContent({ id: 'b', order: 'b0' }), 'backlog')
91
+ writeCardFile(tempDir, 'a.md', makeCardContent({ id: 'a', order: 'a0' }), 'backlog')
92
+
93
+ const cards = await sdk.listCards()
94
+ expect(cards[0].id).toBe('a')
95
+ expect(cards[1].id).toBe('b')
96
+ })
97
+
98
+ it('should skip files without valid frontmatter', async () => {
99
+ writeCardFile(tempDir, 'invalid.md', '# No frontmatter', 'backlog')
100
+ writeCardFile(tempDir, 'valid.md', makeCardContent({ id: 'valid' }), 'backlog')
101
+
102
+ const cards = await sdk.listCards()
103
+ expect(cards.length).toBe(1)
104
+ expect(cards[0].id).toBe('valid')
105
+ })
106
+
107
+ it('should also load orphaned root-level files for backward compat', async () => {
108
+ writeCardFile(tempDir, 'orphan.md', makeCardContent({ id: 'orphan', status: 'backlog' }))
109
+
110
+ const cards = await sdk.listCards()
111
+ expect(cards.length).toBe(1)
112
+ expect(cards[0].id).toBe('orphan')
113
+ })
114
+ })
115
+
116
+ describe('getCard', () => {
117
+ it('should return a card by ID', async () => {
118
+ writeCardFile(tempDir, 'find-me.md', makeCardContent({ id: 'find-me', priority: 'high' }), 'backlog')
119
+
120
+ const card = await sdk.getCard('find-me')
121
+ expect(card).not.toBeNull()
122
+ expect(card?.id).toBe('find-me')
123
+ expect(card?.priority).toBe('high')
124
+ })
125
+
126
+ it('should return null for non-existent card', async () => {
127
+ const card = await sdk.getCard('ghost')
128
+ expect(card).toBeNull()
129
+ })
130
+ })
131
+
132
+ describe('createCard', () => {
133
+ it('should create a card file on disk', async () => {
134
+ const card = await sdk.createCard({
135
+ content: '# New Card\n\nSome description',
136
+ status: 'todo',
137
+ priority: 'high',
138
+ labels: ['frontend']
139
+ })
140
+
141
+ expect(card.status).toBe('todo')
142
+ expect(card.priority).toBe('high')
143
+ expect(card.labels).toEqual(['frontend'])
144
+ expect(fs.existsSync(card.filePath)).toBe(true)
145
+
146
+ const onDisk = fs.readFileSync(card.filePath, 'utf-8')
147
+ expect(onDisk).toContain('status: "todo"')
148
+ expect(onDisk).toContain('# New Card')
149
+ })
150
+
151
+ it('should use defaults for optional fields', async () => {
152
+ const card = await sdk.createCard({ content: '# Default Card' })
153
+ expect(card.status).toBe('backlog')
154
+ expect(card.priority).toBe('medium')
155
+ expect(card.assignee).toBeNull()
156
+ expect(card.labels).toEqual([])
157
+ })
158
+
159
+ it('should place cards in their status subfolder', async () => {
160
+ const card = await sdk.createCard({
161
+ content: '# Todo Card',
162
+ status: 'todo'
163
+ })
164
+ expect(card.filePath).toContain('/todo/')
165
+ })
166
+
167
+ it('should place done cards in done/ subfolder', async () => {
168
+ const card = await sdk.createCard({
169
+ content: '# Done Card',
170
+ status: 'done'
171
+ })
172
+ expect(card.filePath).toContain('/done/')
173
+ expect(card.completedAt).not.toBeNull()
174
+ })
175
+
176
+ it('should assign incremental order within a column', async () => {
177
+ const c1 = await sdk.createCard({ content: '# First', status: 'todo' })
178
+ const c2 = await sdk.createCard({ content: '# Second', status: 'todo' })
179
+ expect(c2.order > c1.order).toBe(true)
180
+ })
181
+ })
182
+
183
+ describe('updateCard', () => {
184
+ it('should update fields and persist', async () => {
185
+ writeCardFile(tempDir, 'update-me.md', makeCardContent({ id: 'update-me', priority: 'low' }), 'backlog')
186
+
187
+ const updated = await sdk.updateCard('update-me', {
188
+ priority: 'critical',
189
+ assignee: 'alice',
190
+ labels: ['urgent']
191
+ })
192
+
193
+ expect(updated.priority).toBe('critical')
194
+ expect(updated.assignee).toBe('alice')
195
+ expect(updated.labels).toEqual(['urgent'])
196
+
197
+ const onDisk = fs.readFileSync(updated.filePath, 'utf-8')
198
+ expect(onDisk).toContain('priority: "critical"')
199
+ expect(onDisk).toContain('assignee: "alice"')
200
+ })
201
+
202
+ it('should move file to done/ when status changes to done', async () => {
203
+ writeCardFile(tempDir, 'finish-me.md', makeCardContent({ id: 'finish-me', status: 'review' }), 'review')
204
+
205
+ const updated = await sdk.updateCard('finish-me', { status: 'done' })
206
+ expect(updated.completedAt).not.toBeNull()
207
+ expect(updated.filePath).toContain('/done/')
208
+ expect(fs.existsSync(updated.filePath)).toBe(true)
209
+ })
210
+
211
+ it('should move file between status folders on any status change', async () => {
212
+ writeCardFile(tempDir, 'move-status.md', makeCardContent({ id: 'move-status', status: 'backlog' }), 'backlog')
213
+
214
+ const updated = await sdk.updateCard('move-status', { status: 'in-progress' })
215
+ expect(updated.filePath).toContain('/in-progress/')
216
+ expect(fs.existsSync(updated.filePath)).toBe(true)
217
+ expect(fs.existsSync(path.join(tempDir, 'backlog', 'move-status.md'))).toBe(false)
218
+ })
219
+
220
+ it('should throw for non-existent card', async () => {
221
+ await expect(sdk.updateCard('ghost', { priority: 'high' })).rejects.toThrow('Card not found')
222
+ })
223
+ })
224
+
225
+ describe('moveCard', () => {
226
+ it('should change status and move file to new folder', async () => {
227
+ writeCardFile(tempDir, 'move-me.md', makeCardContent({ id: 'move-me', status: 'backlog' }), 'backlog')
228
+
229
+ const moved = await sdk.moveCard('move-me', 'in-progress')
230
+ expect(moved.status).toBe('in-progress')
231
+ expect(moved.filePath).toContain('/in-progress/')
232
+ expect(fs.existsSync(moved.filePath)).toBe(true)
233
+ })
234
+
235
+ it('should handle done boundary crossing', async () => {
236
+ writeCardFile(tempDir, 'to-done.md', makeCardContent({ id: 'to-done', status: 'review' }), 'review')
237
+
238
+ const moved = await sdk.moveCard('to-done', 'done')
239
+ expect(moved.completedAt).not.toBeNull()
240
+ expect(moved.filePath).toContain('/done/')
241
+ })
242
+
243
+ it('should insert at specified position', async () => {
244
+ writeCardFile(tempDir, 'a.md', makeCardContent({ id: 'a', status: 'todo', order: 'a0' }), 'todo')
245
+ writeCardFile(tempDir, 'c.md', makeCardContent({ id: 'c', status: 'todo', order: 'a2' }), 'todo')
246
+ writeCardFile(tempDir, 'new.md', makeCardContent({ id: 'new', status: 'backlog', order: 'a0' }), 'backlog')
247
+
248
+ const moved = await sdk.moveCard('new', 'todo', 1)
249
+ expect(moved.order > 'a0').toBe(true)
250
+ expect(moved.order < 'a2').toBe(true)
251
+ })
252
+
253
+ it('should throw for non-existent card', async () => {
254
+ await expect(sdk.moveCard('ghost', 'todo')).rejects.toThrow('Card not found')
255
+ })
256
+ })
257
+
258
+ describe('deleteCard', () => {
259
+ it('should remove the file from disk', async () => {
260
+ writeCardFile(tempDir, 'delete-me.md', makeCardContent({ id: 'delete-me' }), 'backlog')
261
+ const filePath = path.join(tempDir, 'backlog', 'delete-me.md')
262
+ expect(fs.existsSync(filePath)).toBe(true)
263
+
264
+ await sdk.deleteCard('delete-me')
265
+ expect(fs.existsSync(filePath)).toBe(false)
266
+ })
267
+
268
+ it('should throw for non-existent card', async () => {
269
+ await expect(sdk.deleteCard('ghost')).rejects.toThrow('Card not found')
270
+ })
271
+ })
272
+
273
+ describe('getCardsByStatus', () => {
274
+ it('should filter cards by status', async () => {
275
+ writeCardFile(tempDir, 'todo1.md', makeCardContent({ id: 'todo1', status: 'todo', order: 'a0' }), 'todo')
276
+ writeCardFile(tempDir, 'todo2.md', makeCardContent({ id: 'todo2', status: 'todo', order: 'a1' }), 'todo')
277
+ writeCardFile(tempDir, 'backlog1.md', makeCardContent({ id: 'backlog1', status: 'backlog', order: 'a0' }), 'backlog')
278
+
279
+ const todoCards = await sdk.getCardsByStatus('todo')
280
+ expect(todoCards.length).toBe(2)
281
+ expect(todoCards.every(c => c.status === 'todo')).toBe(true)
282
+ })
283
+ })
284
+
285
+ describe('getUniqueAssignees', () => {
286
+ it('should return sorted unique assignees', async () => {
287
+ writeCardFile(tempDir, 'c1.md', makeCardContent({ id: 'c1', assignee: 'bob', order: 'a0' }), 'backlog')
288
+ writeCardFile(tempDir, 'c2.md', makeCardContent({ id: 'c2', assignee: 'alice', order: 'a1' }), 'backlog')
289
+ writeCardFile(tempDir, 'c3.md', makeCardContent({ id: 'c3', assignee: 'bob', order: 'a2' }), 'backlog')
290
+
291
+ const assignees = await sdk.getUniqueAssignees()
292
+ expect(assignees).toEqual(['alice', 'bob'])
293
+ })
294
+ })
295
+
296
+ describe('getUniqueLabels', () => {
297
+ it('should return sorted unique labels', async () => {
298
+ writeCardFile(tempDir, 'c1.md', makeCardContent({ id: 'c1', labels: ['ui', 'frontend'], order: 'a0' }), 'backlog')
299
+ writeCardFile(tempDir, 'c2.md', makeCardContent({ id: 'c2', labels: ['backend', 'ui'], order: 'a1' }), 'backlog')
300
+
301
+ const labels = await sdk.getUniqueLabels()
302
+ expect(labels).toEqual(['backend', 'frontend', 'ui'])
303
+ })
304
+ })
305
+
306
+ describe('addAttachment', () => {
307
+ it('should copy file and add to attachments', async () => {
308
+ writeCardFile(tempDir, 'card.md', makeCardContent({ id: 'card' }), 'backlog')
309
+
310
+ // Create a source file to attach
311
+ const srcFile = path.join(os.tmpdir(), 'test-attach.txt')
312
+ fs.writeFileSync(srcFile, 'hello', 'utf-8')
313
+
314
+ const updated = await sdk.addAttachment('card', srcFile)
315
+ expect(updated.attachments).toContain('test-attach.txt')
316
+
317
+ // Verify file was copied to the status subfolder
318
+ const destPath = path.join(tempDir, 'backlog', 'test-attach.txt')
319
+ expect(fs.existsSync(destPath)).toBe(true)
320
+
321
+ fs.unlinkSync(srcFile)
322
+ })
323
+
324
+ it('should not duplicate attachment if already present', async () => {
325
+ writeCardFile(tempDir, 'card.md', makeCardContent({ id: 'card' }), 'backlog')
326
+ const srcFile = path.join(os.tmpdir(), 'dup.txt')
327
+ fs.writeFileSync(srcFile, 'data', 'utf-8')
328
+
329
+ await sdk.addAttachment('card', srcFile)
330
+ const updated = await sdk.addAttachment('card', srcFile)
331
+ expect(updated.attachments.filter(a => a === 'dup.txt').length).toBe(1)
332
+
333
+ fs.unlinkSync(srcFile)
334
+ })
335
+
336
+ it('should throw for non-existent card', async () => {
337
+ await expect(sdk.addAttachment('ghost', '/tmp/x.txt')).rejects.toThrow('Card not found')
338
+ })
339
+ })
340
+
341
+ describe('removeAttachment', () => {
342
+ it('should remove attachment from card metadata', async () => {
343
+ writeCardFile(tempDir, 'card.md', makeCardContent({ id: 'card' }), 'backlog')
344
+ const srcFile = path.join(os.tmpdir(), 'rm-me.txt')
345
+ fs.writeFileSync(srcFile, 'data', 'utf-8')
346
+
347
+ await sdk.addAttachment('card', srcFile)
348
+ const updated = await sdk.removeAttachment('card', 'rm-me.txt')
349
+ expect(updated.attachments).not.toContain('rm-me.txt')
350
+
351
+ fs.unlinkSync(srcFile)
352
+ })
353
+
354
+ it('should throw for non-existent card', async () => {
355
+ await expect(sdk.removeAttachment('ghost', 'x.txt')).rejects.toThrow('Card not found')
356
+ })
357
+ })
358
+
359
+ describe('listAttachments', () => {
360
+ it('should return attachments for a card', async () => {
361
+ writeCardFile(tempDir, 'card.md', makeCardContent({ id: 'card' }), 'backlog')
362
+ const srcFile = path.join(os.tmpdir(), 'att.txt')
363
+ fs.writeFileSync(srcFile, 'data', 'utf-8')
364
+
365
+ await sdk.addAttachment('card', srcFile)
366
+ const attachments = await sdk.listAttachments('card')
367
+ expect(attachments).toEqual(['att.txt'])
368
+
369
+ fs.unlinkSync(srcFile)
370
+ })
371
+
372
+ it('should throw for non-existent card', async () => {
373
+ await expect(sdk.listAttachments('ghost')).rejects.toThrow('Card not found')
374
+ })
375
+ })
376
+
377
+ describe('listColumns', () => {
378
+ it('should return default columns when no board.json exists', async () => {
379
+ const columns = await sdk.listColumns()
380
+ expect(columns.length).toBe(5)
381
+ expect(columns[0].id).toBe('backlog')
382
+ expect(columns[4].id).toBe('done')
383
+ })
384
+
385
+ it('should return custom columns from board.json', async () => {
386
+ const config = {
387
+ columns: [
388
+ { id: 'new', name: 'New', color: '#ff0000' },
389
+ { id: 'wip', name: 'WIP', color: '#00ff00' },
390
+ ]
391
+ }
392
+ fs.mkdirSync(tempDir, { recursive: true })
393
+ fs.writeFileSync(path.join(tempDir, 'board.json'), JSON.stringify(config), 'utf-8')
394
+
395
+ const columns = await sdk.listColumns()
396
+ expect(columns.length).toBe(2)
397
+ expect(columns[0].id).toBe('new')
398
+ expect(columns[1].id).toBe('wip')
399
+ })
400
+ })
401
+
402
+ describe('addColumn', () => {
403
+ it('should add a column and persist to board.json', async () => {
404
+ const columns = await sdk.addColumn({ id: 'testing', name: 'Testing', color: '#ff9900' })
405
+ // Default 5 + 1 new
406
+ expect(columns.length).toBe(6)
407
+ expect(columns[5].id).toBe('testing')
408
+
409
+ // Verify persisted
410
+ const raw = fs.readFileSync(path.join(tempDir, 'board.json'), 'utf-8')
411
+ const config = JSON.parse(raw)
412
+ expect(config.columns.length).toBe(6)
413
+ })
414
+
415
+ it('should throw if column ID already exists', async () => {
416
+ await expect(sdk.addColumn({ id: 'backlog', name: 'Backlog 2', color: '#000' }))
417
+ .rejects.toThrow('Column already exists: backlog')
418
+ })
419
+ })
420
+
421
+ describe('updateColumn', () => {
422
+ it('should update column name and color', async () => {
423
+ const columns = await sdk.updateColumn('backlog', { name: 'Inbox', color: '#123456' })
424
+ const updated = columns.find(c => c.id === 'backlog')
425
+ expect(updated?.name).toBe('Inbox')
426
+ expect(updated?.color).toBe('#123456')
427
+ })
428
+
429
+ it('should throw for non-existent column', async () => {
430
+ await expect(sdk.updateColumn('ghost', { name: 'X' })).rejects.toThrow('Column not found')
431
+ })
432
+ })
433
+
434
+ describe('removeColumn', () => {
435
+ it('should remove an empty column', async () => {
436
+ // Add a custom column first, then remove it
437
+ await sdk.addColumn({ id: 'staging', name: 'Staging', color: '#aaa' })
438
+ const columns = await sdk.removeColumn('staging')
439
+ expect(columns.find(c => c.id === 'staging')).toBeUndefined()
440
+ })
441
+
442
+ it('should throw if cards exist in the column', async () => {
443
+ writeCardFile(tempDir, 'card.md', makeCardContent({ id: 'card', status: 'backlog' }), 'backlog')
444
+ await expect(sdk.removeColumn('backlog')).rejects.toThrow('Cannot remove column')
445
+ })
446
+
447
+ it('should throw for non-existent column', async () => {
448
+ await expect(sdk.removeColumn('ghost')).rejects.toThrow('Column not found')
449
+ })
450
+ })
451
+
452
+ describe('reorderColumns', () => {
453
+ it('should reorder columns', async () => {
454
+ const columns = await sdk.reorderColumns(['done', 'review', 'in-progress', 'todo', 'backlog'])
455
+ expect(columns[0].id).toBe('done')
456
+ expect(columns[4].id).toBe('backlog')
457
+ })
458
+
459
+ it('should throw if a column ID is missing', async () => {
460
+ await expect(sdk.reorderColumns(['done', 'review'])).rejects.toThrow('Must include all column IDs')
461
+ })
462
+
463
+ it('should throw for unknown column ID', async () => {
464
+ await expect(sdk.reorderColumns(['done', 'review', 'in-progress', 'todo', 'unknown']))
465
+ .rejects.toThrow('Column not found')
466
+ })
467
+ })
468
+ })
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Feature } from '../../shared/types'
3
+ import { parseFeatureFile, serializeFeature } from '../parser'
4
+
5
+ describe('parseFeatureFile', () => {
6
+ it('should parse a valid feature file', () => {
7
+ const content = `---
8
+ id: "test-feature"
9
+ status: "todo"
10
+ priority: "high"
11
+ assignee: "alice"
12
+ dueDate: "2025-12-31"
13
+ created: "2025-01-01T00:00:00.000Z"
14
+ modified: "2025-01-01T00:00:00.000Z"
15
+ completedAt: null
16
+ labels: ["frontend", "urgent"]
17
+ attachments: ["screenshot.png", "spec.pdf"]
18
+ order: "a0"
19
+ ---
20
+ # Test Feature
21
+
22
+ Some description here.`
23
+
24
+ const feature = parseFeatureFile(content, '/tmp/test-feature.md')
25
+
26
+ expect(feature).not.toBeNull()
27
+ expect(feature?.id).toBe('test-feature')
28
+ expect(feature?.status).toBe('todo')
29
+ expect(feature?.priority).toBe('high')
30
+ expect(feature?.assignee).toBe('alice')
31
+ expect(feature?.dueDate).toBe('2025-12-31')
32
+ expect(feature?.created).toBe('2025-01-01T00:00:00.000Z')
33
+ expect(feature?.completedAt).toBeNull()
34
+ expect(feature?.labels).toEqual(['frontend', 'urgent'])
35
+ expect(feature?.attachments).toEqual(['screenshot.png', 'spec.pdf'])
36
+ expect(feature?.order).toBe('a0')
37
+ expect(feature?.content).toBe('# Test Feature\n\nSome description here.')
38
+ expect(feature?.filePath).toBe('/tmp/test-feature.md')
39
+ })
40
+
41
+ it('should return null for content without frontmatter', () => {
42
+ const result = parseFeatureFile('# Just a heading\nNo frontmatter', '/tmp/no-fm.md')
43
+ expect(result).toBeNull()
44
+ })
45
+
46
+ it('should handle null values correctly', () => {
47
+ const content = `---
48
+ id: "null-test"
49
+ status: "backlog"
50
+ priority: "medium"
51
+ assignee: null
52
+ dueDate: null
53
+ created: "2025-01-01T00:00:00.000Z"
54
+ modified: "2025-01-01T00:00:00.000Z"
55
+ completedAt: null
56
+ labels: []
57
+ order: "a0"
58
+ ---
59
+ # Null Test`
60
+
61
+ const feature = parseFeatureFile(content, '/tmp/null-test.md')
62
+ expect(feature?.assignee).toBeNull()
63
+ expect(feature?.dueDate).toBeNull()
64
+ expect(feature?.completedAt).toBeNull()
65
+ expect(feature?.labels).toEqual([])
66
+ expect(feature?.attachments).toEqual([])
67
+ })
68
+
69
+ it('should handle Windows-style line endings', () => {
70
+ const content = '---\r\nid: "crlf"\r\nstatus: "todo"\r\npriority: "low"\r\nassignee: null\r\ndueDate: null\r\ncreated: "2025-01-01T00:00:00.000Z"\r\nmodified: "2025-01-01T00:00:00.000Z"\r\ncompletedAt: null\r\nlabels: []\r\norder: "a0"\r\n---\r\n# CRLF Test'
71
+
72
+ const feature = parseFeatureFile(content, '/tmp/crlf.md')
73
+ expect(feature).not.toBeNull()
74
+ expect(feature?.id).toBe('crlf')
75
+ })
76
+
77
+ it('should fall back to filename for missing id', () => {
78
+ const content = `---
79
+ status: "backlog"
80
+ priority: "medium"
81
+ assignee: null
82
+ dueDate: null
83
+ created: "2025-01-01T00:00:00.000Z"
84
+ modified: "2025-01-01T00:00:00.000Z"
85
+ completedAt: null
86
+ labels: []
87
+ order: "a0"
88
+ ---
89
+ # No ID`
90
+
91
+ const feature = parseFeatureFile(content, '/tmp/fallback-name.md')
92
+ expect(feature?.id).toBe('fallback-name')
93
+ })
94
+
95
+ it('should default status to backlog and priority to medium', () => {
96
+ const content = `---
97
+ id: "minimal"
98
+ created: "2025-01-01T00:00:00.000Z"
99
+ modified: "2025-01-01T00:00:00.000Z"
100
+ completedAt: null
101
+ labels: []
102
+ order: "a0"
103
+ ---
104
+ # Minimal`
105
+
106
+ const feature = parseFeatureFile(content, '/tmp/minimal.md')
107
+ expect(feature?.status).toBe('backlog')
108
+ expect(feature?.priority).toBe('medium')
109
+ })
110
+ })
111
+
112
+ describe('serializeFeature', () => {
113
+ it('should round-trip parse and serialize', () => {
114
+ const original: Feature = {
115
+ id: 'round-trip',
116
+ status: 'in-progress',
117
+ priority: 'critical',
118
+ assignee: 'bob',
119
+ dueDate: '2025-06-15',
120
+ created: '2025-01-01T00:00:00.000Z',
121
+ modified: '2025-02-01T00:00:00.000Z',
122
+ completedAt: null,
123
+ labels: ['backend', 'api'],
124
+ attachments: ['design.png', 'notes.txt'],
125
+ order: 'a1',
126
+ content: '# Round Trip\n\nTest content.',
127
+ filePath: '/tmp/round-trip.md'
128
+ }
129
+
130
+ const serialized = serializeFeature(original)
131
+ const parsed = parseFeatureFile(serialized, original.filePath)
132
+
133
+ expect(parsed).not.toBeNull()
134
+ expect(parsed?.id).toBe(original.id)
135
+ expect(parsed?.status).toBe(original.status)
136
+ expect(parsed?.priority).toBe(original.priority)
137
+ expect(parsed?.assignee).toBe(original.assignee)
138
+ expect(parsed?.dueDate).toBe(original.dueDate)
139
+ expect(parsed?.completedAt).toBe(original.completedAt)
140
+ expect(parsed?.labels).toEqual(original.labels)
141
+ expect(parsed?.attachments).toEqual(original.attachments)
142
+ expect(parsed?.order).toBe(original.order)
143
+ expect(parsed?.content).toBe(original.content)
144
+ })
145
+
146
+ it('should serialize null fields correctly', () => {
147
+ const feature: Feature = {
148
+ id: 'null-serialize',
149
+ status: 'backlog',
150
+ priority: 'low',
151
+ assignee: null,
152
+ dueDate: null,
153
+ created: '2025-01-01T00:00:00.000Z',
154
+ modified: '2025-01-01T00:00:00.000Z',
155
+ completedAt: null,
156
+ labels: [],
157
+ attachments: [],
158
+ order: 'a0',
159
+ content: '# Null Serialize',
160
+ filePath: '/tmp/null.md'
161
+ }
162
+
163
+ const serialized = serializeFeature(feature)
164
+ expect(serialized).toContain('assignee: null')
165
+ expect(serialized).toContain('dueDate: null')
166
+ expect(serialized).toContain('completedAt: null')
167
+ expect(serialized).toContain('labels: []')
168
+ expect(serialized).toContain('attachments: []')
169
+ })
170
+ })