bingocode 1.0.28 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/adapters/common/__tests__/chat-queue.test.ts +61 -0
  2. package/adapters/common/__tests__/format.test.ts +148 -0
  3. package/adapters/common/__tests__/http-client.test.ts +105 -0
  4. package/adapters/common/__tests__/message-buffer.test.ts +84 -0
  5. package/adapters/common/__tests__/message-dedup.test.ts +57 -0
  6. package/adapters/common/__tests__/session-store.test.ts +62 -0
  7. package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
  8. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
  9. package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
  10. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
  11. package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
  12. package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
  13. package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
  14. package/adapters/feishu/__tests__/feishu.test.ts +907 -0
  15. package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
  16. package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
  17. package/adapters/feishu/__tests__/media.test.ts +120 -0
  18. package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
  19. package/adapters/telegram/__tests__/media.test.ts +86 -0
  20. package/adapters/telegram/__tests__/telegram.test.ts +115 -0
  21. package/adapters/tsconfig.json +18 -0
  22. package/bunfig.toml +1 -0
  23. package/package.json +1 -1
  24. package/preload.ts +30 -0
  25. package/scripts/count-app-loc.ts +256 -0
  26. package/scripts/release.ts +130 -0
  27. package/src/server/__tests__/conversation-service.test.ts +173 -0
  28. package/src/server/__tests__/conversations.test.ts +458 -0
  29. package/src/server/__tests__/cron-scheduler.test.ts +575 -0
  30. package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
  31. package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
  32. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
  33. package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
  34. package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
  35. package/src/server/__tests__/providers-real.test.ts +244 -0
  36. package/src/server/__tests__/providers.test.ts +579 -0
  37. package/src/server/__tests__/proxy-streaming.test.ts +317 -0
  38. package/src/server/__tests__/proxy-transform.test.ts +469 -0
  39. package/src/server/__tests__/real-llm-test.ts +526 -0
  40. package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
  41. package/src/server/__tests__/sessions.test.ts +786 -0
  42. package/src/server/__tests__/settings.test.ts +376 -0
  43. package/src/server/__tests__/skills.test.ts +125 -0
  44. package/src/server/__tests__/tasks.test.ts +171 -0
  45. package/src/server/__tests__/team-watcher.test.ts +400 -0
  46. package/src/server/__tests__/teams.test.ts +627 -0
  47. package/src/server/middleware/cors.test.ts +27 -0
  48. package/src/utils/__tests__/cronFrequency.test.ts +153 -0
  49. package/src/utils/__tests__/cronTasks.test.ts +204 -0
  50. package/src/utils/computerUse/permissions.test.ts +44 -0
  51. package/stubs/ant-claude-for-chrome-mcp.ts +24 -0
  52. package/stubs/color-diff-napi.ts +45 -0
  53. package/tsconfig.json +24 -0
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Unit tests for Settings, Models, and Status APIs
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
6
+ import * as fs from 'fs/promises'
7
+ import * as path from 'path'
8
+ import * as os from 'os'
9
+ import { SettingsService } from '../services/settingsService.js'
10
+ import { handleSettingsApi } from '../api/settings.js'
11
+ import { handleModelsApi } from '../api/models.js'
12
+ import { handleStatusApi, resetUsage, addUsage } from '../api/status.js'
13
+
14
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
15
+
16
+ let tmpDir: string
17
+ let originalConfigDir: string | undefined
18
+
19
+ async function setup() {
20
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-'))
21
+ originalConfigDir = process.env.CLAUDE_CONFIG_DIR
22
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
23
+ }
24
+
25
+ async function teardown() {
26
+ if (originalConfigDir !== undefined) {
27
+ process.env.CLAUDE_CONFIG_DIR = originalConfigDir
28
+ } else {
29
+ delete process.env.CLAUDE_CONFIG_DIR
30
+ }
31
+ await fs.rm(tmpDir, { recursive: true, force: true })
32
+ }
33
+
34
+ /** 创建一个模拟 Request */
35
+ function makeRequest(
36
+ method: string,
37
+ urlStr: string,
38
+ body?: Record<string, unknown>,
39
+ ): { req: Request; url: URL; segments: string[] } {
40
+ const url = new URL(urlStr, 'http://localhost:3456')
41
+ const init: RequestInit = { method }
42
+ if (body) {
43
+ init.headers = { 'Content-Type': 'application/json' }
44
+ init.body = JSON.stringify(body)
45
+ }
46
+ const req = new Request(url.toString(), init)
47
+ const segments = url.pathname.split('/').filter(Boolean)
48
+ return { req, url, segments }
49
+ }
50
+
51
+ // =============================================================================
52
+ // SettingsService
53
+ // =============================================================================
54
+
55
+ describe('SettingsService', () => {
56
+ beforeEach(setup)
57
+ afterEach(teardown)
58
+
59
+ it('should return empty object when settings file does not exist', async () => {
60
+ const svc = new SettingsService()
61
+ const settings = await svc.getUserSettings()
62
+ expect(settings).toEqual({})
63
+ })
64
+
65
+ it('should write and read user settings', async () => {
66
+ const svc = new SettingsService()
67
+ await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-7' })
68
+
69
+ const settings = await svc.getUserSettings()
70
+ expect(settings.theme).toBe('dark')
71
+ expect(settings.model).toBe('claude-opus-4-7')
72
+ })
73
+
74
+ it('should merge settings on update (shallow merge)', async () => {
75
+ const svc = new SettingsService()
76
+ await svc.updateUserSettings({ theme: 'dark' })
77
+ await svc.updateUserSettings({ model: 'claude-haiku-4-5' })
78
+
79
+ const settings = await svc.getUserSettings()
80
+ expect(settings.theme).toBe('dark')
81
+ expect(settings.model).toBe('claude-haiku-4-5')
82
+ })
83
+
84
+ it('should read and write project settings', async () => {
85
+ const projectRoot = path.join(tmpDir, 'myproject')
86
+ await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
87
+
88
+ const svc = new SettingsService(projectRoot)
89
+ await svc.updateProjectSettings({ outputStyle: 'verbose' })
90
+
91
+ const settings = await svc.getProjectSettings()
92
+ expect(settings.outputStyle).toBe('verbose')
93
+ })
94
+
95
+ it('should merge user and project settings', async () => {
96
+ const projectRoot = path.join(tmpDir, 'myproject')
97
+ await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
98
+
99
+ const svc = new SettingsService(projectRoot)
100
+ await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-7' })
101
+ await svc.updateProjectSettings({ theme: 'light' })
102
+
103
+ const merged = await svc.getSettings()
104
+ // project overrides user
105
+ expect(merged.theme).toBe('light')
106
+ // user value preserved when not overridden
107
+ expect(merged.model).toBe('claude-opus-4-7')
108
+ })
109
+
110
+ it('should get default permission mode', async () => {
111
+ const svc = new SettingsService()
112
+ const mode = await svc.getPermissionMode()
113
+ expect(mode).toBe('default')
114
+ })
115
+
116
+ it('should set and get permission mode', async () => {
117
+ const svc = new SettingsService()
118
+ await svc.setPermissionMode('plan')
119
+ const mode = await svc.getPermissionMode()
120
+ expect(mode).toBe('plan')
121
+ })
122
+
123
+ it('should reject invalid permission mode', async () => {
124
+ const svc = new SettingsService()
125
+ await expect(svc.setPermissionMode('invalid')).rejects.toThrow('Invalid permission mode')
126
+ })
127
+
128
+ it('should preserve other settings when updating permission mode', async () => {
129
+ const svc = new SettingsService()
130
+ await svc.updateUserSettings({ theme: 'dark' })
131
+ await svc.setPermissionMode('acceptEdits')
132
+
133
+ const settings = await svc.getUserSettings()
134
+ expect(settings.theme).toBe('dark')
135
+ expect(settings.defaultMode).toBe('acceptEdits')
136
+ })
137
+ })
138
+
139
+ // =============================================================================
140
+ // Settings API
141
+ // =============================================================================
142
+
143
+ describe('Settings API', () => {
144
+ beforeEach(setup)
145
+ afterEach(teardown)
146
+
147
+ it('GET /api/settings should return merged settings', async () => {
148
+ // Seed some user settings
149
+ const settingsPath = path.join(tmpDir, 'settings.json')
150
+ await fs.writeFile(settingsPath, JSON.stringify({ theme: 'dark' }))
151
+
152
+ const { req, url, segments } = makeRequest('GET', '/api/settings')
153
+ const res = await handleSettingsApi(req, url, segments)
154
+
155
+ expect(res.status).toBe(200)
156
+ const body = await res.json()
157
+ expect(body.theme).toBe('dark')
158
+ })
159
+
160
+ it('GET /api/settings/user should return user settings', async () => {
161
+ const { req, url, segments } = makeRequest('GET', '/api/settings/user')
162
+ const res = await handleSettingsApi(req, url, segments)
163
+
164
+ expect(res.status).toBe(200)
165
+ const body = await res.json()
166
+ expect(body).toEqual({})
167
+ })
168
+
169
+ it('PUT /api/settings/user should update user settings', async () => {
170
+ const { req, url, segments } = makeRequest('PUT', '/api/settings/user', {
171
+ model: 'claude-opus-4-7',
172
+ })
173
+ const res = await handleSettingsApi(req, url, segments)
174
+
175
+ expect(res.status).toBe(200)
176
+ const body = await res.json()
177
+ expect(body.ok).toBe(true)
178
+
179
+ // Verify persisted
180
+ const { req: r2, url: u2, segments: s2 } = makeRequest('GET', '/api/settings/user')
181
+ const res2 = await handleSettingsApi(r2, u2, s2)
182
+ const body2 = await res2.json()
183
+ expect(body2.model).toBe('claude-opus-4-7')
184
+ })
185
+
186
+ it('GET /api/permissions/mode should return default mode', async () => {
187
+ const { req, url, segments } = makeRequest('GET', '/api/permissions/mode')
188
+ const res = await handleSettingsApi(req, url, segments)
189
+
190
+ expect(res.status).toBe(200)
191
+ const body = await res.json()
192
+ expect(body.mode).toBe('default')
193
+ })
194
+
195
+ it('PUT /api/permissions/mode should set mode', async () => {
196
+ const { req, url, segments } = makeRequest('PUT', '/api/permissions/mode', {
197
+ mode: 'bypassPermissions',
198
+ })
199
+ const res = await handleSettingsApi(req, url, segments)
200
+
201
+ expect(res.status).toBe(200)
202
+ const body = await res.json()
203
+ expect(body.ok).toBe(true)
204
+ expect(body.mode).toBe('bypassPermissions')
205
+ })
206
+
207
+ it('PUT /api/permissions/mode should reject invalid mode', async () => {
208
+ const { req, url, segments } = makeRequest('PUT', '/api/permissions/mode', {
209
+ mode: 'yolo',
210
+ })
211
+ const res = await handleSettingsApi(req, url, segments)
212
+
213
+ expect(res.status).toBe(400)
214
+ })
215
+
216
+ it('should return 404 for unknown settings endpoint', async () => {
217
+ const { req, url, segments } = makeRequest('GET', '/api/settings/unknown')
218
+ const res = await handleSettingsApi(req, url, segments)
219
+ expect(res.status).toBe(404)
220
+ })
221
+ })
222
+
223
+ // =============================================================================
224
+ // Models API
225
+ // =============================================================================
226
+
227
+ describe('Models API', () => {
228
+ beforeEach(setup)
229
+ afterEach(teardown)
230
+
231
+ it('GET /api/models should return available models', async () => {
232
+ const { req, url, segments } = makeRequest('GET', '/api/models')
233
+ const res = await handleModelsApi(req, url, segments)
234
+
235
+ expect(res.status).toBe(200)
236
+ const body = await res.json()
237
+ expect(body.models).toBeArray()
238
+ expect(body.models.length).toBe(4)
239
+ expect(body.models[0].id).toContain('claude')
240
+ })
241
+
242
+ it('GET /api/models/current should return default model when not set', async () => {
243
+ const { req, url, segments } = makeRequest('GET', '/api/models/current')
244
+ const res = await handleModelsApi(req, url, segments)
245
+
246
+ expect(res.status).toBe(200)
247
+ const body = await res.json()
248
+ expect(body.model.id).toBe('claude-sonnet-4-6')
249
+ })
250
+
251
+ it('PUT /api/models/current should switch model', async () => {
252
+ const { req, url, segments } = makeRequest('PUT', '/api/models/current', {
253
+ modelId: 'claude-opus-4-7',
254
+ })
255
+ const res = await handleModelsApi(req, url, segments)
256
+
257
+ expect(res.status).toBe(200)
258
+ const body = await res.json()
259
+ expect(body.ok).toBe(true)
260
+ expect(body.model).toBe('claude-opus-4-7')
261
+
262
+ // Verify persisted
263
+ const { req: r2, url: u2, segments: s2 } = makeRequest('GET', '/api/models/current')
264
+ const res2 = await handleModelsApi(r2, u2, s2)
265
+ const body2 = await res2.json()
266
+ expect(body2.model.id).toBe('claude-opus-4-7')
267
+ })
268
+
269
+ it('PUT /api/models/current should reject missing modelId', async () => {
270
+ const { req, url, segments } = makeRequest('PUT', '/api/models/current', {})
271
+ const res = await handleModelsApi(req, url, segments)
272
+ expect(res.status).toBe(400)
273
+ })
274
+
275
+ it('GET /api/effort should return default effort level', async () => {
276
+ const { req, url, segments } = makeRequest('GET', '/api/effort')
277
+ const res = await handleModelsApi(req, url, segments)
278
+
279
+ expect(res.status).toBe(200)
280
+ const body = await res.json()
281
+ expect(body.level).toBe('medium')
282
+ expect(body.available).toEqual(['low', 'medium', 'high', 'max'])
283
+ })
284
+
285
+ it('PUT /api/effort should set effort level', async () => {
286
+ const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'high' })
287
+ const res = await handleModelsApi(req, url, segments)
288
+
289
+ expect(res.status).toBe(200)
290
+ const body = await res.json()
291
+ expect(body.ok).toBe(true)
292
+ expect(body.level).toBe('high')
293
+ })
294
+
295
+ it('PUT /api/effort should reject invalid level', async () => {
296
+ const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'turbo' })
297
+ const res = await handleModelsApi(req, url, segments)
298
+ expect(res.status).toBe(400)
299
+ })
300
+
301
+ it('should return 404 for unknown models endpoint', async () => {
302
+ const { req, url, segments } = makeRequest('GET', '/api/models/unknown')
303
+ const res = await handleModelsApi(req, url, segments)
304
+ expect(res.status).toBe(404)
305
+ })
306
+ })
307
+
308
+ // =============================================================================
309
+ // Status API
310
+ // =============================================================================
311
+
312
+ describe('Status API', () => {
313
+ beforeEach(async () => {
314
+ await setup()
315
+ resetUsage()
316
+ })
317
+ afterEach(teardown)
318
+
319
+ it('GET /api/status should return health check', async () => {
320
+ const { req, url, segments } = makeRequest('GET', '/api/status')
321
+ const res = await handleStatusApi(req, url, segments)
322
+
323
+ expect(res.status).toBe(200)
324
+ const body = await res.json()
325
+ expect(body.status).toBe('ok')
326
+ expect(body.version).toBeDefined()
327
+ expect(body.uptime).toBeGreaterThanOrEqual(0)
328
+ })
329
+
330
+ it('GET /api/status/diagnostics should return system info', async () => {
331
+ const { req, url, segments } = makeRequest('GET', '/api/status/diagnostics')
332
+ const res = await handleStatusApi(req, url, segments)
333
+
334
+ expect(res.status).toBe(200)
335
+ const body = await res.json()
336
+ expect(body.platform).toBeDefined()
337
+ expect(body.arch).toBeDefined()
338
+ expect(body.configDir).toBeDefined()
339
+ })
340
+
341
+ it('GET /api/status/usage should return token usage', async () => {
342
+ addUsage(100, 50, 0.005)
343
+ addUsage(200, 100, 0.01)
344
+
345
+ const { req, url, segments } = makeRequest('GET', '/api/status/usage')
346
+ const res = await handleStatusApi(req, url, segments)
347
+
348
+ expect(res.status).toBe(200)
349
+ const body = await res.json()
350
+ expect(body.totalInputTokens).toBe(300)
351
+ expect(body.totalOutputTokens).toBe(150)
352
+ expect(body.totalCost).toBeCloseTo(0.015)
353
+ })
354
+
355
+ it('GET /api/status/user should return user info', async () => {
356
+ const { req, url, segments } = makeRequest('GET', '/api/status/user')
357
+ const res = await handleStatusApi(req, url, segments)
358
+
359
+ expect(res.status).toBe(200)
360
+ const body = await res.json()
361
+ expect(body.configDir).toBe(tmpDir)
362
+ expect(body.projects).toBeArray()
363
+ })
364
+
365
+ it('should reject non-GET methods', async () => {
366
+ const { req, url, segments } = makeRequest('POST', '/api/status')
367
+ const res = await handleStatusApi(req, url, segments)
368
+ expect(res.status).toBe(405)
369
+ })
370
+
371
+ it('should return 404 for unknown status endpoint', async () => {
372
+ const { req, url, segments } = makeRequest('GET', '/api/status/nonexistent')
373
+ const res = await handleStatusApi(req, url, segments)
374
+ expect(res.status).toBe(404)
375
+ })
376
+ })
@@ -0,0 +1,125 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import { getCwdState, setCwdState } from '../../bootstrap/state.js'
6
+ import { handleSkillsApi } from '../api/skills.js'
7
+
8
+ let tmpHome: string
9
+ let originalHome: string | undefined
10
+ let originalUserProfile: string | undefined
11
+ let originalClaudeConfigDir: string | undefined
12
+ let originalCwdState: string
13
+
14
+ function makeRequest(urlStr: string): { req: Request; url: URL; segments: string[] } {
15
+ const url = new URL(urlStr, 'http://localhost:3456')
16
+ const req = new Request(url.toString(), { method: 'GET' })
17
+ return {
18
+ req,
19
+ url,
20
+ segments: url.pathname.split('/').filter(Boolean),
21
+ }
22
+ }
23
+
24
+ async function writeSkill(root: string, skillName: string, content: string): Promise<void> {
25
+ const skillDir = path.join(root, skillName)
26
+ await fs.mkdir(skillDir, { recursive: true })
27
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8')
28
+ }
29
+
30
+ describe('Skills API', () => {
31
+ beforeEach(async () => {
32
+ tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-skills-test-'))
33
+ originalHome = process.env.HOME
34
+ originalUserProfile = process.env.USERPROFILE
35
+ originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
36
+ originalCwdState = getCwdState()
37
+
38
+ process.env.HOME = tmpHome
39
+ process.env.USERPROFILE = tmpHome
40
+ process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude')
41
+ setCwdState(tmpHome)
42
+ })
43
+
44
+ afterEach(async () => {
45
+ if (originalHome === undefined) {
46
+ delete process.env.HOME
47
+ } else {
48
+ process.env.HOME = originalHome
49
+ }
50
+
51
+ if (originalUserProfile === undefined) {
52
+ delete process.env.USERPROFILE
53
+ } else {
54
+ process.env.USERPROFILE = originalUserProfile
55
+ }
56
+
57
+ if (originalClaudeConfigDir === undefined) {
58
+ delete process.env.CLAUDE_CONFIG_DIR
59
+ } else {
60
+ process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
61
+ }
62
+
63
+ setCwdState(originalCwdState)
64
+ await fs.rm(tmpHome, { recursive: true, force: true })
65
+ })
66
+
67
+ it('lists user and project skills for the requested cwd', async () => {
68
+ const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
69
+ const projectRoot = path.join(tmpHome, 'workspace')
70
+ const cwd = path.join(projectRoot, 'packages', 'app')
71
+
72
+ await writeSkill(
73
+ userSkillsRoot,
74
+ 'user-skill',
75
+ ['---', 'description: User scope', '---', '', '# User skill'].join('\n'),
76
+ )
77
+ await writeSkill(
78
+ path.join(projectRoot, '.claude', 'skills'),
79
+ 'project-skill',
80
+ ['---', 'description: Project scope', '---', '', '# Project skill'].join('\n'),
81
+ )
82
+
83
+ const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
84
+ const res = await handleSkillsApi(req, url, segments)
85
+
86
+ expect(res.status).toBe(200)
87
+ const body = await res.json() as { skills: Array<{ name: string; source: string }> }
88
+ expect(body.skills).toContainEqual(expect.objectContaining({ name: 'user-skill', source: 'user' }))
89
+ expect(body.skills).toContainEqual(expect.objectContaining({ name: 'project-skill', source: 'project' }))
90
+ })
91
+
92
+ it('resolves project skill details from the nearest project skills directory', async () => {
93
+ const projectRoot = path.join(tmpHome, 'workspace')
94
+ const nestedRoot = path.join(projectRoot, 'packages', 'app')
95
+ const nestedSkillsRoot = path.join(nestedRoot, '.claude', 'skills')
96
+ const parentSkillsRoot = path.join(projectRoot, '.claude', 'skills')
97
+
98
+ await writeSkill(
99
+ parentSkillsRoot,
100
+ 'shared-skill',
101
+ ['---', 'description: Parent version', '---', '', 'parent body'].join('\n'),
102
+ )
103
+ await writeSkill(
104
+ nestedSkillsRoot,
105
+ 'shared-skill',
106
+ ['---', 'description: Child version', '---', '', 'child body'].join('\n'),
107
+ )
108
+
109
+ const { req, url, segments } = makeRequest(
110
+ `/api/skills/detail?source=project&name=shared-skill&cwd=${encodeURIComponent(nestedRoot)}`,
111
+ )
112
+ const res = await handleSkillsApi(req, url, segments)
113
+
114
+ expect(res.status).toBe(200)
115
+ const body = await res.json() as {
116
+ detail: { meta: { description: string }; skillRoot: string; files: Array<{ path: string; body?: string }> }
117
+ }
118
+
119
+ expect(body.detail.meta.description).toBe('Child version')
120
+ expect(body.detail.skillRoot).toBe(path.join(nestedSkillsRoot, 'shared-skill'))
121
+ expect(body.detail.files).toContainEqual(
122
+ expect.objectContaining({ path: 'SKILL.md', body: 'child body' }),
123
+ )
124
+ })
125
+ })
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Unit tests for TaskService and Tasks API
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
6
+ import * as fs from 'fs/promises'
7
+ import * as path from 'path'
8
+ import * as os from 'os'
9
+ import { TaskService } from '../services/taskService.js'
10
+
11
+ // ============================================================================
12
+ // TaskService unit tests
13
+ // ============================================================================
14
+
15
+ describe('TaskService', () => {
16
+ let tmpDir: string
17
+
18
+ beforeEach(async () => {
19
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-'))
20
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
21
+ })
22
+
23
+ afterEach(async () => {
24
+ await fs.rm(tmpDir, { recursive: true, force: true })
25
+ delete process.env.CLAUDE_CONFIG_DIR
26
+ })
27
+
28
+ it('should return empty list when no tasks dir', async () => {
29
+ const svc = new TaskService()
30
+ const tasks = await svc.listTasks()
31
+ expect(tasks).toEqual([])
32
+ })
33
+
34
+ it('should list tasks from JSON files', async () => {
35
+ const tasksDir = path.join(tmpDir, 'tasks')
36
+ await fs.mkdir(tasksDir, { recursive: true })
37
+
38
+ await fs.writeFile(path.join(tasksDir, 'task-001.json'), JSON.stringify({
39
+ id: 'task-001',
40
+ type: 'local_agent',
41
+ status: 'completed',
42
+ name: 'code-review',
43
+ description: 'Review PR #42',
44
+ createdAt: Date.now() - 60000,
45
+ completedAt: Date.now(),
46
+ }))
47
+
48
+ await fs.writeFile(path.join(tasksDir, 'task-002.json'), JSON.stringify({
49
+ id: 'task-002',
50
+ type: 'in_process_teammate',
51
+ status: 'running',
52
+ name: 'frontend-dev',
53
+ teamName: 'ui-team',
54
+ createdAt: Date.now(),
55
+ }))
56
+
57
+ const svc = new TaskService()
58
+ const tasks = await svc.listTasks()
59
+ expect(tasks.length).toBe(2)
60
+ // 按 createdAt 倒序
61
+ expect(tasks[0].id).toBe('task-002')
62
+ expect(tasks[1].id).toBe('task-001')
63
+ })
64
+
65
+ it('should scan nested team task directories', async () => {
66
+ const teamDir = path.join(tmpDir, 'tasks', 'my-team')
67
+ await fs.mkdir(teamDir, { recursive: true })
68
+
69
+ await fs.writeFile(path.join(teamDir, 'member-1.json'), JSON.stringify({
70
+ id: 'member-1',
71
+ type: 'in_process_teammate',
72
+ status: 'completed',
73
+ teamName: 'my-team',
74
+ }))
75
+
76
+ const svc = new TaskService()
77
+ const tasks = await svc.listTasks()
78
+ expect(tasks.length).toBe(1)
79
+ expect(tasks[0].teamName).toBe('my-team')
80
+ })
81
+
82
+ it('should get single task by ID', async () => {
83
+ const tasksDir = path.join(tmpDir, 'tasks')
84
+ await fs.mkdir(tasksDir, { recursive: true })
85
+
86
+ await fs.writeFile(path.join(tasksDir, 'abc.json'), JSON.stringify({
87
+ id: 'abc',
88
+ type: 'local_shell',
89
+ status: 'failed',
90
+ name: 'build',
91
+ }))
92
+
93
+ const svc = new TaskService()
94
+ const task = await svc.getTask('abc')
95
+ expect(task).toBeDefined()
96
+ expect(task!.status).toBe('failed')
97
+ })
98
+
99
+ it('should return null for unknown task', async () => {
100
+ const svc = new TaskService()
101
+ const task = await svc.getTask('nonexistent')
102
+ expect(task).toBeNull()
103
+ })
104
+
105
+ it('should skip invalid JSON files gracefully', async () => {
106
+ const tasksDir = path.join(tmpDir, 'tasks')
107
+ await fs.mkdir(tasksDir, { recursive: true })
108
+ await fs.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{')
109
+
110
+ const svc = new TaskService()
111
+ const tasks = await svc.listTasks()
112
+ expect(tasks).toEqual([])
113
+ })
114
+ })
115
+
116
+ // ============================================================================
117
+ // Tasks API integration tests
118
+ // ============================================================================
119
+
120
+ describe('Tasks API', () => {
121
+ let server: any
122
+ let baseUrl: string
123
+ let tmpDir: string
124
+
125
+ beforeEach(async () => {
126
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-api-'))
127
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
128
+ await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
129
+
130
+ const port = 15500 + Math.floor(Math.random() * 500)
131
+ const { startServer } = await import('../../server/index.js')
132
+ server = startServer(port, '127.0.0.1')
133
+ baseUrl = `http://127.0.0.1:${port}`
134
+ })
135
+
136
+ afterEach(async () => {
137
+ server?.stop()
138
+ await fs.rm(tmpDir, { recursive: true, force: true })
139
+ delete process.env.CLAUDE_CONFIG_DIR
140
+ })
141
+
142
+ it('should return empty tasks list', async () => {
143
+ const res = await fetch(`${baseUrl}/api/tasks`)
144
+ const data = await res.json()
145
+ expect(res.status).toBe(200)
146
+ expect(data.tasks).toEqual([])
147
+ })
148
+
149
+ it('should return tasks when files exist', async () => {
150
+ const tasksDir = path.join(tmpDir, 'tasks')
151
+ await fs.mkdir(tasksDir, { recursive: true })
152
+ await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify({
153
+ id: 'test', type: 'local_agent', status: 'completed', name: 'test-task',
154
+ }))
155
+
156
+ const res = await fetch(`${baseUrl}/api/tasks`)
157
+ const data = await res.json()
158
+ expect(data.tasks.length).toBe(1)
159
+ expect(data.tasks[0].name).toBe('test-task')
160
+ })
161
+
162
+ it('should return 404 for unknown task', async () => {
163
+ const res = await fetch(`${baseUrl}/api/tasks/nonexistent`)
164
+ expect(res.status).toBe(404)
165
+ })
166
+
167
+ it('should reject non-GET methods', async () => {
168
+ const res = await fetch(`${baseUrl}/api/tasks`, { method: 'POST' })
169
+ expect(res.status).toBe(405)
170
+ })
171
+ })