diff --git a/AGENTS.md b/AGENTS.md index 9498adf..4364695 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,7 +181,7 @@ interface TabDescriptor { */ settings?: { toggles?: readonly { - /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */ + /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'agentOpenTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */ key: string title: string | (() => string) desc?: string | (() => string) @@ -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 注册示例 @@ -571,7 +571,7 @@ interface OpenTabSeed { } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时设置条隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——设置弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有设置条)。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时设置条隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——设置弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有设置条)。 --- @@ -729,6 +729,8 @@ 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`**:设计文档(含实施偏差记录) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/README.md b/README.md index d9643e5..cafa18b 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ 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) 网页 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 @@ -155,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/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 56f7270..992c573 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -65,6 +65,8 @@ describe('dsh-better-sidebar plugin export shape', () => { // The terminal tools default OFF (the feature is dormant until the user // enables it in the side card settings). expect(resolved.agentTerminalTools).toBe(false) + // The sidebar-open tool defaults OFF too (same dormant-until-enabled rule). + expect(resolved.agentOpenTools).toBe(false) // The terminal font customizations default to the theme (empty family) // and 13px. expect(resolved.terminalFontFamily).toBe('') @@ -92,6 +94,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 0ce1ba3..90be5d6 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -37,7 +37,7 @@ describe('side card preferences', () => { defaultWidthPercent: 60, autoOpenSubagent: false, autoOpenJobs: true, - agentTerminalTools: true, + agentTerminalTools: true, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, @@ -69,7 +69,7 @@ describe('side card preferences', () => { defaultWidthPercent: 33, autoOpenSubagent: true, autoOpenJobs: true, - agentTerminalTools: false, + agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, @@ -101,7 +101,7 @@ describe('side card preferences', () => { defaultWidthPercent: 40, autoOpenSubagent: true, autoOpenJobs: true, - agentTerminalTools: false, + agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, @@ -133,6 +133,13 @@ describe('side card preferences', () => { .toBe(false) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools) .toBe(true) + // The sidebar-open tool is OFF by default too; only an explicit true turns it on. + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentOpenTools) + .toBe(false) + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: 1 }))).agentOpenTools) + .toBe(false) + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: true }))).agentOpenTools) + .toBe(true) // The job auto-open is ON by default; only an explicit false turns it off. expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs) .toBe(true) @@ -260,9 +267,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -298,7 +305,7 @@ describe('side card preferences', () => { it('skips the default seed tab when the editor (files window) type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) store.setSession('no-editor') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -308,7 +315,7 @@ describe('side card preferences', () => { // editorExplorer modes. for (const editorExplorer of [true, false]) { const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) openStore.setSession(`with-editor-${editorExplorer}`) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['editor']) @@ -318,7 +325,7 @@ describe('side card preferences', () => { it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => { for (const editorExplorer of [true, false]) { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession(`fresh-${editorExplorer}`) const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(tabs).toHaveLength(1) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 07cc983..3e55bc3 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -178,10 +178,10 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)') expect(html).not.toContain('Auto-detect<') - // Two general-row switches remain (openByDefault + interceptOpenPath), - // only interceptOpenPath checked by default — the scheme row is a - // dropdown, not a switch. - expect(html.match(/type="checkbox"/g)?.length).toBe(2) + // Three general-row switches remain (openByDefault + interceptOpenPath + // + agentOpenTools), only interceptOpenPath checked by default — the + // scheme row is a dropdown, not a switch. + expect(html.match(/type="checkbox"/g)?.length).toBe(3) expect(html.match(/checked=""/g)?.length).toBe(1) // Auto (default) needs no further settings → no gear. expect(html).not.toContain('Position compatibility mode Feature settings') diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index 3c88d30..9bab02c 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 }, @@ -85,6 +92,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/smoke.spec.ts b/tests/smoke.spec.ts index 55bfff6..ad21a51 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -110,7 +110,7 @@ describe('host plugin smoke', () => { '/sidebar/file', '/sidebar/html', ]) - expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals']) + expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals', '/sidebar/ws/agent-opens']) // Teardown runs without throwing (pty manager has nothing open). for (const cleanup of effects) cleanup() }) @@ -891,7 +891,7 @@ describe('side card settings routes', () => { defaultWidthPercent: 35, autoOpenSubagent: true, autoOpenJobs: true, - agentTerminalTools: false, + agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, @@ -1067,6 +1067,59 @@ describe('agent terminal tool gating', () => { expect(live()).toBe(8) expect(registered).toBe(16) }) +}) - +describe('agent sidebar-open tool gating', () => { + it('injects the one open tool only when the side-card setting is enabled (default off)', () => { + let registered = 0 + let disposed = 0 + const live = (): number => registered - disposed + const watcherRef: { current: (() => void) | null } = { current: null } + let enabled = false + const settings = { + register() { + return { + get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }), + watch: (callback: () => void) => { watcherRef.current = callback; return () => {} }, + update: async () => {}, + replace: async () => {}, + } + }, + describe: () => [], + async update() {}, + } + const ctx = { + webRuntime: { trustedHosts: [] }, + webServer: { + register: (route: SidebarWebRoute) => { void route; return () => {} }, + registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, + }, + sessions: { get: () => undefined }, + tools: { register: () => { registered += 1; return () => { disposed += 1 } } }, + effect: (fn: () => void | (() => void)) => { fn() }, + inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => { + if (deps.includes('settings')) callback({ settings }) + return () => {} + }, + get: () => undefined, + } + apply(ctx as never) + // Default off: no open tool is registered even though the settings service is mounted. + expect(live()).toBe(0) + // Flipping the setting on registers the single sidebar_open tool. + enabled = true + watcherRef.current?.() + expect(live()).toBe(1) + expect(disposed).toBe(0) + // Flipping it back off unregisters it (and drains the undelivered queue). + enabled = false + watcherRef.current?.() + expect(live()).toBe(0) + expect(disposed).toBe(1) + // And a redundant toggle registers it fresh (no double-registration). + enabled = true + watcherRef.current?.() + expect(live()).toBe(1) + expect(registered).toBe(2) + }) })