The dev bundle watch missed rebuilds that landed while the registry was
constructing: fs.watchFile captures its comparison baseline with an
ASYNCHRONOUS first stat, so a write racing that window is absorbed into
the baseline and never reported. Standalone repro missed 24/400 same-tick
rewrites; the CI flake in web-plugins.spec.ts ('watch mode: a bundle
content change re-hashes the row...') was exactly this — the spec writes
immediately after createHostWebPluginRegistry returns.
The watch now polls from one registry-owned setInterval against a stat
baseline the scan itself captures synchronously, stat-before-read: a
write landing between stat and read leaves the hash newer than the
baseline (next tick re-hashes to the same rev, no spurious notify); a
write landing after the read leaves the baseline older (next tick
detects and notifies). No blind window. The poll iterates the live
table, so rescans retarget the watch for free and dispose clears one
timer. Stress: real-registry same-tick rewrite 0/600 missed (was 1/300);
spec watch tests 0/50.
New regression test pins the same-tick-as-construction write. Its
rewrite deliberately differs in size from the seed: a same-millisecond
same-size rewrite is invisible to any mtime+size poll (coarse fs
timestamps) — a stat-polling limit, not this regression. Loading-model
Agent Note updated in both languages (pair re-recorded).
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
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, vi } 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<string, unknown>, 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<string, unknown> = {}): Record<string, unknown> => ({
|
|
dshClient: { inject: [], platform: 'web', ...extra },
|
|
exports: { '.': './lib/index.js', './client': './lib/client.js' },
|
|
})
|
|
|
|
interface Fixture {
|
|
deps: WebPluginRegistryDeps
|
|
entries: LoaderEntryView[]
|
|
errors: Error[]
|
|
ctx: Context
|
|
root: string
|
|
}
|
|
|
|
function makeDeps(
|
|
specs: { name: string; pkg: Record<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
|
|
): Fixture {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
|
|
const paths = new Map<string, string>()
|
|
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, root }
|
|
}
|
|
|
|
describe('createHostWebPluginRegistry', () => {
|
|
it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => {
|
|
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 graph = registry.graph()
|
|
expect(graph.rev).toMatch(/^[0-9a-f]{12}$/)
|
|
const connection = graph.entries[0]
|
|
expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection')
|
|
expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/)
|
|
expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`)
|
|
expect(connection?.immediately).toBe(true)
|
|
const layout = graph.entries[1]
|
|
expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout')
|
|
expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime'])
|
|
expect(layout?.immediately).toBeUndefined()
|
|
expect(graph.entries).toHaveLength(2)
|
|
expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).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.graph().entries).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 at build time on a registered bundle that is not built (rev hashing reads the file)', () => {
|
|
const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }])
|
|
expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/)
|
|
})
|
|
|
|
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('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => {
|
|
const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }])
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
const before = registry.graph()
|
|
const beforeRow = before.entries.find(e => e.id === 'hot')
|
|
writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents')
|
|
const rev = registry.rebuilt('hot')
|
|
expect(rev).toMatch(/^[0-9a-f]{12}$/)
|
|
expect(rev).not.toBe(beforeRow?.rev)
|
|
const after = registry.graph()
|
|
const afterRow = after.entries.find(e => e.id === 'hot')
|
|
expect(afterRow?.rev).toBe(rev)
|
|
expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`)
|
|
expect(afterRow?.immediately).toBe(true)
|
|
expect(after.rev).not.toBe(before.rev)
|
|
// Unknown ids are not rebuildable.
|
|
expect(registry.rebuilt('nope')).toBeUndefined()
|
|
registry.dispose()
|
|
})
|
|
|
|
it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => {
|
|
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
|
|
deps.watch = { intervalMs: 20 }
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
const before = registry.graph().entries[0]?.rev
|
|
const rebuilds: { id: string; rev: string }[] = []
|
|
registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
|
|
|
|
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents')
|
|
await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 })
|
|
expect(rebuilds[0]?.id).toBe('watched')
|
|
expect(rebuilds[0]?.rev).not.toBe(before)
|
|
expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
|
|
|
|
registry.dispose()
|
|
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents')
|
|
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
|
expect(rebuilds).toHaveLength(1)
|
|
})
|
|
|
|
it('watch mode: a write landing during registry construction is still detected (regression: fs.watchFile baseline absorption)', async () => {
|
|
// The old fs.watchFile watch captured its comparison baseline with an
|
|
// ASYNCHRONOUS first stat; a rewrite in the same tick as construction was
|
|
// absorbed into that baseline and never reported (the CI flake). The
|
|
// record-baseline poll stats synchronously before hashing, so this exact
|
|
// timing must now always notify.
|
|
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
|
|
deps.watch = { intervalMs: 20 }
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
const rebuilds: string[] = []
|
|
registry.onRebuilt(id => rebuilds.push(id))
|
|
// Same tick as construction — inside the old watch's blind window. The
|
|
// rewrite deliberately differs in SIZE from the seed: a same-millisecond
|
|
// same-size rewrite is invisible to any mtime+size poll by construction
|
|
// (coarse filesystem timestamps), which is a stat-polling limit, not the
|
|
// regression under test.
|
|
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// same-tick rewritten contents')
|
|
await vi.waitFor(() => { expect(rebuilds).toEqual(['watched']) }, { timeout: 5000 })
|
|
registry.dispose()
|
|
})
|
|
|
|
it('rejects a non-positive or non-integer watch interval at build time', () => {
|
|
for (const intervalMs of [0, -5, 1.5]) {
|
|
const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])
|
|
deps.watch = { intervalMs }
|
|
expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/)
|
|
}
|
|
})
|
|
|
|
it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => {
|
|
const { deps, entries, errors, ctx } = makeDeps([
|
|
{ name: 'late-loader', pkg: webDecl(), loaded: false },
|
|
])
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
expect(registry.graph().entries).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.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
|
|
|
// A failing rescan reports the error and keeps serving the previous graph.
|
|
entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
|
|
ctx.emit('internal/plugin', ctx.fiber)
|
|
await Promise.resolve()
|
|
expect(errors).toHaveLength(1)
|
|
expect(registry.graph().entries.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 graph as the first script inside <head> and escapes </script> breakouts', () => {
|
|
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
|
|
const out = injectBootManifest(html, {
|
|
rev: 'r1',
|
|
entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
|
|
})
|
|
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
|
|
expect(out).not.toContain('</script><script>alert(1)')
|
|
expect(out).toContain('\\u003c/script')
|
|
})
|
|
|
|
it('prepends when the page has no <head>', () => {
|
|
const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
|
|
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('clientExportOf shapes (through the registry build)', () => {
|
|
it('accepts the conditional {types, default} export form', () => {
|
|
const { deps } = makeDeps([{
|
|
name: 'conditional',
|
|
pkg: {
|
|
dshClient: { platform: 'web' },
|
|
exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
|
|
},
|
|
}])
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
|
|
registry.dispose()
|
|
})
|
|
|
|
it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
|
|
for (const exportsField of [
|
|
{ './client': { types: './x.d.ts' } },
|
|
{ './client': ['./a.js'] },
|
|
]) {
|
|
const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
|
|
expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
|
|
}
|
|
// Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
|
|
const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
|
|
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
|
|
})
|
|
|
|
it('skips duplicate loader entries for the same package name (first wins)', () => {
|
|
const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
|
|
const first = entries[0] as LoaderEntryView
|
|
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
|
|
void first
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
|
|
registry.dispose()
|
|
})
|
|
|
|
it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
|
|
// client: null → the object-form branch's null guard.
|
|
const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
|
|
expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
|
|
|
|
// Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
|
|
const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
|
|
const registry = createHostWebPluginRegistry(deps)
|
|
entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
|
|
const original = deps.resolvePkgJson
|
|
deps.resolvePkgJson = (name) => {
|
|
|
|
if (name === 'ghost-two') throw 'string failure'
|
|
return original(name)
|
|
}
|
|
ctx.emit('internal/plugin', ctx.fiber)
|
|
await Promise.resolve()
|
|
expect(errors[0]).toBeInstanceOf(Error)
|
|
expect(String(errors[0])).toContain('string failure')
|
|
registry.dispose()
|
|
})
|
|
|
|
})
|