DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features.
410 lines
17 KiB
TypeScript
410 lines
17 KiB
TypeScript
/**
|
|
* 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 module system injected via
|
|
* {@link setChunkModuleSystem} (mirroring the client half's `ctx.modules`
|
|
* injection) 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,
|
|
revalidateChunksOnReactivate,
|
|
resetChunks,
|
|
setChunkModuleSystem,
|
|
setChunkScriptLoaderForTests,
|
|
} from '../src/client/chunk-loader.ts'
|
|
import type { ChunkExports } from '../src/client/chunk-loader.ts'
|
|
|
|
interface FakeModuleSystem {
|
|
import: (specifier: string) => Promise<unknown>
|
|
}
|
|
|
|
function installModuleSystem(): FakeModuleSystem {
|
|
const fake: FakeModuleSystem = {
|
|
import: vi.fn(async (specifier: string) => ({ seed: specifier })),
|
|
}
|
|
setChunkModuleSystem(fake)
|
|
return fake
|
|
}
|
|
|
|
/** 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(() => {
|
|
setChunkModuleSystem(undefined)
|
|
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('editor', async () => {
|
|
calls += 1
|
|
return { TextEditor: 'text-editor' }
|
|
})
|
|
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('editor')).toBe(a)
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('a failed load clears the cache so the next call retries', async () => {
|
|
let calls = 0
|
|
registerChunkForTests('locale', async () => {
|
|
calls += 1
|
|
if (calls === 1) throw new Error('boom')
|
|
return { localeDicts: 'locale-dicts' }
|
|
})
|
|
await expect(loadChunk('locale')).rejects.toThrow('boom')
|
|
await expect(loadChunk('locale')).resolves.toEqual({ localeDicts: 'locale-dicts' })
|
|
expect(calls).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('production path (script injection + global registry + externals require)', () => {
|
|
it('resolves externals through an injected ctx.modules system (rc.8 — no page global)', async () => {
|
|
const modules = installModuleSystem()
|
|
const loaded: string[] = []
|
|
setChunkScriptLoaderForTests(async (src) => {
|
|
loaded.push(src)
|
|
simulateScript('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
|
|
})
|
|
const exports = await loadChunk('editor')
|
|
expect(loaded).toEqual(['/sidebar/bundle/editor.js'])
|
|
expect(exports).toEqual({ TextEditor: 'view:[object Object]' })
|
|
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
|
// The injection also lands on a plugin-owned global so chunk-bundle
|
|
// copies of this loader (which inline their own module instance and
|
|
// never run apply()) can resolve externals too — rc.8 has no shell
|
|
// page global anymore.
|
|
expect((globalThis as Record<string, unknown>).__dshSidebarModuleSystem__).toBe(modules)
|
|
// The injection survives resetChunks (shell state, not chunk state).
|
|
resetChunks()
|
|
setChunkScriptLoaderForTests(async () => {
|
|
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
|
|
})
|
|
await loadChunk('editor')
|
|
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length * 2)
|
|
})
|
|
|
|
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('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
|
|
})
|
|
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('editor')
|
|
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' : 'locale', () => ({}))
|
|
})
|
|
await loadChunk('locale')
|
|
await loadChunk('editor')
|
|
expect(seen).toEqual(['/sidebar/bundle/locale.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('editor', (require) => { require('not-in-the-table'); return {} })
|
|
})
|
|
await expect(loadChunk('editor')).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('locale')).rejects.toThrow('script 404')
|
|
expect(modules.import).not.toHaveBeenCalled()
|
|
// Cache cleared: the retry re-attempts the script load.
|
|
setChunkScriptLoaderForTests(async () => { simulateScript('locale', () => ({ localeDicts: 'ok' })) })
|
|
await expect(loadChunk('locale')).resolves.toEqual({ localeDicts: '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('editor', () => {
|
|
if (calls === 1) throw new Error('materialize boom')
|
|
return { TextEditor: 'text-editor' }
|
|
})
|
|
})
|
|
await expect(loadChunk('editor')).rejects.toThrow('materialize boom')
|
|
// Retry: script re-injected, slot overwritten, materialization succeeds.
|
|
await expect(loadChunk('editor')).resolves.toEqual({ TextEditor: 'text-editor' })
|
|
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('editor')).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('revalidateChunksOnReactivate (HMR re-activation keeps unchanged chunks)', () => {
|
|
/** Stub the bundle route's HEAD endpoint with per-name ETags (Error = unreachable). */
|
|
function stubBundleHead(etagFor: (name: string) => string | null | Error): void {
|
|
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
|
|
const url = String(input)
|
|
const name = url.endsWith('locale.js') ? 'locale' : 'editor'
|
|
const etag = etagFor(name)
|
|
if (etag instanceof Error) throw etag
|
|
return {
|
|
headers: { get: (key: string) => (key.toLowerCase() === 'etag' ? etag : null) },
|
|
} as unknown as Response
|
|
}))
|
|
}
|
|
|
|
/** Let the fire-and-forget ETag recorder (recordEtag) settle. */
|
|
const settleEtag = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 5))
|
|
|
|
it('keeps the resolved exports of an unchanged chunk — no re-inject / re-execute', async () => {
|
|
installModuleSystem()
|
|
let scriptCalls = 0
|
|
setChunkScriptLoaderForTests(async () => {
|
|
scriptCalls += 1
|
|
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
|
|
})
|
|
stubBundleHead(() => '"v1"')
|
|
const first = await loadChunk('editor')
|
|
expect(scriptCalls).toBe(1)
|
|
await settleEtag()
|
|
await revalidateChunksOnReactivate()
|
|
const second = await loadChunk('editor')
|
|
expect(second).toBe(first)
|
|
expect(scriptCalls).toBe(1)
|
|
})
|
|
|
|
it('drops a chunk whose ETag changed on disk — the next open re-injects and re-executes', async () => {
|
|
installModuleSystem()
|
|
let scriptCalls = 0
|
|
setChunkScriptLoaderForTests(async () => {
|
|
scriptCalls += 1
|
|
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
|
})
|
|
let etag = '"v1"'
|
|
stubBundleHead(() => etag)
|
|
const first = await loadChunk('editor')
|
|
await settleEtag()
|
|
etag = '"v2"'
|
|
await revalidateChunksOnReactivate()
|
|
const second = await loadChunk('editor')
|
|
expect(second).not.toBe(first)
|
|
expect(scriptCalls).toBe(2)
|
|
})
|
|
|
|
it('fails open when revalidation cannot reach the bundle route', async () => {
|
|
installModuleSystem()
|
|
let scriptCalls = 0
|
|
setChunkScriptLoaderForTests(async () => {
|
|
scriptCalls += 1
|
|
simulateScript('locale', () => ({ localeDicts: 'ld' }))
|
|
})
|
|
stubBundleHead(() => new Error('HEAD unreachable'))
|
|
await loadChunk('locale')
|
|
await settleEtag()
|
|
await revalidateChunksOnReactivate()
|
|
await loadChunk('locale')
|
|
expect(scriptCalls).toBe(2)
|
|
})
|
|
|
|
it('loadChunk is a barrier: a pending revalidation never serves stale exports (CR P1)', async () => {
|
|
installModuleSystem()
|
|
let scriptCalls = 0
|
|
setChunkScriptLoaderForTests(async () => {
|
|
scriptCalls += 1
|
|
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
|
})
|
|
// First load: etag "v1", recorded after settlement.
|
|
let etag = '"v1"'
|
|
stubBundleHead(() => etag)
|
|
const first = await loadChunk('editor')
|
|
await settleEtag()
|
|
expect(scriptCalls).toBe(1)
|
|
// The chunk changed on disk; the revalidation HEAD is gated (pending).
|
|
etag = '"v2"'
|
|
let release: (() => void) | undefined
|
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
await gate
|
|
return { headers: { get: () => etag } } as unknown as Response
|
|
}))
|
|
const revalidating = revalidateChunksOnReactivate()
|
|
// A lazy open DURING the pending revalidation must NOT get the old cache.
|
|
let resolved = false
|
|
const pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports })
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
expect(resolved, 'loadChunk must await the pending revalidation').toBe(false)
|
|
// Release the HEAD; the barrier lifts and the load re-injects fresh exports.
|
|
release?.()
|
|
await revalidating
|
|
const after = await pendingLoad
|
|
expect(after).not.toBe(first)
|
|
expect(scriptCalls).toBe(2)
|
|
})
|
|
|
|
it('a HEAD that never answers is bounded by a timeout signal and fails open (barrier cannot wedge loads forever)', async () => {
|
|
installModuleSystem()
|
|
let scriptCalls = 0
|
|
setChunkScriptLoaderForTests(async () => {
|
|
scriptCalls += 1
|
|
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
|
})
|
|
// First load settles an ETag under a healthy HEAD route.
|
|
stubBundleHead(() => '"v1"')
|
|
const first = await loadChunk('editor')
|
|
await settleEtag()
|
|
expect(scriptCalls).toBe(1)
|
|
// The revalidation HEAD now hangs. The loader must hand the fetch a
|
|
// timeout signal and, when it fires, fail open (drop + re-fetch) — the
|
|
// barrier must never block lazy loads indefinitely.
|
|
const controller = new AbortController()
|
|
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal)
|
|
vi.stubGlobal('fetch', vi.fn(async (_input, init?: RequestInit) => {
|
|
await new Promise<void>((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new DOMException('timed out', 'AbortError')))
|
|
})
|
|
}))
|
|
const revalidating = revalidateChunksOnReactivate()
|
|
let resolved = false
|
|
const pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports })
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
expect(resolved, 'loadChunk must await the pending revalidation').toBe(false)
|
|
controller.abort() // the timeout fires
|
|
await revalidating
|
|
const after = await pendingLoad
|
|
expect(after).not.toBe(first)
|
|
expect(scriptCalls).toBe(2)
|
|
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Number))
|
|
timeoutSpy.mockRestore()
|
|
})
|
|
|
|
it('resetChunks clears a pending revalidation barrier (the next load does not wait on the orphaned task)', async () => {
|
|
installModuleSystem()
|
|
let scriptCalls = 0
|
|
setChunkScriptLoaderForTests(async () => {
|
|
scriptCalls += 1
|
|
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
|
|
})
|
|
const etag = '"v1"'
|
|
stubBundleHead(() => etag)
|
|
await loadChunk('editor')
|
|
await settleEtag()
|
|
// Gate the revalidation HEAD so the barrier stays pending.
|
|
let release: (() => void) | undefined
|
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
await gate
|
|
return { headers: { get: () => etag } } as unknown as Response
|
|
}))
|
|
const revalidating = revalidateChunksOnReactivate()
|
|
// resetChunks mid-revalidation: the barrier guarded only the state being
|
|
// dropped, so the next load must proceed without waiting for the HEAD.
|
|
resetChunks()
|
|
const after = await loadChunk('editor')
|
|
expect(after).toEqual({ TextEditor: 'editor-view:2' })
|
|
expect(scriptCalls).toBe(2)
|
|
// The orphaned task still settles; its identity-guarded finally no-ops.
|
|
// (Its sweep may later drop the freshly loaded entry — chunkEtags was
|
|
// cleared by resetChunks, so the old ETag no longer matches. That is the
|
|
// fail-safe direction: never serve stale, at worst one redundant fetch.)
|
|
release?.()
|
|
await revalidating
|
|
})
|
|
|
|
it('clears test-registry fixtures on re-activation (per-test stubs never leak)', async () => {
|
|
let testCalls = 0
|
|
registerChunkForTests('editor', async () => { testCalls += 1; return { TextEditor: 'test-view' } })
|
|
await loadChunk('editor')
|
|
expect(testCalls).toBe(1)
|
|
await revalidateChunksOnReactivate()
|
|
// Test fixture gone: the production path runs and fails fast (no module system).
|
|
await expect(loadChunk('editor')).rejects.toThrow('client module system unavailable')
|
|
expect(testCalls).toBe(1)
|
|
})
|
|
})
|
|
|
|
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-ui-primitives',
|
|
])
|
|
})
|
|
})
|