From 7f634866bfcdc90ba2d213b275cb99dcf47058a7 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 24 Aug 2026 12:05:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(editor):=20Markdown=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=86=85=E5=B5=8C=20HTML=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20+=20=E7=9B=AE=E5=BD=95=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=EF=BC=88TOC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主 MarkdownText 出于聊天安全把 raw HTML 按字面文本渲染,预览 GitHub 风格 README(徽章墙 div、details 折叠块内嵌 markdown、表格单元格内联 标签、video/picture)时全是源码文本。本次在不动渲染器的前提下: - markdown-html.ts(纯函数):fence 感知切分器把文档切成 markdown/html 段(CommonMark type-6 块级标签名单 + 注释段 + 空行终止);段内标签 tokenizer 归约 open/close/html parts;全文档引用定义收集(每段追加 保持 [text][id] 跨段语义) - MarkdownHtml.tsx:DOMPurify 白名单消毒(显式禁 script/iframe/form 等;a 强制 _blank+noopener;本地媒体 src 经 /sidebar/file 重写); MarkdownDocument 帧栈状态机让
跨段嵌套内部 markdown; MarkdownSegment 内联替换(MutationObserver 幂等 pass,把表格单元格 等处标签文本消毒还原为元素,纯散文不误伤) - md-toc.tsx:零高度 sticky 浮动目录按钮(≥3 标题)+ 弹出大纲,跳转 平滑滚动 + 自动展开折叠 details + 高亮闪现。关键修复:子组件读父 ref 在 React layout 阶段恒为 null(子先于父),改为经自身 bar ref 的 parentElement 定位容器(jsdom 单测传预填 ref 掩盖了该 bug,仅 真实挂载 e2e 抓到) - 纯 markdown 文档走原单次解析路径零变化(现有 mermaid e2e 守卫一字 不动);新增 readme-style.md 种子 + 探针(消毒渲染/嵌套/内联/TOC 全链路);19 语言词典新增 toc 键;dompurify 提升为直接依赖(与 mermaid 传递依赖去重单版本) 设计文档 docs/plans/2026-08-24-markdown-html-toc-design.md --- AGENTS.md | 3 +- README.md | 4 +- .../2026-08-24-markdown-html-toc-design.md | 52 +++ package.json | 1 + pnpm-lock.yaml | 3 + src/client/MarkdownHtml.tsx | 295 ++++++++++++++++ src/client/TextEditor.tsx | 63 ++-- src/client/locales-ar.ts | 1 + src/client/locales-de.ts | 1 + src/client/locales-fr.ts | 1 + src/client/locales-hi.ts | 1 + src/client/locales-id.ts | 1 + src/client/locales-it.ts | 1 + src/client/locales-ja.ts | 1 + src/client/locales-ko.ts | 1 + src/client/locales-nl.ts | 1 + src/client/locales-pl.ts | 1 + src/client/locales-pt.ts | 1 + src/client/locales-ru.ts | 1 + src/client/locales-sv.ts | 1 + src/client/locales-th.ts | 1 + src/client/locales-tr.ts | 1 + src/client/locales-vi.ts | 1 + src/client/locales-zh-HK.ts | 1 + src/client/locales-zh-MO.ts | 1 + src/client/locales-zh-TW.ts | 1 + src/client/locales.ts | 2 + src/client/markdown-html.ts | 331 ++++++++++++++++++ src/client/markdown-images.ts | 45 ++- src/client/md-toc.tsx | 127 +++++++ src/client/mermaid-blocks.ts | 6 +- src/client/sidebar.module.css | 132 +++++++ tests/e2e/mount.e2e.ts | 109 ++++++ tests/markdown-html-render.spec.tsx | 176 ++++++++++ tests/markdown-html.spec.ts | 196 +++++++++++ tests/md-toc.spec.tsx | 152 ++++++++ 36 files changed, 1673 insertions(+), 43 deletions(-) create mode 100644 docs/plans/2026-08-24-markdown-html-toc-design.md create mode 100644 src/client/MarkdownHtml.tsx create mode 100644 src/client/markdown-html.ts create mode 100644 src/client/md-toc.tsx create mode 100644 tests/markdown-html-render.spec.tsx create mode 100644 tests/markdown-html.spec.ts create mode 100644 tests/md-toc.spec.tsx diff --git a/AGENTS.md b/AGENTS.md index 9498adf..db06d1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,7 +437,7 @@ interface FileViewerProps { > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 -> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。 +> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead,**README 级内嵌 HTML 支持**——块级 HTML 段按文档顺序 DOMPurify 白名单消毒内联渲染、`
` 跨段嵌套内部 markdown、markdown 表格单元格等处的内联标签消毒还原为元素、本地媒体 src 重写走 `/sidebar/file`;预览顶部浮动目录大纲按钮(≥3 标题出现,点击条目平滑滚动并自动展开折叠 `
`;纯 markdown 文档走原单次解析路径零变化。实现 `src/client/markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。 ### 4.5 注册示例 @@ -729,6 +729,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮" - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据) - **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护 - **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`) +- **`src/client/markdown-html.ts`** / **`src/client/MarkdownHtml.tsx`** / **`src/client/md-toc.tsx`**:markdown 预览的内嵌 HTML 渲染(fence 感知切分器 + DOMPurify 消毒管线 + 跨段 `
` 嵌套状态机 + 内联标签 commit 后还原 + MutationObserver 迟到内容)与目录大纲(DOM 收集 + sticky 浮动按钮 + 跳转展开折叠块;注意 `md-toc.tsx` 头注释记录的「子组件读父 ref 为 null」React 时序陷阱)。测试 `tests/markdown-html.spec.ts` / `tests/markdown-html-render.spec.tsx` / `tests/md-toc.spec.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md` - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/README.md b/README.md index 4e739c8..127d74f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF +- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `
` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 @@ -155,7 +155,7 @@ dsh registry enable dsh-external/dsh-better-sidebar | | | |---|---| -| **🗂️ 文件工作台:资源管理器**
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
文件资源管理器
| **📝 Markdown · 图片 · PDF 内联预览**
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。
Markdown + Mermaid 预览
| +| **🗂️ 文件工作台:资源管理器**
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
文件资源管理器
| **📝 Markdown · 图片 · PDF 内联预览**
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `
`、`
` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`', + '
', + '', + '## Setup', + '', + '[docs link][def]', + '', + '
', + 'Steps', + '', + '### Inside', + '', + '```sh', + 'dsh plugin --profile web add x', + '```', + '', + '
', + '', + '## Table', + '', + '| col | note |', + '| --- | --- |', + '| alpha | line one
line two |', + '', + '[def]: https://example.com/def', + '', + ].join('\n')) const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, }) @@ -433,6 +473,75 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ).toBeHidden() await assertNoCrash() + // README-style markdown (raw-HTML runs + TOC): open the seeded file and + // require the full round-trip — sanitized HTML leaves (badge image as a + // real element, active content stripped), markdown nested inside the + // unclosed
run, the inline pass turning the table cell's
+ // into an element, the reference link resolving across the HTML run, and + // the TOC outline jumping into the collapsed details (auto-expanding it). + await sidebar.locator('[title="Files"][draggable="true"]').first().click() + const readmeRow = sidebar.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`) + await expect( + readmeRow, + `the seeded "${SEEDED_README_FILE}" file must appear in the files window's tree`, + ).toHaveCount(1, { timeout: 30_000 }) + await readmeRow.click({ position: { x: 8, y: 8 } }) + await expect( + sidebar.locator(`[title="${SEEDED_README_FILE}"][draggable="true"]`), + 'separate mode opens a new tab for the README-style markdown file', + ).toHaveCount(1, { timeout: 30_000 }) + // The hero div renders as a sanitized leaf with the badge image as a real + // element (the remote src may not load in the sandboxed lane; the ELEMENT + // is the proof of rendering, not the bytes). + await expect( + sidebar.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'), + 'the badge-wall div must render its image as a real element', + ).toHaveCount(1, { timeout: 30_000 }) + // Active content never survives sanitization. + await expect( + sidebar.locator('script'), + 'the embedded ', + ' link', + '', + ].join('\n')) + expect(container.querySelector('script')).toBeNull() + const anchor = container.querySelector('a[href="https://example.com"]') + expect(anchor).not.toBeNull() + expect(anchor?.getAttribute('target')).toBe('_blank') + expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer') + await unmount(root) + }) + + it('rewrites local media sources through the /sidebar/file route', async () => { + const { container, root } = await renderDocument('shot') + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fshot.png&cwd=%2Fws') + await unmount(root) + }) +}) + +describe('MarkdownDocument (nesting + markdown runs)', () => { + it('nests markdown runs inside an unclosed
element', async () => { + const { container, root } = await renderDocument([ + '
', + '更新', + '', + '```sh', + 'dsh plugin --profile web add x', + '```', + '', + '
', + '', + 'after the fold', + ].join('\n')) + const details = container.querySelector('details') + expect(details, 'the open part must lower following runs into a details element').not.toBeNull() + expect(details?.querySelector('summary')).not.toBeNull() + expect(details?.textContent).toContain('dsh plugin --profile web add x') + // The close part pops the frame: the trailing markdown is a sibling. + expect(details?.textContent).not.toContain('after the fold') + expect(container.querySelector('details')?.nextElementSibling?.textContent).toContain('after the fold') + await unmount(root) + }) + + it('keeps reference definitions resolving across lifted HTML runs', async () => { + const { container, root } = await renderDocument([ + 'see [the docs][shared]', + '', + '
badges
', + '', + '[shared]: https://example.com/docs', + ].join('\n')) + const anchor = container.querySelector('a[href="https://example.com/docs"]') + expect(anchor, 'a definition after an HTML run must still resolve').not.toBeNull() + expect(anchor?.textContent).toContain('the docs') + await unmount(root) + }) + + it('renders a forbidden wrapper as transparent (children survive)', async () => { + const { container, root } = await renderDocument([ + '
', + '', + 'inner text', + '', + '
', + ].join('\n')) + expect(container.querySelector('form')).toBeNull() + expect(container.textContent).toContain('inner text') + await unmount(root) + }) +}) + +describe('MarkdownDocument (inline pass)', () => { + it('turns tag text in table cells back into elements', async () => { + const { container, root } = await renderDocument([ + '| feature | notes |', + '|---|---|', + '| alpha | line one
line two |', + ].join('\n')) + const cell = [...container.querySelectorAll('td')].find((td) => td.textContent?.includes('line one')) + expect(cell?.querySelector('[data-html-inline] br'), 'the
must render as a real element').not.toBeNull() + await unmount(root) + }) + + it('leaves rendered code and comparison prose untouched', async () => { + const { container, root } = await renderDocument([ + 'a < b comparison stays text', + '', + '```html', + '
demo
', + '```', + ].join('\n')) + expect(container.querySelectorAll('[data-html-inline]').length).toBe(0) + expect(container.textContent).toContain('a < b comparison stays text') + const code = container.querySelector('code') + expect(code?.textContent).toContain('
demo
') + await unmount(root) + }) + + it('rewrites local image src inside inline-swapped tags', async () => { + const { container, root } = await renderDocument('text i tail') + const img = container.querySelector('[data-html-inline] img') + expect(img?.getAttribute('src')).toContain('/sidebar/file?') + await unmount(root) + }) +}) diff --git a/tests/markdown-html.spec.ts b/tests/markdown-html.spec.ts new file mode 100644 index 0000000..b00eaa7 --- /dev/null +++ b/tests/markdown-html.spec.ts @@ -0,0 +1,196 @@ +/** + * markdown-html splitter spec: the pure lifting of raw-HTML runs out of the + * markdown stream (fence-aware), the tag-balance part analysis (unclosed + * `
` → wrapper opens, stray closes → pops), the doc-wide reference + * definition collection, and the inline-html gate. + */ +import { describe, expect, it } from 'vitest' +import { + analyzeHtmlSegment, + analyzeMarkdownHtml, + collectReferenceDefinitions, + splitHtmlBlocks, + type HtmlPart, +} from '../src/client/markdown-html.ts' + +/** Structural view of parts: html parts as [html ], opens/closes by kind. */ +function shape(parts: readonly HtmlPart[]): string[] { + return parts.map((part) => part.kind === 'html' + ? `html(${part.html})` + : part.kind === 'open' + ? `open(${part.tag}${part.attrs.trim() !== '' ? ` ${part.attrs.trim()}` : ''})` + : `close(${part.tag})`) +} + +describe('splitHtmlBlocks', () => { + it('returns one markdown segment for plain markdown (no HTML)', () => { + const text = '# Title\n\nsome **markdown**\n\n- a\n- b\n' + expect(splitHtmlBlocks(text)).toEqual([{ kind: 'markdown', text: text.replace(/\n$/, '') }]) + }) + + it('lifts a balanced block-tag run out of the markdown stream', () => { + const segments = splitHtmlBlocks([ + '# Title', + '', + '
', + ' badge', + '
', + '', + 'tail text', + ].join('\n')) + expect(segments).toEqual([ + { kind: 'markdown', text: '# Title' }, + { kind: 'html', text: '
\n badge\n
' }, + { kind: 'markdown', text: 'tail text' }, + ]) + }) + + it('ends an HTML run at a blank line', () => { + const segments = splitHtmlBlocks('

a

\n\nafter') + expect(segments).toEqual([ + { kind: 'html', text: '

a

' }, + { kind: 'markdown', text: 'after' }, + ]) + }) + + it('does not lift tag-like lines inside fenced code blocks', () => { + const segments = splitHtmlBlocks([ + '```html', + '
', + '

example

', + '
', + '```', + '', + '
real
', + ].join('\n')) + expect(segments).toEqual([ + { kind: 'markdown', text: '```html\n
\n

example

\n
\n```' }, + { kind: 'html', text: '
real
' }, + ]) + }) + + it('does not lift inline-only tags or prose starting with a comparison', () => { + const segments = splitHtmlBlocks([ + 'a < b and x inline', + '
alone on its line', + ].join('\n')) + expect(segments).toEqual([ + { kind: 'markdown', text: 'a < b and x inline\n
alone on its line' }, + ]) + }) + + it('treats a close-tag-only line as an HTML run', () => { + const segments = splitHtmlBlocks('text\n\n
\n\nmore') + expect(segments).toEqual([ + { kind: 'markdown', text: 'text' }, + { kind: 'html', text: '
' }, + { kind: 'markdown', text: 'more' }, + ]) + }) + + it('accumulates a comment run until the closing marker line', () => { + const segments = splitHtmlBlocks('\n\nbody') + expect(segments).toEqual([ + { kind: 'html', text: '' }, + { kind: 'markdown', text: 'body' }, + ]) + }) + + it('keeps an unterminated fence as markdown (CommonMark recovery)', () => { + const segments = splitHtmlBlocks('```\n
x
') + expect(segments).toEqual([{ kind: 'markdown', text: '```\n
x
' }]) + }) + + it('keeps the README details shape as three segments', () => { + const segments = splitHtmlBlocks([ + '
', + '更新', + '', + '```sh', + 'dsh plugin --profile web add x', + '```', + '', + '
', + ].join('\n')) + expect(segments.map((segment) => segment.kind)).toEqual(['html', 'markdown', 'html']) + expect(segments[0]).toEqual({ kind: 'html', text: '
\n更新' }) + expect(segments[2]).toEqual({ kind: 'html', text: '
' }) + }) +}) + +describe('analyzeHtmlSegment', () => { + it('reduces a balanced run to a single html part', () => { + expect(shape(analyzeHtmlSegment('
').parts)) + .toEqual(['html(
)']) + }) + + it('surfaces an unclosed open tag as a wrapper with its inner html', () => { + expect(shape(analyzeHtmlSegment('
\n更新').parts)) + .toEqual(['open(details)', 'html(\n更新)']) + }) + + it('surfaces an unmatched close as a pop', () => { + expect(shape(analyzeHtmlSegment('
').parts)).toEqual(['close(details)']) + }) + + it('orders mixed leading closes before following html and closes', () => { + expect(shape(analyzeHtmlSegment(' mid
').parts)) + .toEqual(['close(div)', 'html( mid )', 'close(details)']) + }) + + it('nests multiple unclosed opens outermost-first', () => { + expect(shape(analyzeHtmlSegment('
').parts)) + .toEqual(['open(div align="center")', 'open(details)']) + }) + + it('keeps attrs of the open token for the wrapper', () => { + const parts = analyzeHtmlSegment('
body').parts + expect(parts[0]).toEqual({ kind: 'open', tag: 'details', attrs: ' open class="x"' }) + }) + + it('ignores void and self-closing tags for balance', () => { + expect(shape(analyzeHtmlSegment('
after').parts)) + .toEqual(['html(
after)']) + }) + + it('ignores tags inside comments', () => { + expect(shape(analyzeHtmlSegment('').parts)) + .toEqual(['html()']) + }) + + it('pops implicitly through a mismatched close', () => { + //

is implicitly closed by

: nothing structural remains. + expect(shape(analyzeHtmlSegment('

text

').parts)) + .toEqual(['html(

text

)']) + }) +}) + +describe('collectReferenceDefinitions + analyzeMarkdownHtml', () => { + it('collects definitions from markdown runs only, in document order', () => { + const info = analyzeMarkdownHtml([ + 'intro [link][shared]', + '', + '
badges
', + '', + '[shared]: https://example.com', + '[img]: ./local.png', + ].join('\n')) + expect(info.referenceDefinitions).toBe('[shared]: https://example.com\n[img]: ./local.png') + expect(collectReferenceDefinitions(info.segments)).toBe(info.referenceDefinitions) + }) + + it('reports hasBlockHtml only when a run was lifted', () => { + expect(analyzeMarkdownHtml('# plain\ntext').hasBlockHtml).toBe(false) + expect(analyzeMarkdownHtml('
x
').hasBlockHtml).toBe(true) + // Inline-only tags: no block html, but the inline gate is on. + const inline = analyzeMarkdownHtml('| a | b |\n|---|---|\n| x
y | z |') + expect(inline.hasBlockHtml).toBe(false) + expect(inline.hasInlineHtml).toBe(true) + }) + + it('gates inline html on tag-like source text', () => { + expect(analyzeMarkdownHtml('a < b').hasInlineHtml).toBe(false) + expect(analyzeMarkdownHtml('a bold').hasInlineHtml).toBe(true) + expect(analyzeMarkdownHtml('plain markdown').hasInlineHtml).toBe(false) + }) +}) diff --git a/tests/md-toc.spec.tsx b/tests/md-toc.spec.tsx new file mode 100644 index 0000000..0216ce8 --- /dev/null +++ b/tests/md-toc.spec.tsx @@ -0,0 +1,152 @@ +/** + * MdToc spec: the markdown preview's outline button + popover. Covers the + * minimum-headings gate, collection from the rendered DOM (including headings + * inside HTML `
` runs, which appear late through the observer), + * jump behavior (smooth scroll + flash + auto-expanding collapsed ancestors) + * and Esc-to-close. + */ +// @vitest-environment jsdom +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { MdToc, TOC_MIN_HEADINGS } from '../src/client/md-toc.tsx' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +const scrollIntoView = vi.fn() +Element.prototype.scrollIntoView = scrollIntoView + +/** The preview-harness shape MdToc mounts in: the bar is a direct child of + * the scroll container, headings are siblings (inside or outside details). */ +interface HeadingSpec { + tag: string + text: string + insideClosedDetails?: boolean +} + +function Harness({ specs }: { specs: readonly HeadingSpec[] }): React.ReactElement { + return createElement( + 'div', + null, + createElement(MdToc), + ...specs.map((spec) => spec.insideClosedDetails === true + ? createElement('details', null, createElement(spec.tag, null, spec.text)) + : createElement(spec.tag, null, spec.text)), + ) +} + +/** Render the harness; returns the scroll container (the bar's parent). */ +async function mountToc(specs: readonly HeadingSpec[]): Promise<{ container: HTMLDivElement; root: Root }> { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + await act(async () => { + root.render(createElement(Harness, { specs })) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + }) + const container = host.firstElementChild as HTMLDivElement + return { container, root } +} + +async function unmount(root: Root): Promise { + await act(async () => { root.unmount() }) +} + +beforeEach(() => { + document.body.innerHTML = '' + scrollIntoView.mockClear() +}) + +describe('MdToc', () => { + it('exposes the minimum-headings threshold', () => { + expect(TOC_MIN_HEADINGS).toBe(3) + }) + + it('stays hidden below the heading threshold', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'One' }, + { tag: 'h2', text: 'Two' }, + ]) + expect(container.querySelector('[data-dsh-md-toc]')).toBeNull() + await unmount(root) + }) + + it('shows the button and lists collected headings', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'Install' }, + { tag: 'h2', text: 'Usage' }, + { tag: 'h3', text: 'Deep' }, + ]) + const button = container.querySelector('[data-dsh-md-toc]') + expect(button).not.toBeNull() + await act(async () => { button!.click() }) + const panel = container.querySelector('[data-dsh-md-toc-panel]') + expect(panel).not.toBeNull() + const items = [...panel!.querySelectorAll('button')] + expect(items.map((item) => item.textContent)).toEqual(['1Title', '2Install', '2Usage', '3Deep']) + await unmount(root) + }) + + it('jumps: expands collapsed details ancestors, smooth-scrolls, flashes, closes', async () => { + // Mount with real timers (the settle await needs them), then fake only the + // flash-removal timeout window. + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'Outside' }, + { tag: 'h3', text: 'Hidden', insideClosedDetails: true }, + ]) + vi.useFakeTimers() + try { + const button = container.querySelector('[data-dsh-md-toc]')! + await act(async () => { button.click() }) + const target = [...container.querySelectorAll('h3')].find((h) => h.textContent === 'Hidden')! + const item = [...container.querySelectorAll('[data-dsh-md-toc-panel] button')] + .find((candidate) => candidate.textContent?.includes('Hidden'))! + await act(async () => { item.click() }) + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }) + expect(target.closest('details')?.hasAttribute('open'), 'the collapsed ancestor must open').toBe(true) + expect(target.className, 'the jumped-to heading must flash').not.toBe('') + expect(container.querySelector('[data-dsh-md-toc-panel]'), 'the panel must close on jump').toBeNull() + vi.advanceTimersByTime(1300) + expect(target.className, 'the flash class must clear after the timeout').toBe('') + await unmount(root) + } finally { + vi.useRealTimers() + } + }) + + it('collects headings that appear after mount (observer path)', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'Only two so far' }, + ]) + expect(container.querySelector('[data-dsh-md-toc]')).toBeNull() + const late = document.createElement('h2') + late.textContent = 'Late' + await act(async () => { + container.appendChild(late) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + }) + expect(container.querySelector('[data-dsh-md-toc]'), 'late headings must surface the button').not.toBeNull() + await unmount(root) + }) + + it('closes the popover on Escape', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'A' }, + { tag: 'h2', text: 'B' }, + ]) + const button = container.querySelector('[data-dsh-md-toc]')! + await act(async () => { button.click() }) + expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull() + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + expect(container.querySelector('[data-dsh-md-toc-panel]')).toBeNull() + await unmount(root) + }) +}) From 2b757cfe24a154a746a0b1b5b9810dba7c325081 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 24 Aug 2026 14:18:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(test):=20=E6=B5=8B=E8=AF=95=E9=97=B4?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=B2=E5=8F=B0=E4=B8=8E=20macOS=20?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95=E8=B7=AF=E5=BE=84=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E7=94=A8=E4=BE=8B=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处既有测试基建问题(在慢 runner 的 CI 上表现为 free-window 用例 间歇性失败,阻塞 PR 合并): - free-window/sidebar-crash/bottom-auto-terminal 三个 spec 共用 session id 's1':store 会按 200ms 防抖把每会话状态持久化到 localStorage['dsh-sidebar:v1:s1'],前一个用例的迟写(drop 点 400,300 → 浮窗 left=205)被下一用例 setSession 恢复,新浮窗断言(期望 317) 被 querySelector 拿到的旧窗口炸掉——用例间隔是否超过 200ms 决定成 败,本地快时常过、CI 慢必挂。修复:挂载 helper 缺省生成唯一 session id(持久化往返用例显式传固定 id),afterEach 在 unstub 之后 清 localStorage 兜底 - git/git-worktree 两个用例在 macOS 上必挂:tmpdir() 返回 /var 符号 链接而 git 报告解析后的 /private/var 前缀,路径比较与 resolveWorktree 的恒等匹配失配(CI 的 Linux 路径规范所以一直绿)。 修复:期望值与传入 git API 的路径统一 realpathSync 规范化 本地全量 vitest 963 通过 0 失败(此前 free-window 间歇 0~2 失败 + git 2 个环境性失败);free-window 单文件连跑 8 次稳定。 --- tests/bottom-auto-terminal.spec.tsx | 16 +++++++++++++--- tests/free-window.spec.tsx | 23 ++++++++++++++++++----- tests/git-worktree.spec.ts | 13 ++++++++----- tests/git.spec.ts | 13 ++++++++----- tests/sidebar-crash.spec.tsx | 16 +++++++++++++--- 5 files changed, 60 insertions(+), 21 deletions(-) diff --git a/tests/bottom-auto-terminal.spec.tsx b/tests/bottom-auto-terminal.spec.tsx index dfba81d..e9d4a5a 100644 --- a/tests/bottom-auto-terminal.spec.tsx +++ b/tests/bottom-auto-terminal.spec.tsx @@ -44,6 +44,9 @@ interface MountedSidebar { } /** Mount the real Sidebar shell against a minimal context (real store + service). */ +/** Unique per-test session ids (see the comment inside). */ +let sessionSeq = 0 + function mountSidebar(): MountedSidebar { vi.stubGlobal('WebSocket', FakeWebSocket) const container = document.createElement('div') @@ -52,14 +55,18 @@ function mountSidebar(): MountedSidebar { const service = createBetterSidebarService(store) // Fresh-session seed: the right panel starts OPEN, the bottom panel closed // (bottomOpen false → the first expansion is a false→true TRANSITION). - store.setSession('s1') + // Unique session per test — the store persists per-session state to + // localStorage (200ms debounce); a shared id lets a previous test's late + // write leak into this store's setSession restore. + const sessionId = `s1-${++sessionSeq}` + store.setSession(sessionId) // useSyncExternalStore requires STABLE snapshots across calls (the real DSH // services return stable objects) — a fresh object per call loops forever. const localeSnapshot = { active: 'en' } const sessionsSnapshot = { - current: 's1', + current: sessionId, // cwd present → api.sessionCwd is never called in these tests. - byId: { s1: { cwd: '/tmp' } }, + byId: { [sessionId]: { cwd: '/tmp' } }, } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, @@ -81,6 +88,9 @@ function mountSidebar(): MountedSidebar { afterEach(() => { document.body.innerHTML = '' + // Belt and braces: drop any persisted layout a pending 200ms debounce + // write left behind between tests (unique session ids already isolate). + localStorage.clear() vi.unstubAllGlobals() }) diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx index c90d498..568bfe2 100644 --- a/tests/free-window.spec.tsx +++ b/tests/free-window.spec.tsx @@ -60,7 +60,10 @@ interface Mounted { unmount: () => void } -function mountSidebar(): Mounted { +/** Unique per-test session ids (see the comment inside). */ +let sessionSeq = 0 + +function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted { vi.stubGlobal('WebSocket', FakeWebSocket) if (!('ResizeObserver' in globalThis)) { vi.stubGlobal('ResizeObserver', class { @@ -83,9 +86,15 @@ function mountSidebar(): Mounted { component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'), }) store.setPrefs({ ...store.getPrefs(), openByDefault: true }) - store.setSession('s1') + // The default session id is unique per test: the store persists + // per-session state to localStorage on a 200ms debounce, and a shared id + // let a PREVIOUS test's late timer write leak into this store's + // setSession restore (the portaled-menu/resize flakes — a stale float at + // the old drop point won the querySelector race on slow runners). The + // persistence round-trip test passes its own fixed id instead. + store.setSession(sessionId) const localeSnapshot = { active: 'en' } - const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } } + const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, @@ -132,6 +141,10 @@ afterEach(() => { document.body.innerHTML = '' document.documentElement.style.cssText = '' vi.unstubAllGlobals() + // Belt and braces (after unstub, so a test's storage stub never sees it): + // drop any persisted layout a pending 200ms debounce write left behind + // between tests — unique session ids already isolate the stores. + localStorage.clear() }) describe('free windows: drag-out detection', () => { @@ -380,7 +393,7 @@ describe('free windows: the window', () => { const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null) const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) }) vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} }) - const first = mountSidebar() + const first = mountSidebar('s1') try { act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) }) act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) @@ -390,7 +403,7 @@ describe('free windows: the window', () => { } finally { first.unmount() } - const second = mountSidebar() + const second = mountSidebar('s1') try { const state = second.store.getSnapshot().state! expect(state.floats).toHaveLength(1) diff --git a/tests/git-worktree.spec.ts b/tests/git-worktree.spec.ts index b92c6fd..2f4aca3 100644 --- a/tests/git-worktree.spec.ts +++ b/tests/git-worktree.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { parseWorktreeList, resolveWorktree, status, worktrees } from '../src/git.ts' @@ -67,13 +67,16 @@ describe('linked Git worktrees', () => { git(main, ['commit', '-q', '-m', 'base']) git(main, ['worktree', 'add', '-q', '-b', 'agent', agent]) writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n') + // macOS tmpdir() keeps the /var symlink while git reports the resolved + // /private/var prefix — canonicalize every path handed to git APIs. + const agentPath = realpathSync(agent) const listed = await worktrees(main) expect(listed).toHaveLength(2) expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 }) expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 }) - expect(resolve(await resolveWorktree(main, agent))).toBe(resolve(agent)) - expect((await status(await resolveWorktree(main, agent))).entries).toEqual([ + expect(resolve(await resolveWorktree(main, agentPath))).toBe(resolve(agentPath)) + expect((await status(await resolveWorktree(main, agentPath))).entries).toEqual([ { path: 'tracked.txt', xy: ' M' }, ]) await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree') @@ -84,9 +87,9 @@ describe('linked Git worktrees', () => { rmSync(agent, { recursive: true, force: true }) const remaining = await worktrees(main) expect(remaining).toHaveLength(1) - expect(resolve(remaining[0]!.path)).toBe(resolve(main)) + expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync(main))) expect(remaining[0]!.current).toBe(true) - await expect(resolveWorktree(main, agent)).rejects.toThrow('unknown linked worktree') + await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree') } finally { try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ } rmSync(root, { recursive: true, force: true }) diff --git a/tests/git.spec.ts b/tests/git.spec.ts index 655bde4..7d6e02d 100644 --- a/tests/git.spec.ts +++ b/tests/git.spec.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,6 +10,9 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts const execFileAsync = promisify(execFile) const normalizePath = (path: string): string => path.replaceAll('\\', '/') +// macOS tmpdir() is the /var symlink while git reports the resolved +// /private/var prefix — canonicalize both sides before comparing. +const canonical = (path: string): string => normalizePath(realpathSync(path)) describe('git parsing', () => { it('discovers and selects direct child repositories under a workspace directory', async () => { @@ -23,11 +26,11 @@ describe('git parsing', () => { execFileAsync('git', ['-C', second, 'init']), ]) - await expect(repoRoots(workspace)).resolves.toEqual([normalizePath(first), normalizePath(second)]) - await expect(status(workspace, normalizePath(second))).resolves.toMatchObject({ + await expect(repoRoots(workspace)).resolves.toEqual([canonical(first), canonical(second)]) + await expect(status(workspace, canonical(second))).resolves.toMatchObject({ isRepo: true, - root: normalizePath(second), - repositories: [normalizePath(first), normalizePath(second)], + root: canonical(second), + repositories: [canonical(first), canonical(second)], }) } finally { await rm(workspace, { recursive: true, force: true }) diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index 095ef86..6e47654 100644 --- a/tests/sidebar-crash.spec.tsx +++ b/tests/sidebar-crash.spec.tsx @@ -47,6 +47,9 @@ interface MountedSidebar { } /** Mount the real Sidebar shell against a minimal context (real store + service). */ +/** Unique per-test session ids (see the comment inside). */ +let sessionSeq = 0 + function mountSidebar(): MountedSidebar { vi.stubGlobal('WebSocket', FakeWebSocket) const container = document.createElement('div') @@ -55,14 +58,18 @@ function mountSidebar(): MountedSidebar { const service = createBetterSidebarService(store) // Fresh-session seed: open the panel explicitly (openByDefault defaults off). store.setPrefs({ ...store.getPrefs(), openByDefault: true }) - store.setSession('s1') + // Unique session per test — the store persists per-session state to + // localStorage (200ms debounce); a shared id lets a previous test's late + // write leak into this store's setSession restore. + const sessionId = `s1-${++sessionSeq}` + store.setSession(sessionId) // useSyncExternalStore requires STABLE snapshots across calls (the real DSH // services return stable objects) — a fresh object per call loops forever. const localeSnapshot = { active: 'en' } const sessionsSnapshot = { - current: 's1', + current: sessionId, // cwd present → api.sessionCwd is never called in these tests. - byId: { s1: { cwd: '/tmp' } }, + byId: { [sessionId]: { cwd: '/tmp' } }, } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, @@ -84,6 +91,9 @@ function mountSidebar(): MountedSidebar { afterEach(() => { document.body.innerHTML = '' + // Belt and braces: drop any persisted layout a pending 200ms debounce + // write left behind between tests (unique session ids already isolate). + localStorage.clear() vi.unstubAllGlobals() })