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.
- package/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- 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
|
+
})
|