feat: 客户端懒加载分块 — 重依赖(Univer/xterm/CodeMirror/Office)拆独立 bundle 按需下发(/sidebar/bundle 路由 + __dshChunks__ 注册表),核心启动仅 ~325KB
This commit is contained in:
@@ -12,6 +12,8 @@ if (g.self === undefined) g.self = globalThis
|
||||
|
||||
const elementStub = (): Record<string, unknown> => ({
|
||||
style: {},
|
||||
dataset: {},
|
||||
textContent: '',
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
setAttribute: () => {},
|
||||
getAttribute: () => null,
|
||||
@@ -32,9 +34,12 @@ if (g.document === undefined) {
|
||||
removeEventListener: () => {},
|
||||
defaultView: globalThis,
|
||||
body: elementStub(),
|
||||
// CodeMirror's view module probes the UA and injects styles at load.
|
||||
// CodeMirror's view module probes the UA and injects styles at load;
|
||||
// the plugin's css-inline prologue probes for existing style tags.
|
||||
documentElement: elementStub(),
|
||||
head: elementStub(),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
styleSheets: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Lazy chunk route tests (src/bundle-route.ts): the /sidebar/bundle handler
|
||||
* that serves the client's split bundles. Pins the trust fence, the name
|
||||
* allowlist (no traversal), method gating, and the caching contract — ETag
|
||||
* + If-None-Match 304 so multi-MB chunks revalidate cheaply instead of
|
||||
* re-downloading.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { CHUNK_NAMES, createBundleRouteHandler } from '../src/bundle-route.ts'
|
||||
|
||||
interface FakeRes {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
writeHead(status: number, headers?: Record<string, string>): void
|
||||
end(body?: string | Buffer): void
|
||||
}
|
||||
|
||||
function fakeRes(): FakeRes {
|
||||
return {
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: '',
|
||||
writeHead(status, headers = {}) {
|
||||
this.status = status
|
||||
this.headers = headers
|
||||
},
|
||||
end(body) {
|
||||
if (body !== undefined) this.body = body.toString()
|
||||
},
|
||||
} as FakeRes
|
||||
}
|
||||
|
||||
function req(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
|
||||
return { method, url, headers } as unknown as IncomingMessage
|
||||
}
|
||||
|
||||
/** 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;')
|
||||
const handler = createBundleRouteHandler(() => true, dir)
|
||||
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
|
||||
}
|
||||
|
||||
describe('/sidebar/bundle route', () => {
|
||||
it('serves an allowlisted chunk with the JS content type and an ETag', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.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')
|
||||
expect(res.headers.etag).toMatch(/^"[0-9a-f]{12}"$/)
|
||||
expect(res.body).toContain('window.__ModuleLoader__')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('revalidates with a 304 when If-None-Match matches (no re-download)', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.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)
|
||||
expect(second.status).toBe(304)
|
||||
expect(second.body).toBe('')
|
||||
expect(second.headers.etag).toBe(etag)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the new body after the file changed (ETag rotates)', async () => {
|
||||
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;')
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.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')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown chunk names (including traversal attempts) with 404', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
for (const url of ['/sidebar/bundle/evil.js', '/sidebar/bundle/xlsx', '/sidebar/bundle/../secret.js', '/sidebar/bundle/xlsx.js/extra']) {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', url), res as unknown as ServerResponse)
|
||||
expect(res.status, url).toBe(404)
|
||||
}
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('404s an allowlisted name whose built file is missing', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(404)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('gates non-GET/HEAD methods with 405', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('POST', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(405)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces the browser-trust fence with 403 before any lookup', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-fence-'))
|
||||
try {
|
||||
const handler = createBundleRouteHandler(() => false, dir)
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(403)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
||||
expect([...CHUNK_NAMES]).toEqual(['docx', 'xlsx', 'pptx', 'terminal', 'editor'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Built chunk artifact contract: each lib/client-<name>.js must, when
|
||||
* executed as a classic script, assign its factory to the plugin-owned
|
||||
* global registry (globalThis.__dshChunks__[<name>]), and the factory must
|
||||
* be callable with a require that resolves the platform externals — the
|
||||
* exact shape the loader (src/client/chunk-loader.ts) depends on. Reads the
|
||||
* built lib/ output, so run `pnpm build` first (like manifest-consistency).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// Browser globals first: chunk bodies probe `self`/`document` at evaluation
|
||||
// (xterm's UMD wrapper, CodeMirror's UA probe).
|
||||
import './browser-globals.ts'
|
||||
import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
|
||||
|
||||
describe('built chunk artifacts', () => {
|
||||
it('each chunk assigns its global registry slot when executed as a script', () => {
|
||||
g.window = g // classic-script globals
|
||||
for (const name of CHUNKS) {
|
||||
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
|
||||
// eslint-disable-next-line no-new-func
|
||||
expect(() => new Function(code)(), name).not.toThrow()
|
||||
const registry = g.__dshChunks__ as Record<string, unknown>
|
||||
expect(typeof registry[name], name).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('each chunk factory materializes through a require over the platform externals', () => {
|
||||
const registry = g.__dshChunks__ as Record<string, unknown>
|
||||
const table = new Map<string, unknown>(CHUNK_EXTERNALS.map(spec => [spec, { spec }]))
|
||||
for (const name of CHUNKS) {
|
||||
const factory = registry[name] as (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
expect(() => factory((spec) => {
|
||||
if (!table.has(spec)) throw new Error(`require("${spec}") missed the module table`)
|
||||
return table.get(spec)
|
||||
}), name).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Chunk loader tests: the lazy chunk machinery (src/client/chunk-loader.ts).
|
||||
* Pins the caching contract that makes the lazy mechanism correct:
|
||||
* - one in-flight promise per chunk (concurrent opens share it),
|
||||
* - a failed load clears the cache so the next call retries (the script
|
||||
* re-executes and overwrites its global registry slot — assignments are
|
||||
* idempotent, no duplicate-registration class of errors),
|
||||
* - externals resolve through the module system's seed branch (the stable,
|
||||
* version-independent part), once per page,
|
||||
* - resetChunks drops the cache and the externals memo (HMR).
|
||||
* The production path runs against a fake `window.__DSH_MODULES__` and a
|
||||
* stub script loader that simulates the executed chunk script by assigning
|
||||
* the plugin-owned global factory registry.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
import {
|
||||
CHUNK_EXTERNALS,
|
||||
loadChunk,
|
||||
registerChunkForTests,
|
||||
resetChunks,
|
||||
setChunkScriptLoaderForTests,
|
||||
} from '../src/client/chunk-loader.ts'
|
||||
import type { ChunkExports } from '../src/client/chunk-loader.ts'
|
||||
|
||||
interface FakeModuleSystem {
|
||||
import: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function installModuleSystem(): FakeModuleSystem {
|
||||
const fake: FakeModuleSystem = {
|
||||
import: vi.fn(async (specifier: string) => ({ seed: specifier })),
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).__DSH_MODULES__ = fake
|
||||
return fake
|
||||
}
|
||||
|
||||
function removeModuleSystem(): void {
|
||||
delete (globalThis as Record<string, unknown>).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The global registry the chunk scripts populate (mirror of chunk-loader). */
|
||||
function registry(): Record<string, unknown> {
|
||||
return (globalThis as { __dshChunks__?: Record<string, unknown> }).__dshChunks__ ?? {}
|
||||
}
|
||||
|
||||
/** Simulate a chunk script executing: it assigns its factory to the registry. */
|
||||
function simulateScript(name: string, factory: (require: (spec: string) => unknown) => ChunkExports): void {
|
||||
const g = globalThis as { __dshChunks__?: Record<string, unknown> }
|
||||
g.__dshChunks__ = g.__dshChunks__ ?? {}
|
||||
g.__dshChunks__[name] = factory
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
removeModuleSystem()
|
||||
delete (globalThis as Record<string, unknown>).__dshChunks__
|
||||
resetChunks()
|
||||
setChunkScriptLoaderForTests(null)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
calls += 1
|
||||
return { DocxView: 'docx-view' }
|
||||
})
|
||||
const [a, b, c] = await Promise.all([loadChunk('docx'), loadChunk('docx'), loadChunk('docx')])
|
||||
expect(a).toEqual({ DocxView: 'docx-view' })
|
||||
expect(b).toBe(a)
|
||||
expect(c).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
// Memoized after resolution too.
|
||||
expect(await loadChunk('docx')).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 () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('boom')
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('production path (script injection + global registry + externals require)', () => {
|
||||
it('injects the chunk script, then materializes the factory with externals from the module table', async () => {
|
||||
const modules = installModuleSystem()
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
loaded.push(src)
|
||||
simulateScript('xlsx', (require) => ({ XlsxView: `view:${String(require('react'))}` }))
|
||||
})
|
||||
const exports = await loadChunk('xlsx')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/xlsx.js'])
|
||||
expect(exports).toEqual({ XlsxView: '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')
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
})
|
||||
|
||||
it('resolves the externals table once across chunks', async () => {
|
||||
const modules = installModuleSystem()
|
||||
const seen: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
seen.push(src)
|
||||
simulateScript(src.endsWith('editor.js') ? 'editor' : 'terminal', () => ({}))
|
||||
})
|
||||
await loadChunk('terminal')
|
||||
await loadChunk('editor')
|
||||
expect(seen).toEqual(['/sidebar/bundle/terminal.js', '/sidebar/bundle/editor.js'])
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
})
|
||||
|
||||
it('a chunk requiring an unresolvable externals spec fails loudly', async () => {
|
||||
installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
simulateScript('docx', (require) => { require('not-in-the-table'); return {} })
|
||||
})
|
||||
await expect(loadChunk('docx')).rejects.toThrow('missed the module table')
|
||||
})
|
||||
|
||||
it('a script load failure rejects without materializing', async () => {
|
||||
const modules = installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => { throw new Error('script 404') })
|
||||
await expect(loadChunk('terminal')).rejects.toThrow('script 404')
|
||||
expect(modules.import).not.toHaveBeenCalled()
|
||||
// Cache cleared: the retry re-attempts the script load.
|
||||
setChunkScriptLoaderForTests(async () => { simulateScript('terminal', () => ({ TerminalView: 'ok' })) })
|
||||
await expect(loadChunk('terminal')).resolves.toEqual({ TerminalView: 'ok' })
|
||||
})
|
||||
|
||||
it('a script that ran but registered no factory fails with a clear error', async () => {
|
||||
installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => { /* executes but assigns nothing */ })
|
||||
await expect(loadChunk('editor')).rejects.toThrow('did not register its factory')
|
||||
})
|
||||
|
||||
it('a materialization failure clears the cache; the retry re-executes cleanly (idempotent slot assignment)', async () => {
|
||||
const modules = installModuleSystem()
|
||||
let calls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
calls += 1
|
||||
simulateScript('pptx', () => {
|
||||
if (calls === 1) throw new Error('materialize boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
})
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('materialize boom')
|
||||
// Retry: script re-injected, slot overwritten, materialization succeeds.
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
expect(calls).toBe(2)
|
||||
expect(modules.import).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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')
|
||||
expect(loaded).toEqual([])
|
||||
})
|
||||
|
||||
it('resetChunks drops the cache and the externals memo (HMR re-activation)', async () => {
|
||||
const modules = installModuleSystem()
|
||||
let calls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
calls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
|
||||
})
|
||||
await loadChunk('editor')
|
||||
expect(calls).toBe(1)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
resetChunks()
|
||||
// Cache dropped: the next open re-fetches and re-executes; the externals
|
||||
// memo is rebuilt (fresh module-table resolution).
|
||||
await loadChunk('editor')
|
||||
expect(calls).toBe(2)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length * 2)
|
||||
})
|
||||
|
||||
it('resetChunks is a safe no-op without a module system', () => {
|
||||
resetChunks()
|
||||
expect(() => resetChunks()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('externals contract', () => {
|
||||
it('the loader resolves exactly the platform externals the chunk builds keep external', () => {
|
||||
expect(CHUNK_EXTERNALS).toEqual([
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Lazy wrapper tests (src/client/lazy-chunk.tsx): the wrapper that mounts
|
||||
* chunk-resident components from the built-in descriptors. Pins the two
|
||||
* contracts that matter for the descriptor API:
|
||||
* - the wrapper is a plain render-prop function — `component(props)` can be
|
||||
* called directly (Sidebar's style) without a chunk registered and
|
||||
* without throwing; hooks live in the inner component only,
|
||||
* - loading → placeholder, failure → error + retry that recovers, success →
|
||||
* the chunk component rendered.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createElement, type ComponentType, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { builtinTabs } from '../src/client/builtins/tabs.tsx'
|
||||
import { builtinViewers } from '../src/client/builtins/viewers.tsx'
|
||||
import { registerChunkForTests, resetChunks } from '../src/client/chunk-loader.ts'
|
||||
import { lazyChunkComponent } from '../src/client/lazy-chunk.tsx'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import type { FileViewerProps, TabComponentProps } from '../src/client/service.ts'
|
||||
import css from '../src/client/sidebar.module.css'
|
||||
|
||||
/** Render `node` into a detached body container under React's act(). */
|
||||
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
// act() flushes the (concurrent) initial commit — the placeholder must be
|
||||
// in the DOM before the async load flushes below.
|
||||
act(() => { root.render(node) })
|
||||
const unmount = (): void => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
return { container, unmount }
|
||||
}
|
||||
|
||||
const Marker = (): ReactNode => createElement('div', { 'data-testid': 'chunk-rendered' }, 'loaded')
|
||||
|
||||
beforeEach(() => {
|
||||
resetChunks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Defensive: drop any containers left by failed assertions.
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('lazyChunkComponent', () => {
|
||||
it('renders the loading placeholder first, then the chunk component', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('editor', async () => {
|
||||
calls += 1
|
||||
return { TextEditor: Marker }
|
||||
})
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('editor', (mod) => mod.TextEditor as ComponentType<{ label: string }> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, { label: 'x' }))
|
||||
// Initial paint: the loading placeholder (no chunk loaded yet).
|
||||
expect(container.querySelector(`.${css.editorPlaceholder}`)).not.toBeNull()
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
expect(container.textContent).toContain('loaded')
|
||||
expect(calls).toBe(1)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows the failure reason with a retry that recovers', async () => {
|
||||
let fail = true
|
||||
registerChunkForTests('docx', async () => {
|
||||
if (fail) throw new Error('boom')
|
||||
return { DocxView: Marker }
|
||||
})
|
||||
const Wrapper = lazyChunkComponent<Record<string, never>>('docx', (mod) => mod.DocxView as ComponentType<Record<string, never>> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, {}))
|
||||
await act(async () => {})
|
||||
expect(container.textContent).toContain('boom')
|
||||
// The failed load cleared the loader cache; retry now succeeds.
|
||||
fail = false
|
||||
const button = container.querySelector('button')
|
||||
expect(button).not.toBeNull()
|
||||
await act(async () => { button!.click() })
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
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)
|
||||
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
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']) {
|
||||
const descriptor = viewers.find(viewer => viewer.id === id)
|
||||
expect(descriptor, id).toBeDefined()
|
||||
// No chunk registered: calling must not throw — it returns the lazy
|
||||
// element (the loading placeholder renders once mounted).
|
||||
expect(() => descriptor!.component({} as FileViewerProps), id).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('the terminal tab component keeps the same contract', () => {
|
||||
const tabs = builtinTabs({} as Context)
|
||||
const terminal = tabs.find(tab => tab.id === 'terminal')
|
||||
expect(terminal).toBeDefined()
|
||||
// The descriptor reads tab.id (the tabId mapping); a real tab is part of
|
||||
// the contract — Sidebar always provides one.
|
||||
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
|
||||
expect(() => terminal!.component(props)).not.toThrow()
|
||||
})
|
||||
|
||||
it('a built-in viewer mounted without a chunk available degrades to the error + retry affordance (no crash)', async () => {
|
||||
// No test chunk registered and jsdom has no client module system: the
|
||||
// load fails and the wrapper must degrade gracefully, never throw.
|
||||
const viewers = builtinViewers()
|
||||
const code = viewers.find(viewer => viewer.id === 'code')!
|
||||
const { container, unmount } = mount(createElement(code.component, {} as FileViewerProps))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector(`.${css.editorError}`)).not.toBeNull()
|
||||
expect(container.querySelector('button')).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('a registered chunk makes the built-in viewer render through the real descriptor path', async () => {
|
||||
registerChunkForTests('editor', async () => ({ TextEditor: Marker }))
|
||||
const viewers = builtinViewers()
|
||||
const markdown = viewers.find(viewer => viewer.id === 'markdown')!
|
||||
// EditorHost renders viewer components via createElement(component, props).
|
||||
const { container, unmount } = mount(createElement(markdown.component, {
|
||||
ctx: {},
|
||||
store: undefined,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/a.md',
|
||||
title: 'a.md',
|
||||
viewerId: 'markdown',
|
||||
} as unknown as FileViewerProps))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('the terminal descriptor maps tab.id → tabId (TerminalView props are not TabComponentProps)', async () => {
|
||||
let received: unknown
|
||||
registerChunkForTests('terminal', async () => ({
|
||||
TerminalView: ((props: { tabId: string }) => {
|
||||
received = props.tabId
|
||||
return createElement('div', { 'data-testid': 'terminal-tabid' }, props.tabId)
|
||||
}) as unknown as ComponentType<Record<string, never>>,
|
||||
}))
|
||||
const tabs = builtinTabs({} as Context)
|
||||
const terminal = tabs.find(tab => tab.id === 'terminal')!
|
||||
const props = {
|
||||
ctx: {},
|
||||
store: undefined,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
tab: { id: 'terminal:2', type: 'terminal', title: '终端 2' },
|
||||
visible: true,
|
||||
} as unknown as TabComponentProps
|
||||
const { container, unmount } = mount(createElement(terminal.component, props))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="terminal-tabid"]')?.textContent).toBe('terminal:2')
|
||||
expect(received).toBe('terminal:2')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('the loader cache survives across descriptor mounts (chunk fetched once)', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('terminal', async () => {
|
||||
calls += 1
|
||||
return { TerminalView: Marker }
|
||||
})
|
||||
const tabs = builtinTabs({} as Context)
|
||||
const terminal = tabs.find(tab => tab.id === 'terminal')!
|
||||
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
|
||||
const { container: first, unmount: unmountFirst } = mount(createElement(terminal.component, props))
|
||||
await act(async () => {})
|
||||
expect(first.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmountFirst()
|
||||
const { container: second, unmount: unmountSecond } = mount(createElement(terminal.component, props))
|
||||
await act(async () => {})
|
||||
expect(second.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmountSecond()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,17 @@ function bundleId(file: string): string {
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
/** 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`)
|
||||
|
||||
/** The global registry slot a built chunk script assigns (its factory key). */
|
||||
function chunkSlot(file: string): string {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
const match = /globalThis\.__dshChunks__\["([a-z0-9-]+)"\]/.exec(source)
|
||||
if (match === null) throw new Error(`${file} assigns no globalThis.__dshChunks__ slot — run \`pnpm build\` first`)
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
describe('registry manifest consistency (dsh.plugin.json)', () => {
|
||||
it('id is a valid two-segment registry id (native form)', () => {
|
||||
expect(manifest.id).toMatch(ID_PATTERN)
|
||||
@@ -86,13 +97,20 @@ describe('registry manifest consistency (dsh.plugin.json)', () => {
|
||||
expect(registryId).not.toBe(pkg.name)
|
||||
})
|
||||
|
||||
it('the lazy chunk bundles exist and assign their global registry slots (served by /sidebar/bundle)', () => {
|
||||
for (const file of CHUNK_FILES) {
|
||||
expect(existsSync(resolve(ROOT, file)), file).toBe(true)
|
||||
expect(chunkSlot(file), file).toBe(file.slice('lib/client-'.length, -'.js'.length))
|
||||
}
|
||||
})
|
||||
|
||||
it('client bundles require only frozen module-table entries', () => {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!]) {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
// Exclude method calls such as `freeModule.require("util")`; only the
|
||||
// loader factory's lexical require() is constrained by the module table.
|
||||
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
||||
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id))).toEqual([])
|
||||
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id)), file).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ describe('host plugin smoke', () => {
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file', '/sidebar/html'])
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html'])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
||||
// Teardown runs without throwing (pty manager has nothing open).
|
||||
for (const cleanup of effects) cleanup()
|
||||
|
||||
Reference in New Issue
Block a user