A/B fix per WorkBuddy review: - browser-globals.ts: add addEventListener/removeEventListener/dispatchEvent to window stub, add compatMode to document stub - Remove // @vitest-environment jsdom from 5 specs that only use renderToString/createElement (SSR, no DOM needed) Test Files 131 passed (131), Tests 1367 passed, EXIT=0
357 lines
17 KiB
TypeScript
357 lines
17 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'
|
||
import type { ReactElement } from 'react'
|
||
import { VscCommentDiscussion, VscGitCommit, VscGlobe, VscLayers, VscTerminal } 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 { 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()
|
||
}
|
||
})
|
||
|
||
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)
|
||
expect(glyphOf('browser')).toBe(VscGlobe)
|
||
// The terminal glyph is the widest in the set, so it renders a step down
|
||
// from the strip's size. No outline: the glyphs stay exactly as the icon
|
||
// set draws them, only tinted through the wrapper's token.
|
||
const wrapper = iconOf('terminal') as ReactElement<{ children?: ReactElement }>
|
||
const terminal = wrapper.props.children as ReactElement<{ size?: number; style?: Record<string, unknown> }>
|
||
expect(terminal.type).toBe(VscTerminal)
|
||
expect(terminal.props.size).toBeLessThan(14)
|
||
expect(terminal.props.style).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
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()
|
||
})
|
||
})
|