Files
superwork/vscode/webview-ui/src/codeReader/markdown.test.ts
T

212 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { featureAnswer } from '../../../src/codeReader/features/featureResult'
import { codeLink } from '../../../src/codeReader/qa/linkRule'
import { renderMarkdown, renderMarkdownDiff, renderMarkdownOnce } from './markdown'
import { repoImageInner } from './repoImage'
import { parseRepoLink } from './repoLink'
describe('renderMarkdown', () => {
it('一行文字是一个段落;段内换行是软换行,空行分段', () => {
expect(renderMarkdown('读配置。')).toBe('<p>读配置。</p>\n')
expect(renderMarkdown('第一行\n接着写\n\n第二段')).toBe('<p>第一行\n接着写</p>\n<p>第二段</p>\n')
})
it('行内代码(含双反引号)、强调、列表、代码块、标题、引用、表格', () => {
expect(renderMarkdown('先过 :func:`normalize_phone`,打印 ``user`` 时 **脱敏**')).toBe(
'<p>先过 :func:<code>normalize_phone</code>,打印 <code>user</code> 时 <strong>脱敏</strong></p>\n',
)
expect(renderMarkdown('- 甲\n- 乙')).toBe('<ul>\n<li>甲</li>\n<li>乙</li>\n</ul>\n')
expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('<pre><code class="language-ts">load(\'a\')\n</code></pre>\n')
expect(renderMarkdown('例子:\n\n f(1)')).toBe('<p>例子:</p>\n<pre><code>f(1)\n</code></pre>\n')
expect(renderMarkdown('# 标题')).toBe('<h1>标题</h1>\n')
expect(renderMarkdown('> 引用')).toBe('<blockquote>\n<p>引用</p>\n</blockquote>\n')
expect(renderMarkdown('| a | b |\n| - | - |\n| 1 | 2 |')).toContain('<table>')
})
it('源码里的 HTML 当文字显示', () => {
expect(renderMarkdown('用 <b>mask</b> <script>alert(1)</script>')).toBe(
'<p>用 &lt;b&gt;mask&lt;/b&gt; &lt;script&gt;alert(1)&lt;/script&gt;</p>\n',
)
expect(renderMarkdown('<div onclick="x()">块</div>')).toBe('<p>&lt;div onclick=&quot;x()&quot;&gt;块&lt;/div&gt;</p>\n')
})
it('带协议的网址、邮箱自动成链接;不带协议的文件名不算', () => {
expect(renderMarkdown('见 https://example.com/phone')).toBe('<p>见 <a href="https://example.com/phone">https://example.com/phone</a></p>\n')
expect(renderMarkdown('[文档](http://example.com)')).toBe('<p><a href="http://example.com">文档</a></p>\n')
expect(renderMarkdown('找 dev@example.com')).toContain('<a href="mailto:dev@example.com">')
expect(renderMarkdown('改 sample.py 和 README.md')).toBe('<p>改 sample.py 和 README.md</p>\n')
})
it('危险链接和跑出仓库的路径照原文显示;图片不渲染', () => {
expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain('<a')
expect(renderMarkdown('[命令](command:workbench.action.reloadWindow)')).not.toContain('<a')
expect(renderMarkdown('[相对](./x.md)')).not.toContain('<a')
expect(renderMarkdown('[上级](../secret)')).not.toContain('<a')
expect(renderMarkdown('[行号](file.py#123)')).not.toContain('<a')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
})
it('合法仓库图片路径会变成图片;问到之前只是路径', () => {
const path = 'frontend/src/components/overview/screenshots/OverviewSection.png'
const html = renderMarkdown(`![概览卡片](${path})`)
expect(html).toContain(`data-path="${path}"`)
expect(html).toContain(`>${path}<`)
expect(html).not.toContain('<img')
const src = 'https://file+.vscode-resource.vscode-cdn.net/OverviewSection.png'
const shown = repoImageInner(path, '概览卡片', { src })
expect(shown).toBe(`<a href="${path}"><img src="${src}" alt="概览卡片"></a>`)
})
it('http、..、./ 的图片不渲染', () => {
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('cr-repo-image')
expect(renderMarkdown('![图](../secret.png)')).not.toContain('<img')
expect(renderMarkdown('![图](../secret.png)')).not.toContain('cr-repo-image')
expect(renderMarkdown('![图](./local.png)')).not.toContain('<img')
expect(renderMarkdown('![图](./local.png)')).not.toContain('cr-repo-image')
})
it('文件不存在时是「找不到图片」加路径,不是图片', () => {
const shown = repoImageInner('frontend/missing.png', '图', 'missing')
expect(shown).toBe('找不到图片 frontend/missing.png')
expect(shown).not.toContain('<img')
})
it('仓库路径链接能点,带类名或类名.成员也一样', () => {
expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('<p><a href="vendor/agents">智能体定义</a></p>\n')
expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent">智能体表</a></p>\n')
expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent.owner_id">owner 字段</a></p>\n')
})
it('带几行的仓库链接也能点,注释和问 AI 的回答里一样', () => {
expect(renderMarkdown('[service.py:357-364](backend/app/service.py#L357-L364)', 'answer')).toBe('<p><a href="backend/app/service.py#L357-L364">service.py:357-364</a></p>\n')
expect(renderMarkdown('[第 3 行](backend/app/service.py#L3)')).toBe('<p><a href="backend/app/service.py#L3">第 3 行</a></p>\n')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
})
})
describe('不带路径的引用', () => {
it('注释里的 [Name]、[A.b] 是链接,地址 symbol:名字', () => {
expect(renderMarkdown('见 [LocalRuntimeDisabled]。')).toBe('<p>见 <a href="symbol:LocalRuntimeDisabled">LocalRuntimeDisabled</a>。</p>\n')
expect(renderMarkdown('[Agent.owner_id]')).toBe('<p><a href="symbol:Agent.owner_id">Agent.owner_id</a></p>\n')
expect(renderMarkdown('转写谁说的话: [USER] / [ASSISTANT]')).toBe('<p>转写谁说的话: <a href="symbol:USER">USER</a> / <a href="symbol:ASSISTANT">ASSISTANT</a></p>\n')
expect(renderMarkdown('见[Name]')).toContain('<a href="symbol:Name">')
})
it('反引号里的、下标、中文、后面跟 (…) 的完整链接都不算', () => {
expect(renderMarkdown('`[x]`')).toBe('<p><code>[x]</code></p>\n')
expect(renderMarkdown('items[i] 和 f()[k]、a[0][1]')).toBe('<p>items[i] 和 f()[k]、a[0][1]</p>\n')
expect(renderMarkdown('以 [会话进展] 开头')).toBe('<p>以 [会话进展] 开头</p>\n')
expect(renderMarkdown('[名字](backend/a.py#Agent)')).toBe('<p><a href="backend/a.py#Agent">名字</a></p>\n')
expect(renderMarkdown('[Agent](backend/a.py#Agent)')).toBe('<p><a href="backend/a.py#Agent">Agent</a></p>\n')
expect(renderMarkdown('[Agent](javascript:x)')).not.toContain('<a')
})
it('问 AI 的回答不认;带增删的注释照样认', () => {
expect(renderMarkdown('见 [Agent]', 'answer')).toBe('<p>见 [Agent]</p>\n')
expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"')
})
it('定义卡片浮窗里别的文件的注释不认(引用按注释所在的文件找);仓库路径链接照样是链接', () => {
expect(renderMarkdown('见 [Agent]', 'peek')).toBe('<p>见 [Agent]</p>\n')
expect(renderMarkdown('见 [Agent](app/models.py#Agent)', 'peek')).toContain('<a href="app/models.py#Agent">')
})
})
describe('renderMarkdownDiff', () => {
it('照常排版,新加的字包进 <ins>、删掉的包进 <del>', () => {
expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }]))
.toBe('<p>放锁<del class="cr-diff-word">前</del><ins class="cr-diff-word">都</ins>必须比对 <code>token</code></p>\n')
})
it('跨行的一截逐行包,新加的列表项留住列表记号', () => {
expect(renderMarkdownDiff([{ text: '- a\n' }, { text: '- b\n- c', change: 'add' }]))
.toBe('<ul>\n<li>a</li>\n<li><ins class="cr-diff-word">b</ins></li>\n<li><ins class="cr-diff-word">c</ins></li>\n</ul>\n')
})
it('行中间的一截不把前面的字当块记号', () => {
expect(renderMarkdownDiff([{ text: '共 ' }, { text: '# 3', change: 'add' }])).toBe('<p>共 <ins class="cr-diff-word"># 3</ins></p>\n')
})
})
describe('mermaid 代码块', () => {
const source = 'flowchart TD\n A["读 <配置> & 写"] --> B{"成功?"}\n'
const escaped = 'flowchart TD\n A[&quot;读 &lt;配置&gt; &amp; 写&quot;] --&gt; B{&quot;成功?&quot;}\n'
const codeBlock = `<pre><code class="language-mermaid">${escaped}</code></pre>\n`
const placeholder = `<div class="cr-mermaid" data-source="${escaped}">${codeBlock}</div>\n`
it('出占位:转义后的源码记在 data-source,里面先放原样的代码块', () => {
expect(renderMarkdown(`说明\n\n\`\`\`mermaid\n${source}\`\`\``)).toBe(`<p>说明</p>\n${placeholder}`)
expect(renderMarkdown(`\`\`\`mermaid\n${source}\`\`\``, 'peek')).toBe(placeholder)
expect(renderMarkdown(`\`\`\`mermaid\n${source}\`\`\``, 'answer')).toBe(placeholder)
})
it('别的代码块照旧', () => {
expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('<pre><code class="language-ts">load(\'a\')\n</code></pre>\n')
expect(renderMarkdown('```\nflowchart TD\n```')).toBe('<pre><code>flowchart TD\n</code></pre>\n')
expect(renderMarkdown('```mermaidx\nA\n```')).not.toContain('cr-mermaid')
})
it('带增删的说明、还在逐字长的回答里仍是普通代码块', () => {
expect(renderMarkdownDiff([{ text: `\`\`\`mermaid\n${source}\`\`\`` }])).toBe(codeBlock)
expect(renderMarkdownOnce(`\`\`\`mermaid\n${source}\`\`\``)).toBe(codeBlock)
})
})
describe('问 AI 的回答里写成行内代码的代码位置', () => {
it('整段是代码位置的行内代码包成仓库链接,逐字长着的回答也一样', () => {
expect(renderMarkdown('见 `backend/app/routes.py:96-135`。', 'answer')).toBe('<p>见 <a href="backend/app/routes.py#L96-L135"><code>backend/app/routes.py:96-135</code></a>。</p>\n')
expect(renderMarkdown('`web/src/a.ts`', 'answer')).toBe('<p><a href="web/src/a.ts"><code>web/src/a.ts</code></a></p>\n')
expect(renderMarkdownOnce('见 `backend/app/routes.py:96`')).toBe('<p>见 <a href="backend/app/routes.py#L96"><code>backend/app/routes.py:96</code></a></p>\n')
})
it('不像代码位置的、已经在链接里的不包;注释里的不包', () => {
expect(renderMarkdown('`12:30` 和 `a:b`、`token`', 'answer')).toBe('<p><code>12:30</code> 和 <code>a:b</code>、<code>token</code></p>\n')
expect(renderMarkdown('[`backend/app/routes.py:96`](backend/app/routes.py#L96)', 'answer')).toBe('<p><a href="backend/app/routes.py#L96"><code>backend/app/routes.py:96</code></a></p>\n')
expect(renderMarkdown('见 `backend/app/routes.py:96-135`')).toBe('<p>见 <code>backend/app/routes.py:96-135</code></p>\n')
})
})
describe('「了解一块代码」的回答', () => {
/** 回答里各个链接的地址,按页面认仓库链接的办法拆开 */
const links = (html: string): unknown[] => [...html.matchAll(/<a href="([^"]*)">/g)].map(([, href]) => parseRepoLink(href.replaceAll('&amp;', '&')))
it('调用树画成嵌套列表,节点是仓库链接:路径带成对的括号(Next.js 的路由分组)也认成链接,via 是行内代码', () => {
const answer = featureAnswer({ paths: [{ name: '首页发出首句', nodes: [
{ id: '1', folder: 'web', path: 'src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage', role: '会话页' },
{ id: '2', parent: '1', folder: 'server', path: 'app/routes.py', symbol: 'create_app', via: 'POST /apps', role: '建应用' },
{ id: '3', parent: '2', folder: 'server', path: 'app/models.py', role: '应用表' },
] }] }, ['web', 'server'])
const html = renderMarkdown(answer, 'answer')
expect(html).toBe([
'<p><strong>1. 首页发出首句</strong></p>',
'<ul>',
'<li><a href="web/src/app/(main)/chat/code/page.tsx#ChatCodePage">ChatCodePage</a>:会话页',
'<ul>',
'<li><code>POST /apps</code> → <a href="server/app/routes.py#create_app">create_app</a>:建应用',
'<ul>',
'<li><a href="server/app/models.py">models.py</a>:应用表</li>',
'</ul>',
'</li>',
'</ul>',
'</li>',
'</ul>',
'',
].join('\n'))
expect(links(html)).toEqual([
{ path: 'web/src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage' },
{ path: 'server/app/routes.py', symbol: 'create_app' },
{ path: 'server/app/models.py' },
])
})
it('路径里有空白、百分号、不成对的括号:编码后照样认成链接,拆出来是原来的路径', () => {
const html = renderMarkdown([codeLink('a', 'docs/a b.md'), codeLink('b', 'docs/100%.md'), codeLink('c', 'docs/a(b.md', 'Doc')].join(' '), 'answer')
expect(links(html)).toEqual([{ path: 'docs/a b.md' }, { path: 'docs/100%.md' }, { path: 'docs/a(b.md', symbol: 'Doc' }])
})
})