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,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
|
+
})
|