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.
- package/adapters/common/__tests__/chat-queue.test.ts +61 -0
- package/adapters/common/__tests__/format.test.ts +148 -0
- package/adapters/common/__tests__/http-client.test.ts +105 -0
- package/adapters/common/__tests__/message-buffer.test.ts +84 -0
- package/adapters/common/__tests__/message-dedup.test.ts +57 -0
- package/adapters/common/__tests__/session-store.test.ts +62 -0
- package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
- package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
- package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
- package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
- package/adapters/feishu/__tests__/feishu.test.ts +907 -0
- package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
- package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
- package/adapters/feishu/__tests__/media.test.ts +120 -0
- package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
- package/adapters/telegram/__tests__/media.test.ts +86 -0
- package/adapters/telegram/__tests__/telegram.test.ts +115 -0
- package/adapters/tsconfig.json +18 -0
- package/bunfig.toml +1 -0
- package/package.json +1 -1
- package/preload.ts +30 -0
- package/scripts/count-app-loc.ts +256 -0
- package/scripts/release.ts +130 -0
- package/src/server/__tests__/conversation-service.test.ts +173 -0
- package/src/server/__tests__/conversations.test.ts +458 -0
- package/src/server/__tests__/cron-scheduler.test.ts +575 -0
- package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
- package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
- package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
- package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
- package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
- package/src/server/__tests__/providers-real.test.ts +244 -0
- package/src/server/__tests__/providers.test.ts +579 -0
- package/src/server/__tests__/proxy-streaming.test.ts +317 -0
- package/src/server/__tests__/proxy-transform.test.ts +469 -0
- package/src/server/__tests__/real-llm-test.ts +526 -0
- package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
- package/src/server/__tests__/sessions.test.ts +786 -0
- package/src/server/__tests__/settings.test.ts +376 -0
- package/src/server/__tests__/skills.test.ts +125 -0
- package/src/server/__tests__/tasks.test.ts +171 -0
- package/src/server/__tests__/team-watcher.test.ts +400 -0
- package/src/server/__tests__/teams.test.ts +627 -0
- package/src/server/middleware/cors.test.ts +27 -0
- package/src/utils/__tests__/cronFrequency.test.ts +153 -0
- package/src/utils/__tests__/cronTasks.test.ts +204 -0
- package/src/utils/computerUse/permissions.test.ts +44 -0
- package/stubs/ant-claude-for-chrome-mcp.ts +24 -0
- package/stubs/color-diff-napi.ts +45 -0
- 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
|
+
})
|