bingocode 1.0.27 → 1.0.29

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 (54) hide show
  1. package/package.json +1 -2
  2. package/.github/FUNDING.yml +0 -1
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -44
  4. package/.github/ISSUE_TEMPLATE/config.yml +0 -1
  5. package/.github/ISSUE_TEMPLATE/question.md +0 -40
  6. package/.github/workflows/build-desktop-dev.yml +0 -210
  7. package/.github/workflows/deploy-docs.yml +0 -59
  8. package/.github/workflows/release-desktop.yml +0 -162
  9. package/.spine/user.yaml +0 -5
  10. package/.spine/workspace.yaml +0 -1
  11. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  12. package/adapters/common/__tests__/format.test.ts +0 -148
  13. package/adapters/common/__tests__/http-client.test.ts +0 -105
  14. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  15. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  16. package/adapters/common/__tests__/session-store.test.ts +0 -62
  17. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  18. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  19. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  20. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  21. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  22. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  23. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  24. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  25. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  26. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  27. package/adapters/feishu/__tests__/media.test.ts +0 -120
  28. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  29. package/adapters/telegram/__tests__/media.test.ts +0 -86
  30. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  31. package/src/server/__tests__/conversation-service.test.ts +0 -173
  32. package/src/server/__tests__/conversations.test.ts +0 -458
  33. package/src/server/__tests__/cron-scheduler.test.ts +0 -575
  34. package/src/server/__tests__/e2e/business-flow.test.ts +0 -841
  35. package/src/server/__tests__/e2e/full-flow.test.ts +0 -357
  36. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +0 -123
  37. package/src/server/__tests__/haha-oauth-api.test.ts +0 -146
  38. package/src/server/__tests__/haha-oauth-service.test.ts +0 -185
  39. package/src/server/__tests__/providers-real.test.ts +0 -244
  40. package/src/server/__tests__/providers.test.ts +0 -579
  41. package/src/server/__tests__/proxy-streaming.test.ts +0 -317
  42. package/src/server/__tests__/proxy-transform.test.ts +0 -469
  43. package/src/server/__tests__/real-llm-test.ts +0 -526
  44. package/src/server/__tests__/scheduled-tasks.test.ts +0 -371
  45. package/src/server/__tests__/sessions.test.ts +0 -786
  46. package/src/server/__tests__/settings.test.ts +0 -376
  47. package/src/server/__tests__/skills.test.ts +0 -125
  48. package/src/server/__tests__/tasks.test.ts +0 -171
  49. package/src/server/__tests__/team-watcher.test.ts +0 -400
  50. package/src/server/__tests__/teams.test.ts +0 -627
  51. package/src/server/middleware/cors.test.ts +0 -27
  52. package/src/utils/__tests__/cronFrequency.test.ts +0 -153
  53. package/src/utils/__tests__/cronTasks.test.ts +0 -204
  54. package/src/utils/computerUse/permissions.test.ts +0 -44
@@ -1,907 +0,0 @@
1
- /**
2
- * 飞书 Adapter 翻译逻辑测试
3
- *
4
- * 不启动真实 Bot,只测试事件解析和消息翻译逻辑。
5
- */
6
-
7
- import { describe, it, expect } from 'bun:test'
8
- import * as path from 'node:path'
9
-
10
- // ---------- helpers extracted from feishu/index.ts for testability ----------
11
-
12
- function extractText(content: string, msgType: string): string | null {
13
- try {
14
- const parsed = JSON.parse(content)
15
- if (msgType === 'text') {
16
- return parsed.text ?? null
17
- }
18
- if (msgType === 'post') {
19
- const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
20
- return zhContent
21
- .flat()
22
- .filter((n: any) => n.tag === 'text' || n.tag === 'md')
23
- .map((n: any) => n.text ?? n.content ?? '')
24
- .join('')
25
- .trim() || null
26
- }
27
- return null
28
- } catch {
29
- return null
30
- }
31
- }
32
-
33
- function isBotMentioned(
34
- mentions: Array<{ id?: { open_id?: string } }> | undefined,
35
- botOpenId: string,
36
- ): boolean {
37
- if (!mentions || !botOpenId) return false
38
- return mentions.some((m) => m.id?.open_id === botOpenId)
39
- }
40
-
41
- function stripMentions(text: string): string {
42
- return text.replace(/@_user_\d+/g, '').trim()
43
- }
44
-
45
- type RecentProject = {
46
- projectPath: string
47
- realPath: string
48
- projectName: string
49
- isGit: boolean
50
- repoName: string | null
51
- branch: string | null
52
- modifiedAt: string
53
- sessionCount: number
54
- }
55
-
56
- function prettyPath(realPath: string, maxLen = 64): string {
57
- const home = process.env.HOME
58
- let p = realPath
59
- if (home) {
60
- if (p === home) return '~'
61
- if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
62
- }
63
- if (p.length <= maxLen) return p
64
- const tailLen = Math.floor(maxLen * 0.65)
65
- const headLen = maxLen - tailLen - 1
66
- return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
67
- }
68
-
69
- function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
70
- const items = projects.slice(0, 10)
71
- const total = projects.length
72
- const subtitleText =
73
- total > items.length
74
- ? `共 ${total} 个最近项目,显示前 ${items.length}`
75
- : `共 ${total} 个最近项目`
76
-
77
- const rows = items.map((p, i) => {
78
- const branch = p.branch ? ` · *${p.branch}*` : ''
79
- return {
80
- tag: 'column_set',
81
- flex_mode: 'stretch',
82
- horizontal_spacing: '8px',
83
- margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
84
- columns: [
85
- {
86
- tag: 'column',
87
- width: 'weighted',
88
- weight: 1,
89
- vertical_align: 'center',
90
- elements: [
91
- {
92
- tag: 'markdown',
93
- content: `**${p.projectName}**${branch}`,
94
- },
95
- {
96
- tag: 'markdown',
97
- content: prettyPath(p.realPath, 56),
98
- text_size: 'notation',
99
- margin: '2px 0 0 0',
100
- },
101
- ],
102
- },
103
- {
104
- tag: 'column',
105
- width: 'auto',
106
- vertical_align: 'center',
107
- elements: [
108
- {
109
- tag: 'button',
110
- text: { tag: 'plain_text', content: '选择' },
111
- type: i === 0 ? 'primary' : 'default',
112
- size: 'small',
113
- value: {
114
- action: 'pick_project',
115
- realPath: p.realPath,
116
- projectName: p.projectName,
117
- },
118
- },
119
- ],
120
- },
121
- ],
122
- }
123
- })
124
-
125
- return {
126
- schema: '2.0',
127
- config: {
128
- wide_screen_mode: true,
129
- update_multi: true,
130
- },
131
- header: {
132
- title: { tag: 'plain_text', content: '📁 选择项目' },
133
- subtitle: { tag: 'plain_text', content: subtitleText },
134
- template: 'blue',
135
- },
136
- body: {
137
- elements: [
138
- ...rows,
139
- { tag: 'hr', margin: '14px 0 0 0' },
140
- {
141
- tag: 'markdown',
142
- content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
143
- text_size: 'notation',
144
- margin: '6px 0 0 0',
145
- },
146
- ],
147
- },
148
- }
149
- }
150
-
151
- // ---------- permission card helpers (mirrored from feishu/index.ts) ----------
152
-
153
- type ToolCallSummary = {
154
- icon: string
155
- label: string
156
- target?: string
157
- filePath?: string
158
- }
159
-
160
- function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
161
- const rec: Record<string, unknown> =
162
- input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
163
- const str = (key: string): string | undefined =>
164
- typeof rec[key] === 'string' ? (rec[key] as string) : undefined
165
-
166
- switch (toolName) {
167
- case 'Write': {
168
- const fp = str('file_path')
169
- return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
170
- }
171
- case 'Edit':
172
- case 'MultiEdit':
173
- case 'NotebookEdit': {
174
- const fp = str('file_path') ?? str('notebook_path')
175
- return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
176
- }
177
- case 'Read': {
178
- const fp = str('file_path')
179
- return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
180
- }
181
- case 'Bash':
182
- case 'BashOutput': {
183
- return { icon: '🖥️', label: '执行命令', target: str('command') }
184
- }
185
- case 'Grep': {
186
- const pattern = str('pattern')
187
- return {
188
- icon: '🔍',
189
- label: '搜索内容',
190
- target: pattern ? `pattern: ${pattern}` : undefined,
191
- filePath: str('path'),
192
- }
193
- }
194
- case 'Glob': {
195
- const pattern = str('pattern')
196
- return {
197
- icon: '📁',
198
- label: '查找文件',
199
- target: pattern ? `pattern: ${pattern}` : undefined,
200
- filePath: str('path'),
201
- }
202
- }
203
- case 'WebFetch':
204
- return { icon: '🌐', label: '访问网页', target: str('url') }
205
- case 'WebSearch':
206
- return { icon: '🌐', label: '搜索网页', target: str('query') }
207
- default:
208
- return { icon: '🔧', label: toolName }
209
- }
210
- }
211
-
212
- function isOutsideWorkDir(filePath: string, workDir: string): boolean {
213
- const abs = path.isAbsolute(filePath)
214
- ? path.normalize(filePath)
215
- : path.resolve(workDir, filePath)
216
- const normWork = path.normalize(workDir).replace(/\/+$/, '')
217
- return abs !== normWork && !abs.startsWith(normWork + path.sep)
218
- }
219
-
220
- function truncateTarget(s: string, maxLen = 160): string {
221
- if (s.length <= maxLen) return s
222
- return s.slice(0, maxLen - 1) + '…'
223
- }
224
-
225
- function buildPermissionCard(
226
- toolName: string,
227
- input: unknown,
228
- requestId: string,
229
- workDir?: string,
230
- ): Record<string, unknown> {
231
- const summary = summarizeToolCall(toolName, input)
232
- const crossDir = Boolean(
233
- workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
234
- )
235
-
236
- const elements: Record<string, unknown>[] = [
237
- {
238
- tag: 'markdown',
239
- content: `${summary.icon} **${summary.label}** \`${toolName}\``,
240
- },
241
- ]
242
-
243
- if (summary.target) {
244
- const shown = summary.filePath
245
- ? prettyPath(summary.target, 80)
246
- : truncateTarget(summary.target, 160)
247
- elements.push({
248
- tag: 'markdown',
249
- content: '```\n' + shown + '\n```',
250
- margin: '4px 0 0 0',
251
- })
252
- }
253
-
254
- if (crossDir) {
255
- elements.push({
256
- tag: 'markdown',
257
- content: '⚠️ **该操作位于当前项目目录之外**',
258
- margin: '8px 0 0 0',
259
- text_size: 'notation',
260
- })
261
- }
262
-
263
- elements.push({ tag: 'hr', margin: '12px 0 0 0' })
264
-
265
- elements.push({
266
- tag: 'column_set',
267
- flex_mode: 'stretch',
268
- horizontal_spacing: '8px',
269
- margin: '8px 0 0 0',
270
- columns: [
271
- {
272
- tag: 'column',
273
- width: 'weighted',
274
- weight: 1,
275
- vertical_align: 'center',
276
- elements: [
277
- {
278
- tag: 'button',
279
- text: { tag: 'plain_text', content: '✅ 允许' },
280
- type: 'primary',
281
- size: 'medium',
282
- value: { action: 'permit', requestId, allowed: true },
283
- },
284
- ],
285
- },
286
- {
287
- tag: 'column',
288
- width: 'weighted',
289
- weight: 1,
290
- vertical_align: 'center',
291
- elements: [
292
- {
293
- tag: 'button',
294
- text: { tag: 'plain_text', content: '♾️ 永久允许' },
295
- type: 'default',
296
- size: 'medium',
297
- value: { action: 'permit', requestId, allowed: true, rule: 'always' },
298
- },
299
- ],
300
- },
301
- {
302
- tag: 'column',
303
- width: 'weighted',
304
- weight: 1,
305
- vertical_align: 'center',
306
- elements: [
307
- {
308
- tag: 'button',
309
- text: { tag: 'plain_text', content: '❌ 拒绝' },
310
- type: 'danger',
311
- size: 'medium',
312
- value: { action: 'permit', requestId, allowed: false },
313
- },
314
- ],
315
- },
316
- ],
317
- })
318
-
319
- return {
320
- schema: '2.0',
321
- config: {
322
- wide_screen_mode: false,
323
- update_multi: true,
324
- },
325
- header: {
326
- title: { tag: 'plain_text', content: '🔐 需要权限确认' },
327
- subtitle: {
328
- tag: 'plain_text',
329
- content: crossDir ? '⚠️ 跨目录操作' : toolName,
330
- },
331
- template: crossDir ? 'red' : 'orange',
332
- padding: '12px 12px 12px 12px',
333
- icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
334
- },
335
- body: { elements },
336
- }
337
- }
338
-
339
- // ---------- tests ----------
340
-
341
- describe('Feishu: event parsing', () => {
342
- describe('extractText', () => {
343
- it('extracts text from text message', () => {
344
- const content = JSON.stringify({ text: 'hello world' })
345
- expect(extractText(content, 'text')).toBe('hello world')
346
- })
347
-
348
- it('extracts text from post message (zh_cn)', () => {
349
- const content = JSON.stringify({
350
- zh_cn: {
351
- content: [[
352
- { tag: 'text', text: 'Hello ' },
353
- { tag: 'text', text: 'World' },
354
- ]],
355
- },
356
- })
357
- expect(extractText(content, 'post')).toBe('Hello World')
358
- })
359
-
360
- it('extracts text from post message with md tag', () => {
361
- const content = JSON.stringify({
362
- zh_cn: {
363
- content: [[{ tag: 'md', text: '**bold** text' }]],
364
- },
365
- })
366
- expect(extractText(content, 'post')).toBe('**bold** text')
367
- })
368
-
369
- it('returns null for unsupported message types', () => {
370
- expect(extractText('{}', 'image')).toBeNull()
371
- expect(extractText('{}', 'audio')).toBeNull()
372
- })
373
-
374
- it('returns null for malformed content', () => {
375
- expect(extractText('not-json', 'text')).toBeNull()
376
- })
377
-
378
- it('returns null for empty text', () => {
379
- const content = JSON.stringify({ text: '' })
380
- // empty string is falsy, so ?? null returns ''
381
- expect(extractText(content, 'text')).toBe('')
382
- })
383
- })
384
-
385
- describe('isBotMentioned', () => {
386
- const botId = 'ou_bot_123'
387
-
388
- it('returns true when bot is mentioned', () => {
389
- const mentions = [
390
- { id: { open_id: 'ou_user_1' } },
391
- { id: { open_id: 'ou_bot_123' } },
392
- ]
393
- expect(isBotMentioned(mentions, botId)).toBe(true)
394
- })
395
-
396
- it('returns false when bot is not mentioned', () => {
397
- const mentions = [
398
- { id: { open_id: 'ou_user_1' } },
399
- { id: { open_id: 'ou_user_2' } },
400
- ]
401
- expect(isBotMentioned(mentions, botId)).toBe(false)
402
- })
403
-
404
- it('returns false for undefined mentions', () => {
405
- expect(isBotMentioned(undefined, botId)).toBe(false)
406
- })
407
-
408
- it('returns false for empty mentions', () => {
409
- expect(isBotMentioned([], botId)).toBe(false)
410
- })
411
- })
412
-
413
- describe('stripMentions', () => {
414
- it('removes @_user_N patterns', () => {
415
- expect(stripMentions('@_user_1 hello world')).toBe('hello world')
416
- })
417
-
418
- it('removes multiple mentions', () => {
419
- expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
420
- })
421
-
422
- it('leaves text without mentions unchanged', () => {
423
- expect(stripMentions('hello world')).toBe('hello world')
424
- })
425
-
426
- it('trims whitespace', () => {
427
- expect(stripMentions(' @_user_1 hello ')).toBe('hello')
428
- })
429
- })
430
- })
431
-
432
- describe('Feishu: permission card', () => {
433
- // Helpers to reach into Schema 2.0 body.elements
434
- function getBodyElements(card: Record<string, unknown>): any[] {
435
- return ((card.body as any).elements ?? []) as any[]
436
- }
437
- function getActionRow(card: Record<string, unknown>): any {
438
- return getBodyElements(card).find((el) => el.tag === 'column_set')
439
- }
440
- function getButtons(card: Record<string, unknown>): any[] {
441
- return getActionRow(card).columns.map(
442
- (c: any) => c.elements.find((e: any) => e.tag === 'button'),
443
- )
444
- }
445
-
446
- // ----- Schema 2.0 regression -----
447
-
448
- it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
449
- const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
450
- expect(card.schema).toBe('2.0')
451
- expect(card.elements).toBeUndefined() // old bug had top-level elements
452
- expect((card.body as any).elements).toBeDefined()
453
- expect((card.config as any).update_multi).toBe(true)
454
- expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
455
- })
456
-
457
- it('header has title, subtitle, template, icon', () => {
458
- const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
459
- const header = card.header as any
460
- expect(header.title.content).toContain('权限确认')
461
- expect(header.subtitle.content).toBe('Bash')
462
- expect(header.template).toBe('orange')
463
- expect(header.icon.tag).toBe('standard_icon')
464
- })
465
-
466
- // ----- Three buttons -----
467
-
468
- it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
469
- const card = buildPermissionCard('Read', {}, 'xyz')
470
- const [allow, always, deny] = getButtons(card)
471
- expect(allow.text.content).toContain('允许')
472
- expect(allow.type).toBe('primary')
473
- expect(always.text.content).toContain('永久允许')
474
- expect(always.type).toBe('default')
475
- expect(deny.text.content).toContain('拒绝')
476
- expect(deny.type).toBe('danger')
477
- })
478
-
479
- it('允许 button carries allowed=true and no rule', () => {
480
- const card = buildPermissionCard('Read', {}, 'req-1')
481
- const [allow] = getButtons(card)
482
- expect(allow.value).toEqual({
483
- action: 'permit',
484
- requestId: 'req-1',
485
- allowed: true,
486
- })
487
- expect(allow.value.rule).toBeUndefined()
488
- })
489
-
490
- it('永久允许 button carries allowed=true + rule=always', () => {
491
- const card = buildPermissionCard('Read', {}, 'req-2')
492
- const always = getButtons(card)[1]
493
- expect(always.value).toEqual({
494
- action: 'permit',
495
- requestId: 'req-2',
496
- allowed: true,
497
- rule: 'always',
498
- })
499
- })
500
-
501
- it('拒绝 button carries allowed=false and no rule', () => {
502
- const card = buildPermissionCard('Read', {}, 'req-3')
503
- const deny = getButtons(card)[2]
504
- expect(deny.value).toEqual({
505
- action: 'permit',
506
- requestId: 'req-3',
507
- allowed: false,
508
- })
509
- })
510
-
511
- // ----- Tool summary rendering -----
512
-
513
- it('renders Write with ✏️ 写入文件 header and file path target', () => {
514
- const card = buildPermissionCard(
515
- 'Write',
516
- { file_path: '/tmp/output.txt', content: 'hi' },
517
- 'req',
518
- )
519
- const elements = getBodyElements(card)
520
- expect(elements[0].content).toContain('✏️')
521
- expect(elements[0].content).toContain('写入文件')
522
- expect(elements[0].content).toContain('`Write`')
523
- // Target rendered as fenced code block
524
- expect(elements[1].content).toContain('/tmp/output.txt')
525
- expect(elements[1].content.startsWith('```')).toBe(true)
526
- })
527
-
528
- it('renders Edit with ✏️ 修改文件', () => {
529
- const card = buildPermissionCard(
530
- 'Edit',
531
- { file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
532
- 'req',
533
- )
534
- expect(getBodyElements(card)[0].content).toContain('修改文件')
535
- })
536
-
537
- it('renders Bash with 🖥️ 执行命令 and command target', () => {
538
- const card = buildPermissionCard(
539
- 'Bash',
540
- { command: 'rm -rf /tmp/x' },
541
- 'req',
542
- )
543
- const elements = getBodyElements(card)
544
- expect(elements[0].content).toContain('🖥️')
545
- expect(elements[0].content).toContain('执行命令')
546
- expect(elements[1].content).toContain('rm -rf /tmp/x')
547
- })
548
-
549
- it('truncates very long Bash commands to 160 chars', () => {
550
- const longCmd = 'echo ' + 'x'.repeat(500)
551
- const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
552
- const targetEl = getBodyElements(card)[1]
553
- expect(targetEl.content).toContain('…')
554
- // Fenced code wraps ~10 extra chars
555
- expect(targetEl.content.length).toBeLessThanOrEqual(180)
556
- })
557
-
558
- it('renders Grep with 🔍 搜索内容 and pattern target', () => {
559
- const card = buildPermissionCard(
560
- 'Grep',
561
- { pattern: 'TODO', path: '/src' },
562
- 'req',
563
- )
564
- const elements = getBodyElements(card)
565
- expect(elements[0].content).toContain('🔍')
566
- expect(elements[1].content).toContain('TODO')
567
- })
568
-
569
- it('renders WebFetch with 🌐 访问网页 and url target', () => {
570
- const card = buildPermissionCard(
571
- 'WebFetch',
572
- { url: 'https://example.com/api' },
573
- 'req',
574
- )
575
- const elements = getBodyElements(card)
576
- expect(elements[0].content).toContain('🌐')
577
- expect(elements[0].content).toContain('访问网页')
578
- expect(elements[1].content).toContain('https://example.com/api')
579
- })
580
-
581
- it('falls back to 🔧 <toolName> for unknown tools', () => {
582
- const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
583
- expect(getBodyElements(card)[0].content).toContain('🔧')
584
- expect(getBodyElements(card)[0].content).toContain('CustomTool')
585
- })
586
-
587
- it('has no target line when input is empty', () => {
588
- const card = buildPermissionCard('Bash', {}, 'req')
589
- const elements = getBodyElements(card)
590
- // elements: [header_md, hr, action_column_set]
591
- expect(elements[1].tag).toBe('hr')
592
- })
593
-
594
- // ----- Cross-directory detection -----
595
-
596
- it('does NOT show cross-dir warning when file is inside workDir', () => {
597
- const card = buildPermissionCard(
598
- 'Write',
599
- { file_path: '/Users/me/proj/src/a.ts' },
600
- 'req',
601
- '/Users/me/proj',
602
- )
603
- const elements = getBodyElements(card)
604
- const hasWarn = elements.some(
605
- (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
606
- )
607
- expect(hasWarn).toBe(false)
608
- expect((card.header as any).template).toBe('orange')
609
- expect((card.header as any).subtitle.content).toBe('Write')
610
- })
611
-
612
- it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
613
- const card = buildPermissionCard(
614
- 'Write',
615
- { file_path: '/tmp/evil.sh' },
616
- 'req',
617
- '/Users/me/proj',
618
- )
619
- const elements = getBodyElements(card)
620
- const warn = elements.find(
621
- (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
622
- )
623
- expect(warn).toBeDefined()
624
- expect((card.header as any).template).toBe('red')
625
- expect((card.header as any).subtitle.content).toContain('跨目录')
626
- })
627
-
628
- it('does NOT check cross-dir for Bash (no filePath)', () => {
629
- const card = buildPermissionCard(
630
- 'Bash',
631
- { command: 'rm -rf /tmp/x' },
632
- 'req',
633
- '/Users/me/proj',
634
- )
635
- expect((card.header as any).template).toBe('orange')
636
- })
637
-
638
- it('does not warn when workDir is not provided', () => {
639
- const card = buildPermissionCard(
640
- 'Write',
641
- { file_path: '/tmp/x.ts' },
642
- 'req',
643
- // workDir omitted
644
- )
645
- const elements = getBodyElements(card)
646
- const hasWarn = elements.some(
647
- (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
648
- )
649
- expect(hasWarn).toBe(false)
650
- })
651
- })
652
-
653
- describe('Feishu: isOutsideWorkDir', () => {
654
- it('returns false for file inside workDir', () => {
655
- expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
656
- })
657
-
658
- it('returns false for file directly in workDir', () => {
659
- expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
660
- })
661
-
662
- it('returns true for file in a sibling directory', () => {
663
- expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
664
- })
665
-
666
- it('returns true for /tmp file', () => {
667
- expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
668
- })
669
-
670
- it('handles workDir with trailing slash', () => {
671
- expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
672
- })
673
-
674
- it('resolves relative paths against workDir', () => {
675
- expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
676
- expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
677
- })
678
-
679
- it('does not match prefix collisions (proj vs proj2)', () => {
680
- // /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
681
- // but is NOT inside /Users/me/proj
682
- expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
683
- })
684
- })
685
-
686
- describe('Feishu: project picker card', () => {
687
- const sampleProjects: RecentProject[] = [
688
- {
689
- projectPath: '/Users/dev/claude-code-haha',
690
- realPath: '/Users/dev/claude-code-haha',
691
- projectName: 'claude-code-haha',
692
- isGit: true,
693
- repoName: 'claude-code-haha',
694
- branch: 'main',
695
- modifiedAt: '2026-04-11T00:00:00Z',
696
- sessionCount: 3,
697
- },
698
- {
699
- projectPath: '/Users/dev/desktop',
700
- realPath: '/Users/dev/desktop',
701
- projectName: 'desktop',
702
- isGit: false,
703
- repoName: null,
704
- branch: null,
705
- modifiedAt: '2026-04-10T00:00:00Z',
706
- sessionCount: 1,
707
- },
708
- ]
709
-
710
- function getBodyElements(card: Record<string, unknown>): any[] {
711
- return ((card.body as any).elements ?? []) as any[]
712
- }
713
-
714
- function getRows(card: Record<string, unknown>): any[] {
715
- return getBodyElements(card).filter((el) => el.tag === 'column_set')
716
- }
717
-
718
- function getRowButton(row: any): any {
719
- const buttonCol = row.columns.find((c: any) =>
720
- c.elements.some((e: any) => e.tag === 'button'),
721
- )
722
- return buttonCol.elements.find((e: any) => e.tag === 'button')
723
- }
724
-
725
- function getRowInfoElements(row: any): any[] {
726
- const infoCol = row.columns.find((c: any) =>
727
- c.elements.every((e: any) => e.tag === 'markdown'),
728
- )
729
- return infoCol.elements
730
- }
731
-
732
- it('uses Schema 2.0 with body.elements wrapper', () => {
733
- const card = buildProjectPickerCard(sampleProjects)
734
- expect(card.schema).toBe('2.0')
735
- expect((card.config as any).update_multi).toBe(true)
736
- expect((card.body as any).elements).toBeDefined()
737
- })
738
-
739
- it('header has title and project-count subtitle', () => {
740
- const card = buildProjectPickerCard(sampleProjects)
741
- expect((card.header as any).title.content).toContain('选择项目')
742
- expect((card.header as any).subtitle.content).toContain('2')
743
- expect((card.header as any).subtitle.content).toContain('最近项目')
744
- })
745
-
746
- it('subtitle notes truncation when more than 10 projects exist', () => {
747
- const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
748
- ...sampleProjects[0]!,
749
- projectName: `proj-${i}`,
750
- realPath: `/p/${i}`,
751
- }))
752
- const card = buildProjectPickerCard(many)
753
- const subtitle = (card.header as any).subtitle.content
754
- expect(subtitle).toContain('15')
755
- expect(subtitle).toContain('显示前 10')
756
- })
757
-
758
- it('body contains one column_set row per project', () => {
759
- const card = buildProjectPickerCard(sampleProjects)
760
- expect(getRows(card).length).toBe(2)
761
- })
762
-
763
- it('each row has exactly 2 columns: info (weighted) + button (auto)', () => {
764
- const card = buildProjectPickerCard(sampleProjects)
765
- for (const row of getRows(card)) {
766
- expect(row.columns.length).toBe(2)
767
- expect(row.columns[0].width).toBe('weighted')
768
- expect(row.columns[0].vertical_align).toBe('center')
769
- expect(row.columns[1].width).toBe('auto')
770
- expect(row.columns[1].vertical_align).toBe('center')
771
- }
772
- })
773
-
774
- it('info column has title markdown + notation path markdown', () => {
775
- const card = buildProjectPickerCard(sampleProjects)
776
- const row1 = getRows(card)[0]
777
- const info = getRowInfoElements(row1)
778
-
779
- expect(info.length).toBe(2)
780
- // Title markdown
781
- expect(info[0].tag).toBe('markdown')
782
- expect(info[0].content).toContain('**claude-code-haha**')
783
- expect(info[0].content).toContain('*main*')
784
- // Path markdown (notation = small grey)
785
- expect(info[1].tag).toBe('markdown')
786
- expect(info[1].text_size).toBe('notation')
787
- expect(info[1].content).toContain('claude-code-haha')
788
- })
789
-
790
- it('row without branch has no separator dot in title', () => {
791
- const card = buildProjectPickerCard(sampleProjects)
792
- const row2 = getRows(card)[1]
793
- const title = getRowInfoElements(row2)[0].content
794
- expect(title).toContain('**desktop**')
795
- expect(title).not.toContain('·')
796
- })
797
-
798
- it('row button says 选择 with small size and carries per-project value', () => {
799
- const card = buildProjectPickerCard(sampleProjects)
800
- const rows = getRows(card)
801
-
802
- const btn1 = getRowButton(rows[0])
803
- expect(btn1.text.content).toBe('选择')
804
- expect(btn1.size).toBe('small')
805
- expect(btn1.value.action).toBe('pick_project')
806
- expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha')
807
- expect(btn1.value.projectName).toBe('claude-code-haha')
808
-
809
- const btn2 = getRowButton(rows[1])
810
- expect(btn2.value.realPath).toBe('/Users/dev/desktop')
811
- })
812
-
813
- it('first row button is primary, rest are default', () => {
814
- const card = buildProjectPickerCard(sampleProjects)
815
- const rows = getRows(card)
816
- expect(getRowButton(rows[0]).type).toBe('primary')
817
- expect(getRowButton(rows[1]).type).toBe('default')
818
- })
819
-
820
- it('body tail has hr and notation footer hint', () => {
821
- const card = buildProjectPickerCard(sampleProjects)
822
- const elements = getBodyElements(card)
823
- const hrIdx = elements.findIndex((el) => el.tag === 'hr')
824
- expect(hrIdx).toBeGreaterThan(0)
825
- expect(elements[hrIdx + 1].tag).toBe('markdown')
826
- expect(elements[hrIdx + 1].text_size).toBe('notation')
827
- })
828
-
829
- it('caps to first 10 projects', () => {
830
- const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
831
- ...sampleProjects[0]!,
832
- projectName: `proj-${i}`,
833
- realPath: `/p/${i}`,
834
- }))
835
- const card = buildProjectPickerCard(many)
836
- const rows = getRows(card)
837
- expect(rows.length).toBe(10)
838
- expect(getRowButton(rows[9]).value.realPath).toBe('/p/9')
839
- })
840
-
841
- it('uses ~ shortcut when path is under $HOME', () => {
842
- const home = process.env.HOME
843
- if (!home) return
844
- const project: RecentProject = {
845
- ...sampleProjects[0]!,
846
- realPath: `${home}/some/sub/dir`,
847
- projectName: 'sub-dir',
848
- }
849
- const card = buildProjectPickerCard([project])
850
- const pathEl = getRowInfoElements(getRows(card)[0])[1]
851
- expect(pathEl.content).toBe('~/some/sub/dir')
852
- })
853
-
854
- it('middle-truncates very long paths with ellipsis', () => {
855
- const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars
856
- const project: RecentProject = {
857
- ...sampleProjects[0]!,
858
- realPath: veryLong,
859
- projectName: 'project',
860
- }
861
- const card = buildProjectPickerCard([project])
862
- const content = getRowInfoElements(getRows(card)[0])[1].content
863
- expect(content).toContain('…')
864
- expect(content.length).toBeLessThanOrEqual(56)
865
- expect(content.endsWith('project')).toBe(true)
866
- })
867
- })
868
-
869
- describe('Feishu: card.action.trigger parsing', () => {
870
- it('parses permit action from event', () => {
871
- const event = {
872
- operator: { open_id: 'ou_user_1' },
873
- action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
874
- context: { open_chat_id: 'oc_chat_123' },
875
- }
876
-
877
- expect(event.action.value.action).toBe('permit')
878
- expect(event.action.value.requestId).toBe('abcde')
879
- expect(event.action.value.allowed).toBe(true)
880
- expect(event.context.open_chat_id).toBe('oc_chat_123')
881
- })
882
-
883
- it('parses pick_project action from event', () => {
884
- const event = {
885
- operator: { open_id: 'ou_user_1' },
886
- action: {
887
- value: {
888
- action: 'pick_project',
889
- realPath: '/Users/dev/claude-code-haha',
890
- projectName: 'claude-code-haha',
891
- },
892
- },
893
- context: { open_chat_id: 'oc_chat_123' },
894
- }
895
-
896
- expect(event.action.value.action).toBe('pick_project')
897
- expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha')
898
- expect(event.action.value.projectName).toBe('claude-code-haha')
899
- })
900
-
901
- it('ignores non-handled actions', () => {
902
- const event = {
903
- action: { value: { action: 'other_action' } },
904
- }
905
- expect(['permit', 'pick_project']).not.toContain(event.action.value.action)
906
- })
907
- })