mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
900 lines
27 KiB
TypeScript
900 lines
27 KiB
TypeScript
/**
|
||
* 飞书 Adapter 翻译逻辑测试
|
||
*
|
||
* 不启动真实 Bot,只测试事件解析和消息翻译逻辑。
|
||
*/
|
||
|
||
import { describe, it, expect } from 'bun:test'
|
||
import { isOutsideWorkDir } from '../path-safety.js'
|
||
|
||
// ---------- helpers extracted from feishu/index.ts for testability ----------
|
||
|
||
function extractText(content: string, msgType: string): string | null {
|
||
try {
|
||
const parsed = JSON.parse(content)
|
||
if (msgType === 'text') {
|
||
return parsed.text ?? null
|
||
}
|
||
if (msgType === 'post') {
|
||
const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
|
||
return zhContent
|
||
.flat()
|
||
.filter((n: any) => n.tag === 'text' || n.tag === 'md')
|
||
.map((n: any) => n.text ?? n.content ?? '')
|
||
.join('')
|
||
.trim() || null
|
||
}
|
||
return null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function isBotMentioned(
|
||
mentions: Array<{ id?: { open_id?: string } }> | undefined,
|
||
botOpenId: string,
|
||
): boolean {
|
||
if (!mentions || !botOpenId) return false
|
||
return mentions.some((m) => m.id?.open_id === botOpenId)
|
||
}
|
||
|
||
function stripMentions(text: string): string {
|
||
return text.replace(/@_user_\d+/g, '').trim()
|
||
}
|
||
|
||
type RecentProject = {
|
||
projectPath: string
|
||
realPath: string
|
||
projectName: string
|
||
isGit: boolean
|
||
repoName: string | null
|
||
branch: string | null
|
||
modifiedAt: string
|
||
sessionCount: number
|
||
}
|
||
|
||
function prettyPath(realPath: string, maxLen = 64): string {
|
||
const home = process.env.HOME
|
||
let p = realPath
|
||
if (home) {
|
||
if (p === home) return '~'
|
||
if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
|
||
}
|
||
if (p.length <= maxLen) return p
|
||
const tailLen = Math.floor(maxLen * 0.65)
|
||
const headLen = maxLen - tailLen - 1
|
||
return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
|
||
}
|
||
|
||
function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
|
||
const items = projects.slice(0, 10)
|
||
const total = projects.length
|
||
const subtitleText =
|
||
total > items.length
|
||
? `共 ${total} 个最近项目,显示前 ${items.length}`
|
||
: `共 ${total} 个最近项目`
|
||
|
||
const rows = items.map((p, i) => {
|
||
const branch = p.branch ? ` · *${p.branch}*` : ''
|
||
return {
|
||
tag: 'column_set',
|
||
flex_mode: 'stretch',
|
||
horizontal_spacing: '8px',
|
||
margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
|
||
columns: [
|
||
{
|
||
tag: 'column',
|
||
width: 'weighted',
|
||
weight: 1,
|
||
vertical_align: 'center',
|
||
elements: [
|
||
{
|
||
tag: 'markdown',
|
||
content: `**${p.projectName}**${branch}`,
|
||
},
|
||
{
|
||
tag: 'markdown',
|
||
content: prettyPath(p.realPath, 56),
|
||
text_size: 'notation',
|
||
margin: '2px 0 0 0',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
tag: 'column',
|
||
width: 'auto',
|
||
vertical_align: 'center',
|
||
elements: [
|
||
{
|
||
tag: 'button',
|
||
text: { tag: 'plain_text', content: '选择' },
|
||
type: i === 0 ? 'primary' : 'default',
|
||
size: 'small',
|
||
value: {
|
||
action: 'pick_project',
|
||
realPath: p.realPath,
|
||
projectName: p.projectName,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
return {
|
||
schema: '2.0',
|
||
config: {
|
||
wide_screen_mode: true,
|
||
update_multi: true,
|
||
},
|
||
header: {
|
||
title: { tag: 'plain_text', content: '📁 选择项目' },
|
||
subtitle: { tag: 'plain_text', content: subtitleText },
|
||
template: 'blue',
|
||
},
|
||
body: {
|
||
elements: [
|
||
...rows,
|
||
{ tag: 'hr', margin: '14px 0 0 0' },
|
||
{
|
||
tag: 'markdown',
|
||
content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
|
||
text_size: 'notation',
|
||
margin: '6px 0 0 0',
|
||
},
|
||
],
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---------- permission card helpers (mirrored from feishu/index.ts) ----------
|
||
|
||
type ToolCallSummary = {
|
||
icon: string
|
||
label: string
|
||
target?: string
|
||
filePath?: string
|
||
}
|
||
|
||
function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
|
||
const rec: Record<string, unknown> =
|
||
input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
||
const str = (key: string): string | undefined =>
|
||
typeof rec[key] === 'string' ? (rec[key] as string) : undefined
|
||
|
||
switch (toolName) {
|
||
case 'Write': {
|
||
const fp = str('file_path')
|
||
return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
|
||
}
|
||
case 'Edit':
|
||
case 'MultiEdit':
|
||
case 'NotebookEdit': {
|
||
const fp = str('file_path') ?? str('notebook_path')
|
||
return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
|
||
}
|
||
case 'Read': {
|
||
const fp = str('file_path')
|
||
return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
|
||
}
|
||
case 'Bash':
|
||
case 'BashOutput': {
|
||
return { icon: '🖥️', label: '执行命令', target: str('command') }
|
||
}
|
||
case 'Grep': {
|
||
const pattern = str('pattern')
|
||
return {
|
||
icon: '🔍',
|
||
label: '搜索内容',
|
||
target: pattern ? `pattern: ${pattern}` : undefined,
|
||
filePath: str('path'),
|
||
}
|
||
}
|
||
case 'Glob': {
|
||
const pattern = str('pattern')
|
||
return {
|
||
icon: '📁',
|
||
label: '查找文件',
|
||
target: pattern ? `pattern: ${pattern}` : undefined,
|
||
filePath: str('path'),
|
||
}
|
||
}
|
||
case 'WebFetch':
|
||
return { icon: '🌐', label: '访问网页', target: str('url') }
|
||
case 'WebSearch':
|
||
return { icon: '🌐', label: '搜索网页', target: str('query') }
|
||
default:
|
||
return { icon: '🔧', label: toolName }
|
||
}
|
||
}
|
||
|
||
function truncateTarget(s: string, maxLen = 160): string {
|
||
if (s.length <= maxLen) return s
|
||
return s.slice(0, maxLen - 1) + '…'
|
||
}
|
||
|
||
function buildPermissionCard(
|
||
toolName: string,
|
||
input: unknown,
|
||
requestId: string,
|
||
workDir?: string,
|
||
): Record<string, unknown> {
|
||
const summary = summarizeToolCall(toolName, input)
|
||
const crossDir = Boolean(
|
||
workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
|
||
)
|
||
|
||
const elements: Record<string, unknown>[] = [
|
||
{
|
||
tag: 'markdown',
|
||
content: `${summary.icon} **${summary.label}** \`${toolName}\``,
|
||
},
|
||
]
|
||
|
||
if (summary.target) {
|
||
const shown = summary.filePath
|
||
? prettyPath(summary.target, 80)
|
||
: truncateTarget(summary.target, 160)
|
||
elements.push({
|
||
tag: 'markdown',
|
||
content: '```\n' + shown + '\n```',
|
||
margin: '4px 0 0 0',
|
||
})
|
||
}
|
||
|
||
if (crossDir) {
|
||
elements.push({
|
||
tag: 'markdown',
|
||
content: '⚠️ **该操作位于当前项目目录之外**',
|
||
margin: '8px 0 0 0',
|
||
text_size: 'notation',
|
||
})
|
||
}
|
||
|
||
elements.push({ tag: 'hr', margin: '12px 0 0 0' })
|
||
|
||
elements.push({
|
||
tag: 'column_set',
|
||
flex_mode: 'stretch',
|
||
horizontal_spacing: '8px',
|
||
margin: '8px 0 0 0',
|
||
columns: [
|
||
{
|
||
tag: 'column',
|
||
width: 'weighted',
|
||
weight: 1,
|
||
vertical_align: 'center',
|
||
elements: [
|
||
{
|
||
tag: 'button',
|
||
text: { tag: 'plain_text', content: '✅ 允许' },
|
||
type: 'primary',
|
||
size: 'medium',
|
||
value: { action: 'permit', requestId, allowed: true },
|
||
},
|
||
],
|
||
},
|
||
{
|
||
tag: 'column',
|
||
width: 'weighted',
|
||
weight: 1,
|
||
vertical_align: 'center',
|
||
elements: [
|
||
{
|
||
tag: 'button',
|
||
text: { tag: 'plain_text', content: '♾️ 永久允许' },
|
||
type: 'default',
|
||
size: 'medium',
|
||
value: { action: 'permit', requestId, allowed: true, rule: 'always' },
|
||
},
|
||
],
|
||
},
|
||
{
|
||
tag: 'column',
|
||
width: 'weighted',
|
||
weight: 1,
|
||
vertical_align: 'center',
|
||
elements: [
|
||
{
|
||
tag: 'button',
|
||
text: { tag: 'plain_text', content: '❌ 拒绝' },
|
||
type: 'danger',
|
||
size: 'medium',
|
||
value: { action: 'permit', requestId, allowed: false },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
})
|
||
|
||
return {
|
||
schema: '2.0',
|
||
config: {
|
||
wide_screen_mode: false,
|
||
update_multi: true,
|
||
},
|
||
header: {
|
||
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
|
||
subtitle: {
|
||
tag: 'plain_text',
|
||
content: crossDir ? '⚠️ 跨目录操作' : toolName,
|
||
},
|
||
template: crossDir ? 'red' : 'orange',
|
||
padding: '12px 12px 12px 12px',
|
||
icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
|
||
},
|
||
body: { elements },
|
||
}
|
||
}
|
||
|
||
// ---------- tests ----------
|
||
|
||
describe('Feishu: event parsing', () => {
|
||
describe('extractText', () => {
|
||
it('extracts text from text message', () => {
|
||
const content = JSON.stringify({ text: 'hello world' })
|
||
expect(extractText(content, 'text')).toBe('hello world')
|
||
})
|
||
|
||
it('extracts text from post message (zh_cn)', () => {
|
||
const content = JSON.stringify({
|
||
zh_cn: {
|
||
content: [[
|
||
{ tag: 'text', text: 'Hello ' },
|
||
{ tag: 'text', text: 'World' },
|
||
]],
|
||
},
|
||
})
|
||
expect(extractText(content, 'post')).toBe('Hello World')
|
||
})
|
||
|
||
it('extracts text from post message with md tag', () => {
|
||
const content = JSON.stringify({
|
||
zh_cn: {
|
||
content: [[{ tag: 'md', text: '**bold** text' }]],
|
||
},
|
||
})
|
||
expect(extractText(content, 'post')).toBe('**bold** text')
|
||
})
|
||
|
||
it('returns null for unsupported message types', () => {
|
||
expect(extractText('{}', 'image')).toBeNull()
|
||
expect(extractText('{}', 'audio')).toBeNull()
|
||
})
|
||
|
||
it('returns null for malformed content', () => {
|
||
expect(extractText('not-json', 'text')).toBeNull()
|
||
})
|
||
|
||
it('returns null for empty text', () => {
|
||
const content = JSON.stringify({ text: '' })
|
||
// empty string is falsy, so ?? null returns ''
|
||
expect(extractText(content, 'text')).toBe('')
|
||
})
|
||
})
|
||
|
||
describe('isBotMentioned', () => {
|
||
const botId = 'ou_bot_123'
|
||
|
||
it('returns true when bot is mentioned', () => {
|
||
const mentions = [
|
||
{ id: { open_id: 'ou_user_1' } },
|
||
{ id: { open_id: 'ou_bot_123' } },
|
||
]
|
||
expect(isBotMentioned(mentions, botId)).toBe(true)
|
||
})
|
||
|
||
it('returns false when bot is not mentioned', () => {
|
||
const mentions = [
|
||
{ id: { open_id: 'ou_user_1' } },
|
||
{ id: { open_id: 'ou_user_2' } },
|
||
]
|
||
expect(isBotMentioned(mentions, botId)).toBe(false)
|
||
})
|
||
|
||
it('returns false for undefined mentions', () => {
|
||
expect(isBotMentioned(undefined, botId)).toBe(false)
|
||
})
|
||
|
||
it('returns false for empty mentions', () => {
|
||
expect(isBotMentioned([], botId)).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('stripMentions', () => {
|
||
it('removes @_user_N patterns', () => {
|
||
expect(stripMentions('@_user_1 hello world')).toBe('hello world')
|
||
})
|
||
|
||
it('removes multiple mentions', () => {
|
||
expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
|
||
})
|
||
|
||
it('leaves text without mentions unchanged', () => {
|
||
expect(stripMentions('hello world')).toBe('hello world')
|
||
})
|
||
|
||
it('trims whitespace', () => {
|
||
expect(stripMentions(' @_user_1 hello ')).toBe('hello')
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('Feishu: permission card', () => {
|
||
// Helpers to reach into Schema 2.0 body.elements
|
||
function getBodyElements(card: Record<string, unknown>): any[] {
|
||
return ((card.body as any).elements ?? []) as any[]
|
||
}
|
||
function getActionRow(card: Record<string, unknown>): any {
|
||
return getBodyElements(card).find((el) => el.tag === 'column_set')
|
||
}
|
||
function getButtons(card: Record<string, unknown>): any[] {
|
||
return getActionRow(card).columns.map(
|
||
(c: any) => c.elements.find((e: any) => e.tag === 'button'),
|
||
)
|
||
}
|
||
|
||
// ----- Schema 2.0 regression -----
|
||
|
||
it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
|
||
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
|
||
expect(card.schema).toBe('2.0')
|
||
expect(card.elements).toBeUndefined() // old bug had top-level elements
|
||
expect((card.body as any).elements).toBeDefined()
|
||
expect((card.config as any).update_multi).toBe(true)
|
||
expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
|
||
})
|
||
|
||
it('header has title, subtitle, template, icon', () => {
|
||
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
|
||
const header = card.header as any
|
||
expect(header.title.content).toContain('权限确认')
|
||
expect(header.subtitle.content).toBe('Bash')
|
||
expect(header.template).toBe('orange')
|
||
expect(header.icon.tag).toBe('standard_icon')
|
||
})
|
||
|
||
// ----- Three buttons -----
|
||
|
||
it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
|
||
const card = buildPermissionCard('Read', {}, 'xyz')
|
||
const [allow, always, deny] = getButtons(card)
|
||
expect(allow.text.content).toContain('允许')
|
||
expect(allow.type).toBe('primary')
|
||
expect(always.text.content).toContain('永久允许')
|
||
expect(always.type).toBe('default')
|
||
expect(deny.text.content).toContain('拒绝')
|
||
expect(deny.type).toBe('danger')
|
||
})
|
||
|
||
it('允许 button carries allowed=true and no rule', () => {
|
||
const card = buildPermissionCard('Read', {}, 'req-1')
|
||
const [allow] = getButtons(card)
|
||
expect(allow.value).toEqual({
|
||
action: 'permit',
|
||
requestId: 'req-1',
|
||
allowed: true,
|
||
})
|
||
expect(allow.value.rule).toBeUndefined()
|
||
})
|
||
|
||
it('永久允许 button carries allowed=true + rule=always', () => {
|
||
const card = buildPermissionCard('Read', {}, 'req-2')
|
||
const always = getButtons(card)[1]
|
||
expect(always.value).toEqual({
|
||
action: 'permit',
|
||
requestId: 'req-2',
|
||
allowed: true,
|
||
rule: 'always',
|
||
})
|
||
})
|
||
|
||
it('拒绝 button carries allowed=false and no rule', () => {
|
||
const card = buildPermissionCard('Read', {}, 'req-3')
|
||
const deny = getButtons(card)[2]
|
||
expect(deny.value).toEqual({
|
||
action: 'permit',
|
||
requestId: 'req-3',
|
||
allowed: false,
|
||
})
|
||
})
|
||
|
||
// ----- Tool summary rendering -----
|
||
|
||
it('renders Write with ✏️ 写入文件 header and file path target', () => {
|
||
const card = buildPermissionCard(
|
||
'Write',
|
||
{ file_path: '/tmp/output.txt', content: 'hi' },
|
||
'req',
|
||
)
|
||
const elements = getBodyElements(card)
|
||
expect(elements[0].content).toContain('✏️')
|
||
expect(elements[0].content).toContain('写入文件')
|
||
expect(elements[0].content).toContain('`Write`')
|
||
// Target rendered as fenced code block
|
||
expect(elements[1].content).toContain('/tmp/output.txt')
|
||
expect(elements[1].content.startsWith('```')).toBe(true)
|
||
})
|
||
|
||
it('renders Edit with ✏️ 修改文件', () => {
|
||
const card = buildPermissionCard(
|
||
'Edit',
|
||
{ file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
|
||
'req',
|
||
)
|
||
expect(getBodyElements(card)[0].content).toContain('修改文件')
|
||
})
|
||
|
||
it('renders Bash with 🖥️ 执行命令 and command target', () => {
|
||
const card = buildPermissionCard(
|
||
'Bash',
|
||
{ command: 'rm -rf /tmp/x' },
|
||
'req',
|
||
)
|
||
const elements = getBodyElements(card)
|
||
expect(elements[0].content).toContain('🖥️')
|
||
expect(elements[0].content).toContain('执行命令')
|
||
expect(elements[1].content).toContain('rm -rf /tmp/x')
|
||
})
|
||
|
||
it('truncates very long Bash commands to 160 chars', () => {
|
||
const longCmd = 'echo ' + 'x'.repeat(500)
|
||
const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
|
||
const targetEl = getBodyElements(card)[1]
|
||
expect(targetEl.content).toContain('…')
|
||
// Fenced code wraps ~10 extra chars
|
||
expect(targetEl.content.length).toBeLessThanOrEqual(180)
|
||
})
|
||
|
||
it('renders Grep with 🔍 搜索内容 and pattern target', () => {
|
||
const card = buildPermissionCard(
|
||
'Grep',
|
||
{ pattern: 'TODO', path: '/src' },
|
||
'req',
|
||
)
|
||
const elements = getBodyElements(card)
|
||
expect(elements[0].content).toContain('🔍')
|
||
expect(elements[1].content).toContain('TODO')
|
||
})
|
||
|
||
it('renders WebFetch with 🌐 访问网页 and url target', () => {
|
||
const card = buildPermissionCard(
|
||
'WebFetch',
|
||
{ url: 'https://example.com/api' },
|
||
'req',
|
||
)
|
||
const elements = getBodyElements(card)
|
||
expect(elements[0].content).toContain('🌐')
|
||
expect(elements[0].content).toContain('访问网页')
|
||
expect(elements[1].content).toContain('https://example.com/api')
|
||
})
|
||
|
||
it('falls back to 🔧 <toolName> for unknown tools', () => {
|
||
const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
|
||
expect(getBodyElements(card)[0].content).toContain('🔧')
|
||
expect(getBodyElements(card)[0].content).toContain('CustomTool')
|
||
})
|
||
|
||
it('has no target line when input is empty', () => {
|
||
const card = buildPermissionCard('Bash', {}, 'req')
|
||
const elements = getBodyElements(card)
|
||
// elements: [header_md, hr, action_column_set]
|
||
expect(elements[1].tag).toBe('hr')
|
||
})
|
||
|
||
// ----- Cross-directory detection -----
|
||
|
||
it('does NOT show cross-dir warning when file is inside workDir', () => {
|
||
const card = buildPermissionCard(
|
||
'Write',
|
||
{ file_path: '/Users/me/proj/src/a.ts' },
|
||
'req',
|
||
'/Users/me/proj',
|
||
)
|
||
const elements = getBodyElements(card)
|
||
const hasWarn = elements.some(
|
||
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
||
)
|
||
expect(hasWarn).toBe(false)
|
||
expect((card.header as any).template).toBe('orange')
|
||
expect((card.header as any).subtitle.content).toBe('Write')
|
||
})
|
||
|
||
it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
|
||
const card = buildPermissionCard(
|
||
'Write',
|
||
{ file_path: '/tmp/evil.sh' },
|
||
'req',
|
||
'/Users/me/proj',
|
||
)
|
||
const elements = getBodyElements(card)
|
||
const warn = elements.find(
|
||
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
||
)
|
||
expect(warn).toBeDefined()
|
||
expect((card.header as any).template).toBe('red')
|
||
expect((card.header as any).subtitle.content).toContain('跨目录')
|
||
})
|
||
|
||
it('does NOT check cross-dir for Bash (no filePath)', () => {
|
||
const card = buildPermissionCard(
|
||
'Bash',
|
||
{ command: 'rm -rf /tmp/x' },
|
||
'req',
|
||
'/Users/me/proj',
|
||
)
|
||
expect((card.header as any).template).toBe('orange')
|
||
})
|
||
|
||
it('does not warn when workDir is not provided', () => {
|
||
const card = buildPermissionCard(
|
||
'Write',
|
||
{ file_path: '/tmp/x.ts' },
|
||
'req',
|
||
// workDir omitted
|
||
)
|
||
const elements = getBodyElements(card)
|
||
const hasWarn = elements.some(
|
||
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
||
)
|
||
expect(hasWarn).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('Feishu: isOutsideWorkDir', () => {
|
||
it('returns false for file inside workDir', () => {
|
||
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
|
||
})
|
||
|
||
it('returns false for file directly in workDir', () => {
|
||
expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
|
||
})
|
||
|
||
it('returns true for file in a sibling directory', () => {
|
||
expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
|
||
})
|
||
|
||
it('returns true for /tmp file', () => {
|
||
expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
|
||
})
|
||
|
||
it('handles workDir with trailing slash', () => {
|
||
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
|
||
})
|
||
|
||
it('resolves relative paths against workDir', () => {
|
||
expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
|
||
expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
|
||
})
|
||
|
||
it('does not match prefix collisions (proj vs proj2)', () => {
|
||
// /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
|
||
// but is NOT inside /Users/me/proj
|
||
expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('Feishu: project picker card', () => {
|
||
const sampleProjects: RecentProject[] = [
|
||
{
|
||
projectPath: '/Users/dev/claude-code-haha',
|
||
realPath: '/Users/dev/claude-code-haha',
|
||
projectName: 'claude-code-haha',
|
||
isGit: true,
|
||
repoName: 'claude-code-haha',
|
||
branch: 'main',
|
||
modifiedAt: '2026-04-11T00:00:00Z',
|
||
sessionCount: 3,
|
||
},
|
||
{
|
||
projectPath: '/Users/dev/desktop',
|
||
realPath: '/Users/dev/desktop',
|
||
projectName: 'desktop',
|
||
isGit: false,
|
||
repoName: null,
|
||
branch: null,
|
||
modifiedAt: '2026-04-10T00:00:00Z',
|
||
sessionCount: 1,
|
||
},
|
||
]
|
||
|
||
function getBodyElements(card: Record<string, unknown>): any[] {
|
||
return ((card.body as any).elements ?? []) as any[]
|
||
}
|
||
|
||
function getRows(card: Record<string, unknown>): any[] {
|
||
return getBodyElements(card).filter((el) => el.tag === 'column_set')
|
||
}
|
||
|
||
function getRowButton(row: any): any {
|
||
const buttonCol = row.columns.find((c: any) =>
|
||
c.elements.some((e: any) => e.tag === 'button'),
|
||
)
|
||
return buttonCol.elements.find((e: any) => e.tag === 'button')
|
||
}
|
||
|
||
function getRowInfoElements(row: any): any[] {
|
||
const infoCol = row.columns.find((c: any) =>
|
||
c.elements.every((e: any) => e.tag === 'markdown'),
|
||
)
|
||
return infoCol.elements
|
||
}
|
||
|
||
it('uses Schema 2.0 with body.elements wrapper', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
expect(card.schema).toBe('2.0')
|
||
expect((card.config as any).update_multi).toBe(true)
|
||
expect((card.body as any).elements).toBeDefined()
|
||
})
|
||
|
||
it('header has title and project-count subtitle', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
expect((card.header as any).title.content).toContain('选择项目')
|
||
expect((card.header as any).subtitle.content).toContain('2')
|
||
expect((card.header as any).subtitle.content).toContain('最近项目')
|
||
})
|
||
|
||
it('subtitle notes truncation when more than 10 projects exist', () => {
|
||
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
|
||
...sampleProjects[0]!,
|
||
projectName: `proj-${i}`,
|
||
realPath: `/p/${i}`,
|
||
}))
|
||
const card = buildProjectPickerCard(many)
|
||
const subtitle = (card.header as any).subtitle.content
|
||
expect(subtitle).toContain('15')
|
||
expect(subtitle).toContain('显示前 10')
|
||
})
|
||
|
||
it('body contains one column_set row per project', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
expect(getRows(card).length).toBe(2)
|
||
})
|
||
|
||
it('each row has exactly 2 columns: info (weighted) + button (auto)', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
for (const row of getRows(card)) {
|
||
expect(row.columns.length).toBe(2)
|
||
expect(row.columns[0].width).toBe('weighted')
|
||
expect(row.columns[0].vertical_align).toBe('center')
|
||
expect(row.columns[1].width).toBe('auto')
|
||
expect(row.columns[1].vertical_align).toBe('center')
|
||
}
|
||
})
|
||
|
||
it('info column has title markdown + notation path markdown', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
const row1 = getRows(card)[0]
|
||
const info = getRowInfoElements(row1)
|
||
|
||
expect(info.length).toBe(2)
|
||
// Title markdown
|
||
expect(info[0].tag).toBe('markdown')
|
||
expect(info[0].content).toContain('**claude-code-haha**')
|
||
expect(info[0].content).toContain('*main*')
|
||
// Path markdown (notation = small grey)
|
||
expect(info[1].tag).toBe('markdown')
|
||
expect(info[1].text_size).toBe('notation')
|
||
expect(info[1].content).toContain('claude-code-haha')
|
||
})
|
||
|
||
it('row without branch has no separator dot in title', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
const row2 = getRows(card)[1]
|
||
const title = getRowInfoElements(row2)[0].content
|
||
expect(title).toContain('**desktop**')
|
||
expect(title).not.toContain('·')
|
||
})
|
||
|
||
it('row button says 选择 with small size and carries per-project value', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
const rows = getRows(card)
|
||
|
||
const btn1 = getRowButton(rows[0])
|
||
expect(btn1.text.content).toBe('选择')
|
||
expect(btn1.size).toBe('small')
|
||
expect(btn1.value.action).toBe('pick_project')
|
||
expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha')
|
||
expect(btn1.value.projectName).toBe('claude-code-haha')
|
||
|
||
const btn2 = getRowButton(rows[1])
|
||
expect(btn2.value.realPath).toBe('/Users/dev/desktop')
|
||
})
|
||
|
||
it('first row button is primary, rest are default', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
const rows = getRows(card)
|
||
expect(getRowButton(rows[0]).type).toBe('primary')
|
||
expect(getRowButton(rows[1]).type).toBe('default')
|
||
})
|
||
|
||
it('body tail has hr and notation footer hint', () => {
|
||
const card = buildProjectPickerCard(sampleProjects)
|
||
const elements = getBodyElements(card)
|
||
const hrIdx = elements.findIndex((el) => el.tag === 'hr')
|
||
expect(hrIdx).toBeGreaterThan(0)
|
||
expect(elements[hrIdx + 1].tag).toBe('markdown')
|
||
expect(elements[hrIdx + 1].text_size).toBe('notation')
|
||
})
|
||
|
||
it('caps to first 10 projects', () => {
|
||
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
|
||
...sampleProjects[0]!,
|
||
projectName: `proj-${i}`,
|
||
realPath: `/p/${i}`,
|
||
}))
|
||
const card = buildProjectPickerCard(many)
|
||
const rows = getRows(card)
|
||
expect(rows.length).toBe(10)
|
||
expect(getRowButton(rows[9]).value.realPath).toBe('/p/9')
|
||
})
|
||
|
||
it('uses ~ shortcut when path is under $HOME', () => {
|
||
const home = process.env.HOME
|
||
if (!home) return
|
||
const project: RecentProject = {
|
||
...sampleProjects[0]!,
|
||
realPath: `${home}/some/sub/dir`,
|
||
projectName: 'sub-dir',
|
||
}
|
||
const card = buildProjectPickerCard([project])
|
||
const pathEl = getRowInfoElements(getRows(card)[0])[1]
|
||
expect(pathEl.content).toBe('~/some/sub/dir')
|
||
})
|
||
|
||
it('middle-truncates very long paths with ellipsis', () => {
|
||
const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars
|
||
const project: RecentProject = {
|
||
...sampleProjects[0]!,
|
||
realPath: veryLong,
|
||
projectName: 'project',
|
||
}
|
||
const card = buildProjectPickerCard([project])
|
||
const content = getRowInfoElements(getRows(card)[0])[1].content
|
||
expect(content).toContain('…')
|
||
expect(content.length).toBeLessThanOrEqual(56)
|
||
expect(content.endsWith('project')).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('Feishu: card.action.trigger parsing', () => {
|
||
it('parses permit action from event', () => {
|
||
const event = {
|
||
operator: { open_id: 'ou_user_1' },
|
||
action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
|
||
context: { open_chat_id: 'oc_chat_123' },
|
||
}
|
||
|
||
expect(event.action.value.action).toBe('permit')
|
||
expect(event.action.value.requestId).toBe('abcde')
|
||
expect(event.action.value.allowed).toBe(true)
|
||
expect(event.context.open_chat_id).toBe('oc_chat_123')
|
||
})
|
||
|
||
it('parses pick_project action from event', () => {
|
||
const event = {
|
||
operator: { open_id: 'ou_user_1' },
|
||
action: {
|
||
value: {
|
||
action: 'pick_project',
|
||
realPath: '/Users/dev/claude-code-haha',
|
||
projectName: 'claude-code-haha',
|
||
},
|
||
},
|
||
context: { open_chat_id: 'oc_chat_123' },
|
||
}
|
||
|
||
expect(event.action.value.action).toBe('pick_project')
|
||
expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha')
|
||
expect(event.action.value.projectName).toBe('claude-code-haha')
|
||
})
|
||
|
||
it('ignores non-handled actions', () => {
|
||
const event = {
|
||
action: { value: { action: 'other_action' } },
|
||
}
|
||
expect(['permit', 'pick_project']).not.toContain(event.action.value.action)
|
||
})
|
||
})
|