cc-haha/desktop/src/lib/assistantOutputTargets.test.ts
程序员阿江(Relakkes) fc27397205 fix(desktop): anchor output chips and previews on real changed files
Fix four root causes in the desktop preview pipeline, surfaced when the
model writes the files the user pointed it at:

- Output chips guessed paths from prose and could point at a missing file.
  They are now reconciled against the turn's real changed files: a bare
  `index.html` resolves to the `todo-app/index.html` actually written, and
  mentions the turn never changed are dropped.
- A standalone single-page index.html got no browser preview (mistaken for a
  Vite template). It is now only routed to the source view when a
  package.json/vite.config ships in the same change-set.
- Files written outside the session workdir (another folder, or another drive
  on Windows) failed to preview with 'Path is outside workspace'. The turn's
  changed-file directories are registered as filesystem access roots; html
  serves via /local-file and other files via a workdir-relaxed read.
- The visual-selection prompt leaked as a raw bubble on Windows because the
  server-appended '[Image source: ...]' line broke replay dedupe. Replay text
  is now metadata-normalized before comparison (affects any image message).

Adds unit tests for each: htmlPreviewPolicy, assistantOutputTargets
reconciliation, replay dedupe + stripGeneratedImageMetadataLines, filesystem
access roots, and workspace outside-workdir reads.
2026-06-13 11:01:46 +08:00

332 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('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)
})
})