Baseline moves to DSH 0.1.5-rc.1 — the 0.1.5 line's first release candidate, and the npm `latest` dist-tag now: peer floor ^0.1.5-rc.1, devDependency pins, the CI mount-lane pin and dsh.plugin.json engines all follow. 0.1.5-alpha.2 users stay on v0.19.0-alpha.1. The upstream delta from alpha.2 is small: 373 changed files, mostly version bumps, sidebar preview polish and test snapshots. Exactly two changes touch this plugin, and only one needs work: - `SidebarRightGuideEntry.description` is back (optional). The native guide renders it only while it lists at most 4 entries; a longer list drops every description. The plugin restores `TabDescriptor.description` and the six built-in `guideDesc*` lines (20 dictionaries) verbatim from 0b6ac5d^, but deliberately does NOT restore the old generic `nativeGuideDesc` fallback: the host has no fallback of its own, so a descriptor that declares none now ships no `description` field at all and renders as a clean title-only capsule. Consequence worth knowing: the plugin contributes six guide entries, so the default composition is over the 4-entry limit and shows titles only. The mount lane proves the restored field end to end by shrinking the enabled set to three types through the plugin's own settings route (scratch profile only) and requiring the Files capsule to grow its line. - The built-in `files` type declares a guide description again and uses a coloured folder glyph; the plugin owns that kind, so nothing to do. Verified unchanged from alpha.2 (so nobody re-checks them): the global main-panel model, the file address grammar (util/workspace-path changed only its package.json), the native tab-body host contract (.paneBody is still a block scroller with a definite height), the core/agent/session/ subagent host APIs and the ui-primitives export surface. Restore caveat fixed in the lane: the settings route merges patches key-wise, so undoing a temporary `tabsEnabled` change by posting the original (empty) map leaves the disabled keys behind — the e2e restores every key it touched explicitly.
326 lines
15 KiB
TypeScript
326 lines
15 KiB
TypeScript
/**
|
||
* Built-in registration tests: the plugin registers 7 tabs and 6 file
|
||
* viewers through the same service external plugins use (dogfooding);
|
||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||
* and the html sandbox settings pin the registry's behavior. (Office
|
||
* previews are 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'
|
||
// 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 { createSidebarStore } from '../src/client/state.ts'
|
||
import { allLeaves } from '../src/client/state.ts'
|
||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
|
||
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
|
||
import { t } from '../src/client/locales.ts'
|
||
|
||
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||
const store = createSidebarStore()
|
||
const service = createBetterSidebarService(store)
|
||
const dispose = registerBuiltins({} as Context, service, options)
|
||
return { service, store, dispose }
|
||
}
|
||
|
||
describe('built-in tab registrations', () => {
|
||
it('registers the 7 built-in tabs', () => {
|
||
const { service } = setup()
|
||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||
['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'],
|
||
)
|
||
})
|
||
|
||
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 terminal 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'])
|
||
})
|
||
|
||
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('the terminal tab declares the model terminal-tools, auto-terminal, shell and custom-font settings', () => {
|
||
const { service } = setup()
|
||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalShell', 'terminalShellArgs', 'terminalFontFamily', 'terminalFontSize'])
|
||
// The shell rows are text inputs (empty = yaml/auto resolution), the
|
||
// font rows text/number inputs (not switches), with the size row bounded
|
||
// by the shared 9–32 contract.
|
||
expect(toggles[2]?.type).toBe('text')
|
||
expect(toggles[2]?.title).toBeDefined()
|
||
expect(toggles[2]?.placeholder).toBeDefined()
|
||
expect(toggles[3]?.type).toBe('text')
|
||
expect(toggles[3]?.title).toBeDefined()
|
||
expect(toggles[4]?.type).toBe('text')
|
||
expect(toggles[4]?.title).toBeDefined()
|
||
expect(toggles[4]?.placeholder).toBeDefined()
|
||
expect(toggles[5]?.type).toBe('number')
|
||
expect(toggles[5]?.min).toBe(9)
|
||
expect(toggles[5]?.max).toBe(32)
|
||
expect(toggles[5]?.unit).toBe('px')
|
||
// The first two rows stay plain boolean switches.
|
||
expect(toggles[0]?.type ?? 'switch').toBe('switch')
|
||
expect(toggles[1]?.type ?? 'switch').toBe('switch')
|
||
})
|
||
|
||
it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => {
|
||
const { service } = setup()
|
||
const toggles = service.getTab('browser')?.settings?.toggles ?? []
|
||
expect(toggles.map(t => t.key)).toEqual([
|
||
'browserNoSandbox',
|
||
'browserInterceptLinks',
|
||
'browserInterceptHttp',
|
||
'browserInterceptHttps',
|
||
'browserAllowedLoopback',
|
||
])
|
||
for (const toggle of toggles) {
|
||
expect(toggle.title).toBeDefined()
|
||
expect(toggle.desc).toBeDefined()
|
||
}
|
||
})
|
||
|
||
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
|
||
const { service, store } = setup()
|
||
store.setSession('s1')
|
||
service.openTab({ type: 'browser' })
|
||
service.openTab({ type: 'browser' })
|
||
const state = store.getSnapshot().state!
|
||
const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
|
||
expect(tabs).toHaveLength(2)
|
||
expect(tabs[0]!.id).toBe('browser:1')
|
||
expect(tabs[1]!.id).toBe('browser:2')
|
||
expect(state.nextBrowser).toBe(3)
|
||
})
|
||
|
||
it('the terminal createTab uses shell-name titles and hidden uuid ids, allowing duplicates', () => {
|
||
const { service, store } = setup({ terminalTitle: () => 'bash' })
|
||
store.setSession('s1')
|
||
service.openTab({ type: 'terminal' })
|
||
service.openTab({ type: 'terminal' })
|
||
const state = store.getSnapshot().state!
|
||
const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
|
||
expect(tabs).toHaveLength(2)
|
||
expect(tabs[0]!.title).toBe('bash')
|
||
expect(tabs[1]!.title).toBe('bash')
|
||
expect(tabs[0]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
|
||
expect(tabs[1]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
|
||
expect(tabs[0]!.id).not.toBe(tabs[1]!.id)
|
||
})
|
||
|
||
it('the terminal createTab falls back to the localized terminal label before shell info resolves', () => {
|
||
const { service, store } = setup()
|
||
store.setSession('s1')
|
||
service.openTab({ type: 'terminal' })
|
||
const state = store.getSnapshot().state!
|
||
const tab = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
|
||
expect(tab?.title).toBe(t('terminal'))
|
||
})
|
||
|
||
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()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('built-in file viewer registrations', () => {
|
||
it('registers the 6 built-in file viewers (office previews live in the recommended office plugin)', () => {
|
||
const { service } = setup()
|
||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||
['binary-download', 'code', 'html', 'image', 'markdown', 'pdf'],
|
||
)
|
||
// Office previews are not built in: docx/xlsx/pptx files fall through to
|
||
// the download-only binary viewer (or a registered office plugin).
|
||
expect(service.getFileViewers().map(v => v.id)).not.toContain('docx')
|
||
expect(service.getFileViewers().map(v => v.id)).not.toContain('xlsx')
|
||
expect(service.getFileViewers().map(v => v.id)).not.toContain('pptx')
|
||
})
|
||
|
||
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('binary-download claims legacy office by extension (office previews are not built in)', () => {
|
||
const { service } = setup()
|
||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
|
||
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
|
||
// Modern office files (zip containers, NUL-free) fall through to the
|
||
// catch-all code viewer without an office plugin registered.
|
||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('code')
|
||
})
|
||
|
||
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
|
||
const { service } = setup()
|
||
// First match (no head) falls to the catch-all code viewer...
|
||
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
|
||
// ...but the head re-match (NUL probe) routes it to binary-download.
|
||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
|
||
// A NUL-free blob stays with code.
|
||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
|
||
})
|
||
|
||
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()
|
||
})
|
||
})
|