cc-haha/desktop/src/lib/assistantOutputTargets.test.ts
程序员阿江(Relakkes) a2e954b0f9 fix(desktop): ignore log links in output cards (#714)
Output target detection should not promote URLs or markdown links embedded in fenced log/code blocks into assistant output cards. Keep explicit localhost and file references outside code blocks routable.

Tested: cd desktop && bun run test -- src/lib/assistantOutputTargets.test.ts src/components/chat/AssistantMessage.linkrouting.test.tsx
Tested: cd desktop && bun run lint
Confidence: high
Scope-risk: narrow
2026-06-15 17:32:53 +08:00

356 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import { extractAssistantOutputTargets } from './assistantOutputTargets'
const workDir = '/Users/nanmi/project/demo'
describe('extractAssistantOutputTargets', () => {
it('extracts markdown links for workspace html, markdown, and images', () => {
const content = [
'已完成:',
'- [index.html](/Users/nanmi/project/demo/index.html)',
'- [notes](docs/result.md)',
'- [preview](assets/hero.png)',
].join('\n')
const targets = extractAssistantOutputTargets(content, { workDir })
expect(targets.map((target) => [target.kind, target.href, target.normalizedPath])).toEqual([
['local-html', '/Users/nanmi/project/demo/index.html', 'index.html'],
['markdown', 'docs/result.md', 'docs/result.md'],
['image', 'assets/hero.png', 'assets/hero.png'],
])
})
it('detects a naked relative video path as a video target', () => {
const targets = extractAssistantOutputTargets('渲染完成,见 outputs/clip.mp4 。', { workDir })
expect(targets).toMatchObject([
{
kind: 'video',
href: 'outputs/clip.mp4',
normalizedPath: 'outputs/clip.mp4',
source: 'plain-path',
},
])
})
it('detects a markdown link to a video as a video target', () => {
const targets = extractAssistantOutputTargets('[v](demo.webm)', { workDir })
expect(targets).toMatchObject([
{
kind: 'video',
href: 'demo.webm',
normalizedPath: 'demo.webm',
source: 'markdown-link',
},
])
})
it('rejects a video path outside the active workspace (sandbox)', () => {
const targets = extractAssistantOutputTargets('[bad](/etc/x.mp4)', { workDir })
expect(targets).toEqual([])
})
it('normalizes markdown destinations with angle brackets, spaces, and line suffixes', () => {
const content = [
'[html](</Users/nanmi/project/demo/My Page/index.html>)',
'[lined](/Users/nanmi/project/demo/index.html:12)',
].join('\n')
const targets = extractAssistantOutputTargets(content, { workDir })
expect(targets.map((target) => [target.href, target.normalizedPath])).toEqual([
['/Users/nanmi/project/demo/My Page/index.html', 'My Page/index.html'],
['/Users/nanmi/project/demo/index.html', 'index.html'],
])
})
it('accepts safe Windows workspace paths with case-insensitive segments', () => {
const targets = extractAssistantOutputTargets(
'[Preview](C:/users/nanmi/project/demo/out/index.html)',
{ workDir: 'C:/Users/nanmi/project/demo' },
)
expect(targets).toMatchObject([
{
kind: 'local-html',
href: 'C:/users/nanmi/project/demo/out/index.html',
normalizedPath: 'out/index.html',
},
])
})
it('accepts absolute paths when the workdir is filesystem root', () => {
const targets = extractAssistantOutputTargets(
'[Preview](/tmp/demo/index.html)',
{ workDir: '/' },
)
expect(targets).toMatchObject([
{
kind: 'local-html',
href: '/tmp/demo/index.html',
normalizedPath: 'tmp/demo/index.html',
},
])
})
it('extracts naked localhost and loopback URLs', () => {
const targets = extractAssistantOutputTargets(
'Open http://localhost:5173 and http://127.0.0.1:3000/app and http://[::1]:4173/app now.',
{ workDir },
)
expect(targets).toMatchObject([
{ kind: 'localhost-url', href: 'http://localhost:5173' },
{ kind: 'localhost-url', href: 'http://127.0.0.1:3000/app' },
{ kind: 'localhost-url', href: 'http://[::1]:4173/app' },
])
})
it('trims markdown/code punctuation around naked localhost URLs', () => {
const targets = extractAssistantOutputTargets(
'地址:`http://localhost:9527/`备用http://127.0.0.1:3000/app)。',
{ workDir },
)
expect(targets.map((target) => target.href)).toEqual([
'http://localhost:9527/',
'http://127.0.0.1:3000/app',
])
})
it('ignores localhost URLs printed inside fenced log output', () => {
const targets = extractAssistantOutputTargets(
[
'日志前 50 行:',
'```log',
'[08:29:36][INFO] 代理服务已启动: 127.0.0.1:15721',
'[08:29:36][INFO] Claude Live 配置已接管,代理地址: http://127.0.0.1:15721',
'```',
].join('\n'),
{ workDir },
)
expect(targets).toEqual([])
})
it('ignores markdown links printed inside fenced code blocks', () => {
const targets = extractAssistantOutputTargets(
['```md', '调试输出: [preview](http://localhost:5173/) [page](index.html)', '```'].join('\n'),
{ workDir },
)
expect(targets).toEqual([])
})
it('keeps markdown localhost links as markdown-link targets with authored labels', () => {
const targets = extractAssistantOutputTargets(
'[Preview](http://localhost:4173) then http://localhost:4173',
{ workDir },
)
expect(targets).toMatchObject([
{
kind: 'localhost-url',
href: 'http://localhost:4173',
title: 'Preview',
source: 'markdown-link',
},
])
expect(targets).toHaveLength(1)
})
it('rejects paths outside the active workspace', () => {
const targets = extractAssistantOutputTargets(
'[secret](/Users/nanmi/private/secret.html) [ok](/Users/nanmi/project/demo/public/index.html)',
{ workDir },
)
expect(targets).toHaveLength(1)
expect(targets[0]).toMatchObject({
kind: 'local-html',
normalizedPath: 'public/index.html',
})
})
it('deduplicates repeated targets while preserving order', () => {
const content = [
'[index](index.html)',
'Again: http://localhost:5173',
'[index copy](./index.html)',
'Again: http://localhost:5173',
].join('\n')
const targets = extractAssistantOutputTargets(content, { workDir })
expect(targets.map((target) => target.href)).toEqual(['index.html', 'http://localhost:5173'])
})
it('extracts files from absolute-root directory trees inside code blocks', () => {
const targets = extractAssistantOutputTargets(
[
'目录结构',
'```',
'/Users/nanmi/project/demo/generated/',
'├── README.md # Markdown 说明文件',
'├── index.html # 静态页面',
'└── todo-app/',
' └── index.html',
'```',
].join('\n'),
{ workDir },
)
expect(targets.map((target) => [target.href, target.normalizedPath])).toEqual([
['/Users/nanmi/project/demo/generated/README.md', 'generated/README.md'],
['/Users/nanmi/project/demo/generated/index.html', 'generated/index.html'],
['/Users/nanmi/project/demo/generated/todo-app/index.html', 'generated/todo-app/index.html'],
])
})
it('ignores orphan preview file names inside code blocks', () => {
const targets = extractAssistantOutputTargets(
['```', 'index.html', 'README.md', '```'].join('\n'),
{ workDir },
)
expect(targets).toEqual([])
})
it('preserves first-seen order across mixed target types', () => {
const targets = extractAssistantOutputTargets(
'Open http://localhost:5173 first, then [index](index.html), then docs/guide.md.',
{ workDir },
)
expect(targets.map((target) => target.href)).toEqual([
'http://localhost:5173',
'index.html',
'docs/guide.md',
])
})
it('limits the result set to high-confidence preview targets', () => {
const targets = extractAssistantOutputTargets(
'Read https://example.com and maybe file:///etc/passwd, but use report.pdf only externally.',
{ workDir },
)
expect(targets).toEqual([])
})
it('caps results at 6 by default', () => {
const targets = extractAssistantOutputTargets(
[
'[one](one.html)',
'[two](two.html)',
'[three](three.html)',
'[four](four.html)',
'[five](five.html)',
'[six](six.html)',
'[seven](seven.html)',
].join('\n'),
{ workDir },
)
expect(targets.map((target) => target.href)).toEqual([
'one.html',
'two.html',
'three.html',
'four.html',
'five.html',
'six.html',
])
})
it('respects an explicit limit override', () => {
const targets = extractAssistantOutputTargets(
'[one](one.html) [two](two.html) [three](three.html)',
{ workDir, limit: 2 },
)
expect(targets.map((target) => target.href)).toEqual(['one.html', 'two.html'])
})
})
describe('extractAssistantOutputTargets with changedFiles reconciliation', () => {
it('corrects a bare mention to the real changed path in a subfolder', () => {
// The reported bug: the model writes /private/tmp/todo-app/index.html but the
// prose only says `index.html`, so the chip used to point at the (missing)
// workdir-root index.html. With the turn's real changed files it is corrected.
const targets = extractAssistantOutputTargets('已创建 `index.html`,直接用浏览器打开。', {
workDir: '/private/tmp',
changedFiles: [
'/private/tmp/todo-app/index.html',
'/private/tmp/todo-app/style.css',
'/private/tmp/todo-app/app.js',
],
})
expect(targets).toHaveLength(1)
expect(targets[0]).toMatchObject({
kind: 'local-html',
href: 'todo-app/index.html',
normalizedPath: 'todo-app/index.html',
subtitle: 'todo-app/index.html',
})
})
it('drops a mentioned file that the turn never changed', () => {
const targets = extractAssistantOutputTargets('参考旧文件 old-report.html 和新结果 result.html', {
workDir: '/work',
changedFiles: ['/work/out/result.html'],
})
expect(targets.map((target) => target.normalizedPath)).toEqual(['out/result.html'])
})
it('keeps localhost url chips untouched while reconciling files', () => {
const targets = extractAssistantOutputTargets('启动后访问 http://localhost:5173/ ,源码见 index.html', {
workDir: '/work',
changedFiles: ['/work/app/index.html'],
})
const byKind = new Map(targets.map((target) => [target.kind, target]))
expect(byKind.get('localhost-url')?.href).toBe('http://localhost:5173/')
expect(byKind.get('local-html')?.normalizedPath).toBe('app/index.html')
})
it('rewrites a changed file outside the workdir to its absolute posix path', () => {
const targets = extractAssistantOutputTargets('已创建 todo.html', {
workDir: 'C:/Users/me/tmp/session',
changedFiles: ['D:\\workspace\\demo\\todo.html'],
})
expect(targets).toHaveLength(1)
expect(targets[0]).toMatchObject({
kind: 'local-html',
href: 'D:/workspace/demo/todo.html',
normalizedPath: 'D:/workspace/demo/todo.html',
})
})
it('falls back to text-only behavior when changedFiles is empty', () => {
const targets = extractAssistantOutputTargets('已创建 `index.html`', {
workDir: '/private/tmp',
changedFiles: [],
})
// No reconciliation → original bare-path behavior (mention kept as-is).
expect(targets).toMatchObject([{ kind: 'local-html', normalizedPath: 'index.html' }])
})
it('does not correct when the basename is ambiguous across changed files', () => {
const targets = extractAssistantOutputTargets('见 index.html', {
workDir: '/work',
changedFiles: ['/work/a/index.html', '/work/b/index.html'],
})
// Ambiguous basename match → no unique target, mention dropped rather than guessed.
expect(targets).toHaveLength(0)
})
})