Files
apes-sidebar/tests/builtins.spec.ts
T
Menghuan1918 b011be1e98 feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树
用户追加三条需求,全部落地:

1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存)
- 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts /
  src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor
  的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文)
- 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了)
- FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」
  子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件
  explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用)
- 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除

2) 文件页多选右键「压缩并打包下载」
- 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、
  自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+
  GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、
  10000 条目 / 256 MiB 上限)+ api.archiveUrl
- FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载,
  路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条
- 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix,
  Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000,
  << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错

3) 文件变动页层级树 + 视觉打磨
- 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、
  大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数
- GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 +
  计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 =
  interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线;
  历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐

验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
2026-09-28 18:08:21 +08:00

317 lines
15 KiB
TypeScript

/**
* Built-in registration tests: the plugin registers 5 tabs and 3 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer and the html sandbox settings pin the
* registry's behavior. The read-only previews (image / pdf /
* binary-download) were yielded to the host's own document preview
* (DSH 0.1.7 `ui-sidebar-documentpreview`) and legacy Office
* doc/xls/ppt now fall through to the host too. (Office previews were
* already NOT built in — they moved to the recommended office plugin,
* see src/client/plugins-viewers.ts.) The git tab is the unified changes
* tab (git lens + session lens, PR #471's file-trace merged in).
*/
import { describe, expect, it } from 'vitest'
import type { ReactElement } from 'react'
import { VscCommentDiscussion, VscGitCommit, VscLayers } from 'react-icons/vsc'
// First import: browser globals before the xterm-carrying builtin graph loads.
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { planFsReadOutcome } from '../src/client/editor-load.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
return { service, store, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 5 built-in tabs (the host owns terminal and browser)', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['diff', 'editor', 'git', 'sidechat', 'subagent'],
)
})
it('the git (changes) tab is a single-instance badge-carrying visible tab', () => {
const { service } = setup()
const changes = service.getTab('git')
expect(changes?.single).toBe(true)
expect(changes?.hidden).not.toBe(true)
expect(changes?.badge).toBeDefined()
expect(changes?.component).toBeDefined()
})
it('every visible tab declares a non-empty, mutually distinct title', () => {
// The title is the tab's identity in the native new-tab list (guide
// page) and the tab strip: it must exist, and no two visible tabs may
// share one (identical capsules would be indistinguishable).
const { service } = setup()
const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true)
expect(visible.length).toBeGreaterThan(0)
for (const descriptor of visible) {
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
expect(title, `${descriptor.id} must declare a title`).toBeTruthy()
}
const titles = visible.map(descriptor =>
typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title)
expect(new Set(titles).size, 'titles must differ per tab').toBe(visible.length)
})
it('every visible tab declares a non-empty, mutually distinct description', () => {
// DSH 0.1.5-rc.1+ renders `description` under the title while the guide
// lists at most 4 entries (a longer list drops every description). With
// the host no longer substituting a generic fallback, a tab without one
// renders the title alone — so every visible tab declares the real
// purpose of its page, and no two may read identically.
const { service } = setup()
const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true)
expect(visible.length).toBeGreaterThan(0)
for (const descriptor of visible) {
expect(descriptor.description, `${descriptor.id} must declare a description`).toBeDefined()
const line = typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description
expect(line, `${descriptor.id} description must be non-empty`).toBeTruthy()
}
const lines = visible.map(descriptor =>
typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description)
expect(new Set(lines).size, 'descriptions must differ per tab').toBe(visible.length)
})
it('the changes tab declares no settings of its own (the diff always docks)', () => {
const { service } = setup()
expect(service.getTab('git')?.settings).toBeUndefined()
})
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff'])
const editor = service.getTab('editor')
expect(editor?.hidden).toBe(false)
expect(editor?.order).toBe(10)
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
it('the side chat tab sits between tasks and the removed terminal slot in the + menu', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.order).toBe(35)
expect(sidechat?.hidden).not.toBe(true)
})
it('side chat mints one tab per thread (Codex-style multi-instance)', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.single).not.toBe(true)
// A plain open mints a fresh autoCreate tab (the view creates the
// thread on mount); two opens never collide. (createTab ignores the
// state argument for sidechat — the cast stands in for it.)
const first = sidechat?.createTab?.(undefined as never)
const second = sidechat?.createTab?.(undefined as never)
expect(first?.tab.meta).toEqual({ autoCreate: true })
expect(first?.tab.id).not.toBe(second?.tab.id)
// A parked reopen target mints the deterministic reattach tab, and
// dedupeKey focuses an already-open thread instead of duplicating it.
parkSidechatReopen('session-t1')
const reopen = sidechat?.createTab?.(undefined as never)
expect(reopen?.tab.id).toBe('sidechat:session-t1')
expect(reopen?.tab.meta).toEqual({ threadId: 'session-t1' })
expect(sidechat?.dedupeKey?.(reopen!.tab)).toBe('session-t1')
expect(sidechat?.dedupeKey?.(first!.tab)).toBeUndefined()
})
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs', 'tasksViewMode'])
// The default-view row is a graph/tree select (values are strings).
const viewMode = toggles[2]
expect(viewMode?.type).toBe('select')
expect((viewMode?.options ?? []).map(o => o.value)).toEqual(['graph', 'tree'])
})
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
const { service } = setup()
const toggles = service.getTab('editor')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
expect(toggles[0]?.type).toBe('select')
const options = toggles[0]?.options ?? []
expect(options.map(o => o.value)).toEqual([true, false])
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
// The workspace fence switch rides the same card as a plain boolean row.
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
// The open-with configuration (SSH host + custom editors) is the custom
// panel rendered below the declarative rows.
expect(service.getTab('editor')?.settings?.render).toBeDefined()
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
expect(tab.icon, tab.id).toBeDefined()
}
})
it('the tab glyphs say what the page shows (colored, token-driven)', () => {
const { service } = setup()
const iconOf = (id: string): ReactElement => {
const icon = service.getTab(id)?.icon
expect(icon, id).toBeDefined()
return (typeof icon === 'function' ? icon(14) : icon) as ReactElement
}
// Every colored glyph is [wrapper][glyph]; unwrap the themed wrapper.
const glyphOf = (id: string): unknown => {
const wrapper = iconOf(id) as ReactElement<{ children?: ReactElement }>
return (wrapper.props.children as ReactElement | undefined)?.type ?? wrapper.type
}
// Tasks lists subagent sessions AND background jobs — layered sheets say
// "work running in the background"; a checklist glyph would say "to-do
// list", which this page is not.
expect(glyphOf('subagent')).toBe(VscLayers)
expect(glyphOf('git')).toBe(VscGitCommit)
expect(glyphOf('sidechat')).toBe(VscCommentDiscussion)
})
})
describe('built-in file viewer registrations', () => {
it('registers the 3 built-in file viewers (the read-only previews yielded to the host)', () => {
const { service } = setup()
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
['code', 'html', 'markdown'],
)
// The yielded ids are gone: the host's own document preview
// (ui-sidebar-documentpreview) owns images, pdf, spreadsheets, office
// and plain text; the plugin keeps only markdown / html / the editable
// code catch-all.
for (const id of ['image', 'pdf', 'binary-download']) {
expect(service.getFileViewers().map(v => v.id), `${id} must not be built in`).not.toContain(id)
}
// Office previews are not built in either: they live in the recommended
// office plugin (which registers the ids through this service).
for (const id of ['docx', 'xlsx', 'pptx']) {
expect(service.getFileViewers().map(v => v.id)).not.toContain(id)
}
})
it('the yielded viewer ids stay registrable by external plugins (public contract)', () => {
const { service } = setup()
// Dropping the built-in descriptors must not freeze the ids: the
// registry API keeps accepting them from third-party plugins.
const dispose = service.registerFileViewer({
id: 'image',
exts: ['png'],
fetchStrategy: 'mediaUrl',
component: () => null,
})
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
dispose()
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
})
it('code is the catch-all at the lowest priority', () => {
const { service } = setup()
const code = service.getFileViewers().find(v => v.id === 'code')
expect(code?.exts).toEqual([])
expect(code?.priority).toBe(-100)
expect(code?.fetchStrategy).toBe('fsRead')
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
})
it('markdown claims md/markdown before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
})
it('html claims html/htm before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('index.html')?.id).toBe('html')
expect(service.matchFileViewer('page.htm')?.id).toBe('html')
expect(service.matchFileViewer('index.html', new Uint8Array([0x3c, 0x21]))?.id).toBe('html')
expect(service.matchFileViewer('index.HTML')?.id).toBe('html')
})
it('the html viewer declares its sandbox and default-unsafe related settings', () => {
const { service } = setup()
const toggles = service.getFileViewers().find(v => v.id === 'html')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
})
it('no built-in viewer claims the yielded extensions (the host document preview does)', () => {
const { service } = setup()
// The spreadsheet / pdf / image / office set yielded to the host: the
// only plugin-side match left is the catch-all code viewer, so the
// `editor` type can hand these addresses over (see native/index.ts's
// canOpen). Legacy doc/xls/ppt were previously claimed by the removed
// `binary-download` viewer and are now the host's too.
for (const path of [
'book.xlsx', 'legacy.xls', 'macro.xlsb', 'sheet.xlt', 'template.xltx', 'macro.xltm',
'flat.ods', 'flat.ots', 'flat.fods', 'data.csv', 'data.tsv',
'paper.pdf', 'photo.png', 'photo.jpg', 'anim.gif', 'photo.webp', 'art.svg',
'tile.bmp', 'favicon.ico', 'next.avif',
'report.docx', 'report.doc', 'notes.dot', 'notes.dotx',
'deck.pptx', 'deck.ppt',
]) {
expect(service.matchFileViewer(path)?.id, path).toBe('code')
}
})
it('an unknown-extension binary still downloads (the catch-all + host fallback)', () => {
const { service } = setup()
// Without the removed `binary-download` descriptor no built-in viewer
// carries a `detect` probe, so the catch-all blind-claims everything...
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
expect(service.getFileViewers().some(v => v.detect !== undefined)).toBe(false)
// ...and the head re-match (the same call the editor host makes on a
// binary fsRead result) finds no sniffer either: the fsRead catch-all
// cannot render binary, so planFsReadOutcome hands the host its
// `binary` outcome and the editor renders the download pane.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('code')
const code = service.getFileViewers().find(v => v.id === 'code')
expect(planFsReadOutcome(code!, {
binary: true,
content: '',
truncated: false,
head: Buffer.from([0x01, 0x00, 0x02]).toString('base64'),
}, (head) => service.matchFileViewer('blob.zzz', head), () => '/media')).toEqual({ kind: 'binary' })
})
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
const { service } = setup()
for (const viewer of service.getFileViewers()) {
expect(viewer.title, viewer.id).toBeDefined()
expect(viewer.icon, viewer.id).toBeDefined()
}
})
})
describe('built-in disposer', () => {
it('unregisters everything (HMR-safe)', () => {
const { service, dispose } = setup()
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getFileViewers()).toHaveLength(0)
// The disposer is idempotent.
dispose()
})
})