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:
1 parent
1b0b382edc
commit
cbc28473a0
40 files changed
+942
-1128
No files matched your search
@@ -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('&', '&'))
|
||||
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
@@ -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
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user