diff --git a/AGENTS.md b/AGENTS.md index 4c0bae0..4364695 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` - **`src/agent-opens.ts`** / **`/sidebar/ws/agent-opens`**:模型主动打开(`sidebar_open` 工具 + `agentOpenTools` 设置,默认关闭)——会话级队列推送注册表 + 工具注册;客户端在 Sidebar.tsx 订阅推送并路由到 editor(文件/文件夹窗口)/browser(HTTP(S) 网页)tab,文件夹窗口是 `meta.dir: true` 的 editor tab(全窗树以目录为根);设计文档 `docs/plans/2026-08-23-agent-open-tools-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`**:设计文档(含实施偏差记录) diff --git a/README.md b/README.md index d89090b..80aa034 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_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 @@ -156,7 +156,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) + }) +}) 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() })