import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts' import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts' /** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */ function makePkg(root: string, name: string, pkg: Record, withBundle = true): string { const dir = join(root, name.replaceAll('/', '__')) mkdirSync(join(dir, 'lib'), { recursive: true }) writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg })) if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`) return join(dir, 'package.json') } const webDecl = (extra: Record = {}): Record => ({ dshClient: { inject: [], platform: 'web', ...extra }, exports: { '.': './lib/index.js', './client': './lib/client.js' }, }) interface Fixture { deps: WebPluginRegistryDeps entries: LoaderEntryView[] errors: Error[] ctx: Context } function makeDeps( specs: { name: string; pkg: Record; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[], ): Fixture { const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-')) const paths = new Map() const entries: LoaderEntryView[] = specs.map((spec) => { paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true)) return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false } }) const ctx = new Context() const errors: Error[] = [] const deps: WebPluginRegistryDeps = { ctx, loader: { entries: () => entries }, resolvePkgJson: (name) => { const path = paths.get(name) if (path === undefined) throw new Error(`unresolvable ${name}`) return path }, onError: err => void errors.push(err), } return { deps, entries, errors, ctx } } describe('createHostWebPluginRegistry', () => { it('collects loaded web-declared plugins with url/inject/immediately and client paths', () => { const { deps } = makeDeps([ { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) }, { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) }, { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped ]) const registry = createHostWebPluginRegistry(deps) const rows = registry.snapshot() expect(rows).toEqual([ { id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js', inject: [], immediately: true, }, { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: ['@deepseek-ai/dsh-client-runtime'], }, ]) expect(registry.clientPath('@deepseek-ai/dsh-client-connection')).toMatch(/lib[/\\]client\.js$/) expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined() registry.dispose() }) it('skips entries that are unloaded, disabled, or declare another platform', () => { const { deps } = makeDeps([ { name: 'not-loaded', pkg: webDecl(), loaded: false }, { name: 'disabled', pkg: webDecl(), disabled: true }, { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } }, ]) const registry = createHostWebPluginRegistry(deps) expect(registry.snapshot()).toEqual([]) registry.dispose() }) it('fails loud at build time on a dshClient declaration without a "./client" export', () => { const { deps } = makeDeps([ { name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } }, ]) expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/) }) it('fails loud on malformed declaration fields', () => { for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) { const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }]) expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/) } }) it('rescans on internal/plugin (debounced) and keeps the old table when a rescan fails', async () => { const { deps, entries, errors, ctx } = makeDeps([ { name: 'late-loader', pkg: webDecl(), loaded: false }, ]) const registry = createHostWebPluginRegistry(deps) expect(registry.snapshot()).toEqual([]) // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan. ;(entries[0] as { fiber?: unknown }).fiber = {} ctx.emit('internal/plugin', ctx.fiber) ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan await Promise.resolve() expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader']) // A failing rescan reports the error and keeps serving the previous table. entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false }) ctx.emit('internal/plugin', ctx.fiber) await Promise.resolve() expect(errors).toHaveLength(1) expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader']) // After dispose, further fiber events no longer rescan. registry.dispose() entries.pop() ctx.emit('internal/plugin', ctx.fiber) await Promise.resolve() expect(errors).toHaveLength(1) }) }) describe('injectBootManifest', () => { it('injects the manifest as the first script inside and escapes breakouts', () => { const html = '' const out = injectBootManifest(html, [{ id: 'x