mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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.
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
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)
|
||
})
|
||
})
|