feat: 设置页「添加插件」推荐目录 + 复制安装命令;移除内置 Office 预览

- 设置页侧边栏内容/文件预览两个网格末尾各增虚线卡片,弹出对应 kind 的插件弹窗
- 弹窗:GitHub topic 按钮(新标签页)+ 推荐插件目录(plugins-tabs.ts / plugins-viewers.ts)
- 每条目「跳转」(window.open 新标签页直达仓库)与「复制」(writeClipboard + 已复制反馈)
- 不做终端预填流:不关闭弹窗、不打开终端、无失败路径(install/installNotice/installError* 文案删除)
- 内置 Office 预览(docx/xlsx/pptx)整体移除,chunk 白名单 5→2(terminal/editor)
- 文案「内置插件」→「推荐插件」;AGENTS.md/README/设计文档同步
This commit is contained in:
Menghuan1918 committed 2026-08-14 22:30:34 +08:00
1 parent 1b0b382edc
commit cbc28473a0
40 files changed
+942 -1128

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
/**
* "Add plugin" modal tests: the body component (PluginListBody) renders the
* GitHub topic BUTTON + the recommended plugin catalog of the matching kind
* with per-entry jump/copy buttons. The copy button COPIES the install
* script to the clipboard (writeClipboard) with a transient "Copied"
* feedback — nothing is opened, nothing is closed, nothing can fail
* outward.
*
* The body is tested directly — the Modal primitive runs hooks
* unconditionally, so an open Modal must never be renderToString'd (same
* rule as the settingsFor popup). The click paths are exercised with
* createRoot + act().
*/
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { PLUGIN_TOPIC_URL, type PluginEntry } from '../src/client/plugins-shared.ts'
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
import { AddPluginModal, PluginListBody } from '../src/client/add-plugin-modal.tsx'
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const officeEntry = builtinViewerPlugins[0] as PluginEntry
describe('PluginListBody (render)', () => {
it('viewer kind renders the topic button and the office catalog entry', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const html = renderToString(createElement(PluginListBody, { service, kind: 'viewer' }))
// The topic is a BUTTON (window.open in a new tab), not an anchor.
expect(html).toContain('Browse more plugins on GitHub')
expect(html).not.toContain(`href="${PLUGIN_TOPIC_URL}"`)
// The seeded catalog entry: name as a link, description, install script,
// a jump button (opens the repo in a NEW browser tab) and a copy button.
expect(html).toContain(officeEntry.name)
expect(html).toContain(`href="${officeEntry.url}"`)
expect(html).toContain(officeEntry.install.replaceAll('&', '&amp;'))
expect(html).toContain('Open')
expect(html).toContain('Copy')
})
it('tab kind renders its own empty state (no office entry)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const html = renderToString(createElement(PluginListBody, { service, kind: 'tab' }))
expect(html).toContain('No plugins curated yet')
expect(html).not.toContain(officeEntry.name)
})
})
describe('AddPluginModal wiring', () => {
it('mounts the body through the Modal for each kind (client render)', () => {
// The Modal primitive renders a portal into document.body — the server
// renderer refuses portals, so mount it client-side (createRoot + act
// in jsdom) and assert on the portaled body HTML.
const store = createSidebarStore()
const service = createBetterSidebarService(store)
for (const kind of ['tab', 'viewer'] as const) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(AddPluginModal, { service, onClose: () => {}, kind }))
})
const html = document.body.innerHTML
if (kind === 'viewer') {
expect(html).toContain(officeEntry.name)
} else {
expect(html).toContain('No plugins curated yet')
}
act(() => { root.unmount() })
container.remove()
document.body.innerHTML = ''
}
})
})
/** Mount PluginListBody and click its copy button (the first one). */
function mountBody(service: ReturnType<typeof createBetterSidebarService>, kind: 'tab' | 'viewer' = 'viewer'):
{ clickCopy: () => void; buttonLabel: () => string | null; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(PluginListBody, { service, kind }))
})
return {
clickCopy: () => {
const button = container.querySelector('button[aria-label^="Copy install command:"]')!
act(() => { (button as HTMLButtonElement).click() })
},
buttonLabel: () => {
const button = container.querySelector('button[aria-label^="Copy install command:"]')
return button?.textContent ?? null
},
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
describe('PluginListBody copy click (interactive)', () => {
it('clicking Copy writes the install script to the clipboard and flashes "Copied"', () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
expect(body.buttonLabel()).toBe('Copy')
body.clickCopy()
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
expect(body.buttonLabel()).toBe('Copied')
body.unmount()
vi.restoreAllMocks()
})
it('copying does NOT close anything and the modal body stays mounted', () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
body.clickCopy()
// The entry is still rendered (nothing closed, no navigation, no open).
expect(body.buttonLabel()).toBe('Copied')
expect(service.getSnapshot().state).toBeUndefined()
body.unmount()
vi.restoreAllMocks()
})
it('the jump button opens the plugin repo in a NEW browser tab (window.open, not a link)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const opened: Array<[string, string, string]> = []
const original = window.open
window.open = ((url: string, target: string, features: string) => {
opened.push([url, target, features])
return null
}) as typeof window.open
try {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
})
const button = container.querySelector('button[aria-label^="Open:"]')!
act(() => { (button as HTMLButtonElement).click() })
expect(opened).toEqual([[officeEntry.url, '_blank', 'noopener']])
act(() => { root.unmount() })
container.remove()
} finally {
window.open = original
}
})
})
+15 -12
View File
@@ -1,8 +1,10 @@
/**
* Built-in registration tests: the plugin registers 7 tabs and 9 file
* 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/browser sandbox settings pin the registry's behavior.
* 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.)
*/
import { describe, expect, it } from 'vitest'
// First import: browser globals before the xterm-carrying builtin graph loads.
@@ -97,11 +99,16 @@ describe('built-in tab registrations', () => {
})
describe('built-in file viewer registrations', () => {
it('registers the 9 built-in file viewers', () => {
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', 'docx', 'html', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
['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', () => {
@@ -138,11 +145,14 @@ describe('built-in file viewer registrations', () => {
expect(toggles[1]?.desc).toBeDefined()
})
it('binary-download claims legacy office by extension', () => {
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', () => {
@@ -155,13 +165,6 @@ describe('built-in file viewer registrations', () => {
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
})
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
const { service } = setup()
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
// claims it before binary-download (-50) is consulted.
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
})
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
const { service } = setup()
for (const viewer of service.getFileViewers()) {
+13 -11
View File
@@ -42,7 +42,7 @@ function req(method: string, url: string, headers: Record<string, string> = {}):
/** One handler instance over a scratch dir with a fake chunk file. */
function setup(): { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; dir: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-'))
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 0;')
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 0;')
const handler = createBundleRouteHandler(() => true, dir)
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
}
@@ -52,7 +52,7 @@ describe('/sidebar/bundle route', () => {
const { handler, cleanup } = setup()
try {
const res = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8')
expect(res.headers['cache-control']).toBe('no-cache')
@@ -67,10 +67,10 @@ describe('/sidebar/bundle route', () => {
const { handler, cleanup } = setup()
try {
const first = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
const etag = first.headers.etag!
const second = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
expect(second.status).toBe(304)
expect(second.body).toBe('')
expect(second.headers.etag).toBe(etag)
@@ -83,10 +83,10 @@ describe('/sidebar/bundle route', () => {
const { handler, dir, cleanup } = setup()
try {
const first = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 1;')
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1;')
const second = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
expect(second.status).toBe(200)
expect(second.headers.etag).not.toBe(first.headers.etag)
expect(second.body).toContain('&& 1')
@@ -112,7 +112,9 @@ describe('/sidebar/bundle route', () => {
const { handler, cleanup } = setup()
try {
const res = fakeRes()
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
// 'terminal' is allowlisted but the scratch fixture only ships
// client-editor.js — the built file is missing → 404.
await handler(req('GET', '/sidebar/bundle/terminal.js'), res as unknown as ServerResponse)
expect(res.status).toBe(404)
} finally {
cleanup()
@@ -123,7 +125,7 @@ describe('/sidebar/bundle route', () => {
const { handler, cleanup } = setup()
try {
const res = fakeRes()
await handler(req('POST', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
await handler(req('POST', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
expect(res.status).toBe(405)
} finally {
cleanup()
@@ -135,7 +137,7 @@ describe('/sidebar/bundle route', () => {
try {
const handler = createBundleRouteHandler(() => false, dir)
const res = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
expect(res.status).toBe(403)
} finally {
rmSync(dir, { recursive: true, force: true })
@@ -143,6 +145,6 @@ describe('/sidebar/bundle route', () => {
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['docx', 'xlsx', 'pptx', 'terminal', 'editor'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
})
})
+1 -1
View File
@@ -15,7 +15,7 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
const g = globalThis as Record<string, unknown>
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
const CHUNKS = ['terminal', 'editor']
describe('built chunk artifacts', () => {
it('each chunk assigns its global registry slot when executed as a script', () => {
+21 -21
View File
@@ -61,29 +61,29 @@ beforeEach(() => {
describe('test-registry path (vitest / jsdom-less environments)', () => {
it('returns the registered chunk exports, loading once even for concurrent callers', async () => {
let calls = 0
registerChunkForTests('docx', async () => {
registerChunkForTests('editor', async () => {
calls += 1
return { DocxView: 'docx-view' }
return { TextEditor: 'text-editor' }
})
const [a, b, c] = await Promise.all([loadChunk('docx'), loadChunk('docx'), loadChunk('docx')])
expect(a).toEqual({ DocxView: 'docx-view' })
const [a, b, c] = await Promise.all([loadChunk('editor'), loadChunk('editor'), loadChunk('editor')])
expect(a).toEqual({ TextEditor: 'text-editor' })
expect(b).toBe(a)
expect(c).toBe(a)
expect(calls).toBe(1)
// Memoized after resolution too.
expect(await loadChunk('docx')).toBe(a)
expect(await loadChunk('editor')).toBe(a)
expect(calls).toBe(1)
})
it('a failed load clears the cache so the next call retries', async () => {
let calls = 0
registerChunkForTests('pptx', async () => {
registerChunkForTests('terminal', async () => {
calls += 1
if (calls === 1) throw new Error('boom')
return { PptxView: 'pptx-view' }
return { TerminalView: 'terminal-view' }
})
await expect(loadChunk('pptx')).rejects.toThrow('boom')
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
await expect(loadChunk('terminal')).rejects.toThrow('boom')
await expect(loadChunk('terminal')).resolves.toEqual({ TerminalView: 'terminal-view' })
expect(calls).toBe(2)
})
})
@@ -94,15 +94,15 @@ describe('production path (script injection + global registry + externals requir
const loaded: string[] = []
setChunkScriptLoaderForTests(async (src) => {
loaded.push(src)
simulateScript('xlsx', (require) => ({ XlsxView: `view:${String(require('react'))}` }))
simulateScript('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
})
const exports = await loadChunk('xlsx')
expect(loaded).toEqual(['/sidebar/bundle/xlsx.js'])
expect(exports).toEqual({ XlsxView: 'view:[object Object]' })
const exports = await loadChunk('editor')
expect(loaded).toEqual(['/sidebar/bundle/editor.js'])
expect(exports).toEqual({ TextEditor: 'view:[object Object]' })
// Externals resolved through the module system's seed branch, once.
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
// Memoized: no second script injection, no second externals resolution.
await loadChunk('xlsx')
await loadChunk('editor')
expect(loaded).toHaveLength(1)
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
})
@@ -123,9 +123,9 @@ describe('production path (script injection + global registry + externals requir
it('a chunk requiring an unresolvable externals spec fails loudly', async () => {
installModuleSystem()
setChunkScriptLoaderForTests(async () => {
simulateScript('docx', (require) => { require('not-in-the-table'); return {} })
simulateScript('editor', (require) => { require('not-in-the-table'); return {} })
})
await expect(loadChunk('docx')).rejects.toThrow('missed the module table')
await expect(loadChunk('editor')).rejects.toThrow('missed the module table')
})
it('a script load failure rejects without materializing', async () => {
@@ -149,14 +149,14 @@ describe('production path (script injection + global registry + externals requir
let calls = 0
setChunkScriptLoaderForTests(async () => {
calls += 1
simulateScript('pptx', () => {
simulateScript('editor', () => {
if (calls === 1) throw new Error('materialize boom')
return { PptxView: 'pptx-view' }
return { TextEditor: 'text-editor' }
})
})
await expect(loadChunk('pptx')).rejects.toThrow('materialize boom')
await expect(loadChunk('editor')).rejects.toThrow('materialize boom')
// Retry: script re-injected, slot overwritten, materialization succeeds.
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
await expect(loadChunk('editor')).resolves.toEqual({ TextEditor: 'text-editor' })
expect(calls).toBe(2)
expect(modules.import).toHaveBeenCalled()
})
@@ -164,7 +164,7 @@ describe('production path (script injection + global registry + externals requir
it('fails loudly when no module system is installed (before touching the network)', async () => {
const loaded: string[] = []
setChunkScriptLoaderForTests(async (src) => { loaded.push(src) })
await expect(loadChunk('docx')).rejects.toThrow('client module system unavailable')
await expect(loadChunk('editor')).rejects.toThrow('client module system unavailable')
expect(loaded).toEqual([])
})
+6 -6
View File
@@ -67,11 +67,11 @@ describe('lazyChunkComponent', () => {
it('shows the failure reason with a retry that recovers', async () => {
let fail = true
registerChunkForTests('docx', async () => {
registerChunkForTests('editor', async () => {
if (fail) throw new Error('boom')
return { DocxView: Marker }
return { TextEditor: Marker }
})
const Wrapper = lazyChunkComponent<Record<string, never>>('docx', (mod) => mod.DocxView as ComponentType<Record<string, never>> | undefined)
const Wrapper = lazyChunkComponent<Record<string, never>>('editor', (mod) => mod.TextEditor as ComponentType<Record<string, never>> | undefined)
const { container, unmount } = mount(createElement(Wrapper, {}))
await act(async () => {})
expect(container.textContent).toContain('boom')
@@ -87,8 +87,8 @@ describe('lazyChunkComponent', () => {
it('props flow through to the chunk component', async () => {
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
registerChunkForTests('pptx', async () => ({ PptxView: Recorder }))
const Wrapper = lazyChunkComponent<{ label: string }>('pptx', (mod) => mod.PptxView as ComponentType<{ label: string }> | undefined)
registerChunkForTests('terminal', async () => ({ TerminalView: Recorder }))
const Wrapper = lazyChunkComponent<{ label: string }>('terminal', (mod) => mod.TerminalView as ComponentType<{ label: string }> | undefined)
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
await act(async () => {})
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
@@ -99,7 +99,7 @@ describe('lazyChunkComponent', () => {
describe('built-in descriptor contract (render-prop functions)', () => {
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
const viewers = builtinViewers()
for (const id of ['docx', 'xlsx', 'pptx', 'markdown', 'html', 'code']) {
for (const id of ['markdown', 'html', 'code']) {
const descriptor = viewers.find(viewer => viewer.id === id)
expect(descriptor, id).toBeDefined()
// No chunk registered: calling must not throw — it returns the lazy
+1 -1
View File
@@ -65,7 +65,7 @@ function bundleId(file: string): string {
}
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
const CHUNK_FILES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'].map(name => `lib/client-${name}.js`)
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {
+66
View File
@@ -0,0 +1,66 @@
/**
* Built-in plugin catalog data-integrity tests (both files: the TAB
* catalog in plugins-tabs.ts and the FILE-PREVIEWER catalog in
* plugins-viewers.ts). Every entry must be installable as data — a unique
* id (npm package name), a GitHub URL, a non-empty localized description
* (string or () => string), and an install script that mentions the dsh
* plugin CLI. The catalogs are the discovery surface of the two "add
* plugin" modals; a malformed entry would break the install flow, so the
* shape is pinned here.
*/
import { describe, expect, it } from 'vitest'
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
import type { PluginEntry } from '../src/client/plugins-shared.ts'
/** Resolve an i18n-friendly string-or-function value (mirror of textOf). */
function textOf(value: string | (() => string)): string {
return typeof value === 'function' ? value() : value
}
const catalogs: Array<[string, readonly PluginEntry[]]> = [
['tab catalog', builtinTabPlugins],
['viewer catalog', builtinViewerPlugins],
]
describe('builtin plugin catalogs', () => {
it('the viewer catalog has the seeded office plugin, the tab catalog does not', () => {
const ids = (list: readonly PluginEntry[]): string[] => list.map(p => p.id)
expect(ids(builtinViewerPlugins)).toContain('@huanlin/dsh-plugin-better-sidebar-plugin-office')
expect(ids(builtinTabPlugins)).not.toContain('@huanlin/dsh-plugin-better-sidebar-plugin-office')
})
for (const [name, list] of catalogs) {
describe(name, () => {
it('every entry has a unique id (the npm package name)', () => {
const ids = list.map(p => p.id)
expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) {
expect(id.length).toBeGreaterThan(0)
expect(id).not.toContain(' ')
}
})
it('every entry has a name, a GitHub URL, and an install script', () => {
for (const entry of list) {
expect(entry.name.length).toBeGreaterThan(0)
expect(entry.url.startsWith('https://github.com/')).toBe(true)
expect(entry.install.length).toBeGreaterThan(0)
expect(entry.install).toContain('dsh plugin')
}
})
it('every description resolves to a non-empty localized string', () => {
for (const entry of list) {
expect(textOf(entry.description).length).toBeGreaterThan(0)
}
})
it('every install script starts at the DSH home (cd ~/.dsh) as the modal promises', () => {
for (const entry of list) {
expect(entry.install.startsWith('cd ~/.dsh')).toBe(true)
}
})
})
}
})
+14
View File
@@ -119,6 +119,20 @@ describe('SideCardSection declarative inventory', () => {
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
})
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
const { store, service } = mount()
const html = renderSection(store, service)
// The tab grid's dashed card (tab registration).
expect(html).toContain('Add tab plugins')
expect(html).toContain('Register a new sidebar page')
// The viewer grid's dashed card (file-previewer registration).
expect(html).toContain('Add preview plugins')
expect(html).toContain('Register a file-type preview')
// The add cards are plain buttons (open the modals), never switches:
// they carry no aria-pressed, so the pressed-card counts stay untouched.
expect(pressedCount(html, 'true')).toBe(3)
})
it('a disabled feature renders pressed=false', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
-23
View File
@@ -10,7 +10,6 @@ import {
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
import { officeKindForExt } from '../src/client/office-types.ts'
import { isPdfExt } from '../src/client/pdf-types.ts'
import { isImageExt } from '../src/client/image-types.ts'
import { relativeTo } from '../src/client/paths.ts'
@@ -1088,28 +1087,6 @@ describe('editor language mapping', () => {
})
})
describe('office preview kind', () => {
it('routes docx/xlsx to their renderers', () => {
expect(officeKindForExt('.docx')).toBe('docx')
expect(officeKindForExt('.xlsx')).toBe('xlsx')
})
it('routes pptx to its renderer and legacy OLE formats to download-only', () => {
expect(officeKindForExt('.pptx')).toBe('pptx')
expect(officeKindForExt('.doc')).toBe('download-only')
expect(officeKindForExt('.xls')).toBe('download-only')
expect(officeKindForExt('.ppt')).toBe('download-only')
})
it('returns null for non-Office extensions and empty input', () => {
expect(officeKindForExt('.txt')).toBeNull()
expect(officeKindForExt('.md')).toBeNull()
expect(officeKindForExt('.png')).toBeNull()
expect(officeKindForExt('.pdf')).toBeNull()
expect(officeKindForExt('')).toBeNull()
})
})
describe('pdf preview kind', () => {
it('routes only .pdf to the browser-native preview', () => {
expect(isPdfExt('.pdf')).toBe(true)