Files
deepseek-harness/packages/client/modules/tests/entries.client.spec.ts
T
imccyu 6e77ea0131 feat(web): boot past failed optional client plugins (#7733)
* feat(web): boot past failed optional client plugins

The Web client boot audit now follows Host startup strictness. An
inactive entry on the global required list (module system, Session
transport, renderer, application frame, Session view, and sidebar)
still rejects startup and the report marks the required entries.
Other inactive entries produce one console warning and the
application mounts without them. The boot page no longer draws a
failed entry before the audit decides.

* fix(web): audit boot entries whose creation rejects and name activation errors

ClientEntries.start() now logs a row whose Loader creation rejects, such
as a module that exports no plugin, and leaves it inactive for the boot
audit, as the Host Loader group does. The audit awaits only failed
fibers to report their recorded activation error. Notes, READMEs, and
the Web client subsystem page describe the browser boot audit; tests
cover invalid exports, carrier presentation of optional failures, and
required names absent from the boot graph.

* test(web): fail test carriers and e2e on inactive client entries

The product boot only warns about inactive entries outside its required
list, but tests must still surface every plugin that fails. TestClient
and createClientTest now require every roster entry to activate unless
a case passes allowInactiveEntries. The watchConsole tripwire, the
real-host smoke, and the assembled jsdom boot treat a web boot warning
as a failure with no opt-out.

Docs: the Web README names Session control, notes that an entry without
a fiber is reported as an import failure like the Host audit's "failed
to import", and the startup strictness note lists the browser tests.
2026-11-02 16:20:50 +00:00

672 lines
33 KiB
TypeScript

// @vitest-environment jsdom
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply as provideModules, createClientModuleSystem } from '../src/client/index.ts'
import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootRelease, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) {
await ctx.fiber.dispose()
await ctx.fiber.await()
}
document.head.querySelectorAll('style').forEach((el) => { el.remove() })
document.body.replaceChildren()
vi.restoreAllMocks()
})
const row = (id: string, rev = 'r0', extra: Partial<WebBootEntry> = {}): WebBootEntry => ({
id, rev, url: `/plugins/??${id}/client.js&rev=${rev}`, ...extra,
})
const graph = (...entries: WebBootEntry[]): WebBootGraph => ({
rev: JSON.stringify(entries), entries,
batches: entries.length === 0 ? [] : [{ phase: 'application', url: '/batch', rev: 'batch', entries: entries.map(row => row.id) }],
})
const deferred = () => {
let resolve!: () => void
const promise = new Promise<void>((done) => { resolve = done })
return { promise, resolve }
}
async function bench(initial: WebBootGraph, factories: Record<string, ClientBundleRegistration['factory']>, start = true) {
const ctx = new Context()
contexts.push(ctx)
const fetched: string[] = []
const target: ClientModuleLoaderTarget = {
mode: 'queue', pendingQueue: [], load: () => {},
create: options => createClientModuleSystem(target, { id: 'bootstrap', exports: { inject: ['loader'], apply: provideModules } }, options),
}
let arrival: (url: string) => Promise<void> = async () => {}
const modules = target.create({
boot: initial, staticModules: {},
loadBundle: async (url) => {
fetched.push(url)
const ids = url === '/batch' ? initial.entries.map(row => row.id).filter(id => id !== 'bootstrap') : [url.split('??')[1]!.split('/client.js')[0]!]
const registrations = ids.map(id => ({ id, factory: factories[id]! }))
await arrival(url)
for (const registration of registrations) target.load(registration)
},
})
await ctx.plugin(Loader)
ctx.loader.internal = modules as never
if (start) await modules.entries.start(ctx.loader, modules.manifest)
return { ctx, modules, fetched, target, arrival: (fn: typeof arrival) => { arrival = fn } }
}
/** A visible plugin whose style and listener are owned by its factory and fiber respectively. */
function visible(id: string, effects: { mounted: number; disposed: number; hits: number }, cleanup?: () => Promise<void>) {
return () => {
const style = document.createElement('style')
style.dataset.plugin = id
style.textContent = `[data-live="${id}"] { color: rgb(12, 34, 56); }`
document.head.append(style)
return { apply(ctx: Context) {
ctx.effect(() => {
effects.mounted++
const el = document.createElement('div')
el.dataset.live = id
document.body.append(el)
const listener = () => { effects.hits++ }
window.addEventListener('live-test', listener)
return async () => {
window.removeEventListener('live-test', listener)
el.remove()
await cleanup?.()
effects.disposed++
}
})
} }
}
}
describe('client manifest entries', () => {
it('adds, drains removal and re-enables one instance with styles; unrelated entries survive', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const cleanup = deferred()
const b = await bench(graph(), { pet: visible('pet', effects, () => cleanup.promise) })
b.target.load({ id: 'local', factory: () => ({ apply() {} }) })
const localId = await b.ctx.loader.create({ name: 'local' })
await b.modules.entries.sync(graph(row('pet')))
expect(b.fetched).toEqual([row('pet').url])
expect(document.querySelectorAll('[data-live=pet]')).toHaveLength(1)
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
window.dispatchEvent(new Event('live-test'))
expect(effects.hits).toBe(1)
const removing = b.modules.entries.sync(graph())
await vi.waitFor(() => { expect(document.querySelector('[data-live=pet]')).toBeNull() })
const readding = b.modules.entries.sync(graph(row('pet')))
expect(effects.disposed).toBe(0)
expect(effects.mounted).toBe(1)
cleanup.resolve()
await Promise.all([removing, readding])
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 1 })
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
expect(b.ctx.loader.resolve(localId).fiber?.state).toBe(2)
await b.modules.entries.sync(graph())
window.dispatchEvent(new Event('live-test'))
expect(effects).toEqual({ mounted: 2, disposed: 2, hits: 1 })
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(0)
expect(b.modules.loadCache.has('pet')).toBe(false)
expect(b.modules.entries.state.getSnapshot()).toEqual({ syncing: false, failures: [] })
})
it('does not mount an obsolete download and can load it again later', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(), { pet: visible('pet', effects) })
const arrival = deferred()
const started = deferred()
b.arrival(async () => { started.resolve(); await arrival.promise })
const enabling = b.modules.entries.sync(graph(row('pet')))
await started.promise
const disabling = b.modules.entries.sync(graph())
arrival.resolve()
await Promise.all([enabling, disabling])
expect(effects.mounted).toBe(0)
expect(b.modules.loadCache.has('pet')).toBe(false)
await b.modules.entries.sync(graph(row('pet')))
expect(effects.mounted).toBe(1)
expect(b.fetched).toEqual([row('pet').url, row('pet').url])
})
it('registers dynamic dependencies first and retains one still required by an unmanaged entry', async () => {
const materialized = vi.fn<(id: string) => void>()
const b = await bench(graph(row('existing')), {
existing: () => { materialized('existing'); return { apply() {} } },
dependency: () => { materialized('dependency'); return { apply() {}, value: 42 } },
consumer: (require) => {
materialized('consumer')
expect(require('dependency/client')).toHaveProperty('value', 42)
return { apply() {} }
},
})
await b.modules.entries.sync(graph(row('existing'), row('consumer', 'r0', { external: ['dependency/client'] }), row('dependency')))
expect(b.fetched.slice(1)).toEqual([row('dependency').url, row('consumer').url])
expect(materialized.mock.calls.map(([id]) => id)).toEqual(['existing', 'consumer', 'dependency'])
b.target.load({ id: 'local', factory: require => ({ apply() {}, dep: require('dependency/client') }) })
const localId = await b.ctx.loader.create({ name: 'local' })
await b.modules.entries.sync(graph(row('existing')))
expect(b.modules.loadCache.has('consumer')).toBe(false)
expect(b.modules.loadCache.has('dependency')).toBe(true)
b.ctx.loader.remove(localId)
await b.modules.entries.retry()
expect(b.modules.loadCache.has('dependency')).toBe(false)
expect(materialized.mock.calls.filter(([id]) => id === 'existing')).toHaveLength(1)
})
it('diagnoses download and apply failures, retries an identical graph and leaves healthy plugins running', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
let broken = true
const b = await bench(graph(row('healthy')), {
healthy: visible('healthy', effects),
bad: () => ({ apply() { if (broken) throw new Error('apply unavailable') } }),
})
b.arrival(async () => { throw new Error('download unavailable') })
await b.modules.entries.sync(graph(row('healthy'), row('bad')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('download unavailable')
b.arrival(async () => {})
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('apply unavailable')
broken = false
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
expect(effects.mounted).toBe(1)
expect(effects.disposed).toBe(0)
})
it('deduplicates rebuilt and graph revisions and retries after failed code arrival', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('pet')), { pet: visible('pet', effects) })
await b.modules.entries.reload('pet', 'r1')
await b.modules.entries.sync(graph(row('pet', 'r1')))
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
b.arrival(async () => { throw new Error('offline') })
await expect(b.modules.entries.reload('pet', 'r2')).rejects.toThrow('offline')
expect(effects.mounted).toBe(2)
b.arrival(async () => {})
await b.modules.entries.retry()
expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
})
it('rejects malformed graphs before mutating active entries', async () => {
const b = await bench(graph(row('a')), { a: () => ({ apply() {} }) })
expect(() => b.modules.entries.sync({ rev: 'bad', entries: [{ id: 'a' }], batches: [] })).toThrow('string id/url/rev')
expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['a'])
expect(() => b.modules.entries.start(b.ctx.loader, b.modules.manifest)).toThrow('already started')
})
})
it('publishes stable local snapshots and contains a failing subscriber', async () => {
const b = await bench(graph(), {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const listener = vi.fn()
const removeBad = b.modules.entries.state.subscribe(() => { throw new Error('subscriber') })
const remove = b.modules.entries.state.subscribe(listener)
expect(b.modules.entries.state.getSnapshot()).toBe(b.modules.entries.state.getSnapshot())
await b.modules.entries.retry()
expect(listener).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalled()
removeBad()
remove()
await b.modules.entries.retry()
expect(listener).toHaveBeenCalledTimes(2)
})
it('ignores superseded queued snapshots and rebuilds of absent or unchanged entries', async () => {
const b = await bench(graph(), { a: () => ({ apply() {} }) })
const first = b.modules.entries.sync(graph(row('a')))
const second = b.modules.entries.sync(graph())
await Promise.all([first, second])
expect(b.fetched).toEqual([])
await b.modules.entries.reload('a', 'r1')
await b.modules.entries.sync(graph(row('a')))
await b.modules.entries.reload('a', 'r0')
await b.ctx.fiber.dispose()
await b.modules.entries.reload('a', 'r2')
await b.modules.entries.sync(graph())
expect(b.fetched).toHaveLength(1)
})
it('reports reconciliation before startup and missing service activation without losing later retries', async () => {
const b = await bench(graph(), { pending: () => ({ inject: ['missing'], apply() {} }) }, false)
await expect(b.modules.entries.sync(graph())).rejects.toThrow('have not started')
await b.modules.entries.start(b.ctx.loader, b.modules.manifest)
await b.modules.entries.sync(graph(row('pending')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('waiting for activation')
b.ctx.provide('missing', {})
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
})
it('retries a materialization failure with the same graph and cleans its partial styles', async () => {
let broken = true
const b = await bench(graph(), { a: () => {
const style = document.createElement('style')
style.dataset.plugin = 'a'
document.head.append(style)
if (broken) throw new Error('factory failed')
return { apply() {} }
} })
await b.modules.entries.sync(graph(row('a')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(0)
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
await b.modules.entries.reload('a', 'r1').catch(() => {})
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
broken = false
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
})
it('does not finish a stale code replacement after download or asynchronous teardown', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const cleanup = deferred()
const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise) })
const download = deferred()
const started = deferred()
b.arrival(async () => { started.resolve(); await download.promise })
const rebuilding = b.modules.entries.reload('a', 'r1')
await started.promise
const snapshot = b.modules.entries.sync(graph(row('a')))
download.resolve()
await Promise.all([rebuilding, snapshot])
expect(effects.mounted).toBe(1)
const swapping = b.modules.entries.reload('a', 'r2')
await vi.waitFor(() => { expect(document.querySelector('[data-live=a]')).toBeNull() })
const disabling = b.modules.entries.sync(graph())
cleanup.resolve()
await Promise.all([swapping, disabling])
expect(effects.mounted).toBe(1)
expect(b.modules.loadCache.has('a')).toBe(false)
})
it('stops an obsolete multi-entry application after awaiting removal', async () => {
const cleanup = deferred()
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise), b: () => ({ apply() {} }) })
const first = b.modules.entries.sync(graph(row('b')))
await vi.waitFor(() => { expect(document.querySelector('[data-live=a]')).toBeNull() })
const latest = b.modules.entries.sync(graph())
cleanup.resolve()
await Promise.all([first, latest])
expect(b.fetched).toEqual(['/batch'])
})
it('keeps bootstrap ownership explicit and diagnoses removal without changing entries', async () => {
const b = await bench(graph(row('bootstrap')), {})
await expect(b.modules.entries.sync(graph())).rejects.toThrow('removing bootstrap module')
expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['bootstrap'])
expect(b.modules.entries.state.getSnapshot().syncing).toBe(false)
await b.modules.entries.sync(graph(row('bootstrap')))
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
})
it('retains unrelated style tags while reporting a failed replacement', async () => {
let broken = false
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('a')), { a: () => {
if (broken) throw new Error('changed factory failed')
return visible('a', effects)()
} })
const unrelated = document.createElement('style')
unrelated.dataset.plugin = 'unrelated'
document.head.append(unrelated)
broken = true
await b.modules.entries.sync(graph(row('a', 'r1')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('changed factory failed')
expect(unrelated.isConnected).toBe(true)
})
it('keeps unrelated page failures visible when a rebuilt download fails', async () => {
const b = await bench(graph(row('a'), row('bad')), {
a: () => ({ apply() {} }),
bad: () => ({ apply() { throw new Error('bad apply') } }),
})
await b.modules.entries.retry()
b.arrival(async () => { throw new Error('a download') })
await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('a download')
expect(b.modules.entries.state.getSnapshot().failures.map(failure => failure.id)).toEqual(['bad', 'a'])
})
it('retains a rejected Loader entry for retry instead of creating an orphan sibling', async () => {
let broken = true
const b = await bench(graph(), { a: () => broken ? { default: 'invalid' } : { apply() {} } })
await b.modules.entries.sync(graph(row('a')))
const entry = [...b.ctx.loader.entries()][0]!
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('invalid plugin')
expect([...b.ctx.loader.entries()]).toHaveLength(1)
broken = false
await b.modules.entries.retry()
expect([...b.ctx.loader.entries()]).toEqual([entry])
expect(entry.fiber?.state).toBe(2)
})
it('logs a row whose Loader creation rejects at startup and leaves it inactive for the boot audit', async () => {
let broken = true
const b = await bench(graph(row('a'), row('b')), { a: () => broken ? { default: 'invalid' } : { apply() {} }, b: () => ({ apply() {} }) }, false)
const error = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {})
await b.modules.entries.start(b.ctx.loader, b.modules.manifest)
const entries = new Map([...b.ctx.loader.entries()].map(entry => [entry.options.name, entry]))
expect(error).toHaveBeenCalledOnce()
expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('invalid plugin'))
expect(entries.get('a')?.fiber).toBeUndefined()
expect(entries.get('b')?.fiber?.state).toBe(2)
broken = false
await b.modules.entries.retry()
expect([...b.ctx.loader.entries()]).toHaveLength(2)
expect(entries.get('a')?.fiber?.state).toBe(2)
})
it('reports Loader import failures and recovers the same entry after the importer recovers', async () => {
const b = await bench(graph(), { a: () => ({ apply() {} }) })
let broken = true
b.ctx.loader.internal = {
version: 'client', import: async (id: string) => {
if (broken) throw new Error('Loader import failed')
return b.modules.import(id)
},
} as never
await b.modules.entries.sync(graph(row('a')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('import failed')
const entry = [...b.ctx.loader.entries()][0]!
broken = false
await b.modules.entries.retry()
expect(entry.fiber?.state).toBe(2)
broken = true
await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('import failed')
expect(b.modules.entries.state.getSnapshot().failures[0]?.id).toBe('a')
})
it.each([false, true])('does not mount a materialized factory after a newer removal (rebuild: %s)', async (rebuild) => {
let stop: Promise<void> | undefined
let materializations = 0
let mounted = 0
const factories = { a: () => {
materializations++
if (materializations === (rebuild ? 2 : 1)) {
queueMicrotask(() => { stop = b.modules.entries.sync(graph()) })
}
return { apply() { mounted++ } }
} }
const b = await bench(graph(), factories)
if (rebuild) {
// The first materialization remains live until the replacement reaches the import barrier.
materializations = -1
await b.modules.entries.sync(graph(row('a')))
materializations = 1
await b.modules.entries.reload('a', 'r1')
} else {
await b.modules.entries.sync(graph(row('a')))
}
await stop
expect(mounted).toBe(rebuild ? 1 : 0)
expect([...b.ctx.loader.entries()]).toHaveLength(0)
})
it('coalesces an overlapping graph snapshot with the same rebuilt artifact', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('a')), { a: visible('a', effects) })
const download = deferred()
const started = deferred()
b.arrival(async () => { started.resolve(); await download.promise })
const rebuilding = b.modules.entries.reload('a', 'r1')
await started.promise
const syncing = b.modules.entries.sync(graph(row('a', 'r1')))
download.resolve()
await Promise.all([rebuilding, syncing])
expect(b.fetched).toEqual(['/batch', row('a', 'r1').url])
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
})
it.each(['graph', 'rebuilt'])('replaces a failed factory before entry creation on a new %s revision', async (source) => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => { throw new Error('broken r0 factory') } }
const b = await bench(graph(), factories)
await b.modules.entries.sync(graph(row('a')))
expect([...b.ctx.loader.entries()]).toHaveLength(0)
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('broken r0 factory')
factories.a = () => ({ ...visible('a', effects)(), revision: 'r1' })
if (source === 'graph') await b.modules.entries.sync(graph(row('a', 'r1')))
else await b.modules.entries.reload('a', 'r1')
await b.modules.entries.retry()
expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
expect(await b.modules.import('a')).toHaveProperty('revision', 'r1')
expect(document.querySelectorAll('[data-live=a]')).toHaveLength(1)
expect(effects.mounted).toBe(1)
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
})
it('replaces a superseded arrival and its cached dependency before mounting the latest code', async () => {
const dependency = row('dependency')
const consumer = row('consumer', 'r0', { external: ['dependency/client'] })
const factories: Record<string, ClientBundleRegistration['factory']> = {
dependency: () => ({ apply() {}, revision: 'r0' }),
consumer: require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r0' }),
}
const b = await bench(graph(), factories)
const started = deferred()
const download = deferred()
b.arrival(async (url) => { if (url === consumer.url) { started.resolve(); await download.promise } })
const old = b.modules.entries.sync(graph(consumer, dependency))
try {
await started.promise
factories.dependency = () => ({ apply() {}, revision: 'r1' })
factories.consumer = require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r1' })
const latest = b.modules.entries.sync(graph(row('consumer', 'r1', { external: ['dependency/client'] }), row('dependency', 'r1')))
download.resolve()
await Promise.all([old, latest])
expect(await b.modules.import('consumer')).toMatchObject({ revision: 'r1', dependency: { revision: 'r1' } })
expect(b.fetched).toEqual([dependency.url, consumer.url, row('dependency', 'r1').url, row('consumer', 'r1').url])
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
} finally {
download.resolve()
await old
}
})
it.each(['graph', 'rebuilt'])('preserves bootstrap and dependent fibers when a %s requests new bootstrap code', async (source) => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('bootstrap'), row('consumer')), {
consumer: () => ({ ...visible('consumer', effects)(), inject: ['modules'] }),
})
const fibers = [...b.ctx.loader.entries()].map(entry => entry.fiber)
const exports = await b.modules.import('bootstrap')
if (source === 'graph') await b.modules.entries.sync(graph(row('bootstrap', 'r1'), row('consumer')))
else await expect(b.modules.entries.reload('bootstrap', 'r1')).rejects.toThrow('requires a page reload')
for (let retry = 0; retry < 2; retry++) {
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([
{ id: 'bootstrap', message: 'Error: client-modules: replacing bootstrap module bootstrap requires a page reload' },
])
}
expect([...b.ctx.loader.entries()].map(entry => entry.fiber)).toEqual(fibers)
expect(await b.modules.import('bootstrap')).toBe(exports)
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
expect(b.fetched).toEqual(['/batch'])
})
it('discards a failed arrival target when an uncreated entry receives a newer graph', async () => {
const b = await bench(graph(), { a: () => { throw new Error('r0 factory') } })
await b.modules.entries.sync(graph(row('a')))
b.arrival(async () => { throw new Error('offline r1') })
await b.modules.entries.reload('a', 'r1')
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('offline r1')
b.arrival(async () => {})
await b.modules.entries.sync(graph(row('a', 'r2')))
// A transport failure is retried once on the same URL before the reload is reported failed.
expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url, row('a', 'r1').url, row('a', 'r2').url])
})
it('uses the latest desired revision when a rebuild queues before entry creation', async () => {
const b = await bench(graph(), { a: () => ({ apply() {} }) })
const adding = b.modules.entries.sync(graph(row('a')))
const rebuilding = b.modules.entries.reload('a', 'r1')
const latest = b.modules.entries.sync(graph(row('a', 'r2')))
await Promise.all([adding, rebuilding, latest])
expect(b.fetched).toEqual([row('a', 'r2').url])
expect([...b.ctx.loader.entries()]).toHaveLength(1)
})
it('cleans styles from a materialized factory superseded before its entry is created', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
let latest: Promise<void> | undefined
const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => {
const old = visible('a', effects)()
queueMicrotask(() => {
factories.a = visible('a', effects)
latest = b.modules.entries.sync(graph(row('a', 'r1')))
})
return old
} }
const b = await bench(graph(), factories)
await b.modules.entries.sync(graph(row('a')))
await latest
expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
})
describe('Host release changes', () => {
const built = (release: WebBootRelease | undefined, ...entries: WebBootEntry[]): WebBootGraph => ({
...graph(...entries), ...(release === undefined ? {} : { release }),
})
const r1: WebBootRelease = { version: '1.0.0', commit: 'aaaaaaa' }
const r2: WebBootRelease = { version: '1.0.0', commit: 'bbbbbbb' }
const r3: WebBootRelease = { version: '2.0.0' }
const warning = (from: string, to: string) =>
`client-modules: Host release changed from ${from} to ${to}; plugin updates pause until this page reloads`
it('applies graphs and rebuilt frames of the boot release without announcing anything', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
const listener = vi.fn()
b.modules.entries.releaseChange.subscribe(listener)
await b.modules.entries.sync(built({ ...r1 }, row('pet')))
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
await b.modules.entries.sync(built(r1, row('pet', 'r1')))
await b.modules.entries.reload('pet', 'r2')
expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
expect(listener).not.toHaveBeenCalled()
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
})
it('applies nothing from another release and announces it once', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
const listener = vi.fn()
const unsubscribe = b.modules.entries.releaseChange.subscribe(listener)
// A different commit of the same version is another release.
await b.modules.entries.sync(built(r2, row('pet', 'r1'), row('cat')))
await b.modules.entries.reload('pet', 'r2')
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
expect(b.modules.loadCache.has('cat')).toBe(false)
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r1, host: r2 })
expect(warn).toHaveBeenCalledWith(warning(JSON.stringify(r1), JSON.stringify(r2)))
// Later frames of the same Host release neither apply nor announce again.
await b.modules.entries.sync(built({ ...r2 }, row('pet', 'r3')))
expect(listener).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledOnce()
// Another Host release replaces the announcement; an unsubscribed listener hears nothing more.
await b.modules.entries.sync(built(r3, row('pet', 'r3')))
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r2, host: r3 })
expect(warn).toHaveBeenLastCalledWith(warning(JSON.stringify(r2), JSON.stringify(r3)))
expect(listener).toHaveBeenCalledTimes(2)
unsubscribe()
await b.modules.entries.sync(built(r2, row('pet', 'r3')))
expect(listener).toHaveBeenCalledTimes(2)
expect(effects.mounted).toBe(1)
})
it('withdraws the announcement and replaces only changed modules when the Host returns to the boot release', async () => {
const pet = { mounted: 0, disposed: 0, hits: 0 }
const cat = { mounted: 0, disposed: 0, hits: 0 }
vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(built(r1, row('pet'), row('cat')), { pet: visible('pet', pet), cat: visible('cat', cat) })
await b.modules.entries.sync(built(r2, row('pet', 'r1'), row('cat', 'r1')))
// The boot release's graph is compared with the graph the page last applied.
await b.modules.entries.sync(built(r1, row('pet'), row('cat', 'r1')))
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
expect(pet).toEqual({ mounted: 1, disposed: 0, hits: 0 })
expect(cat).toEqual({ mounted: 2, disposed: 1, hits: 0 })
await b.modules.entries.reload('pet', 'r2')
expect(pet).toEqual({ mounted: 2, disposed: 1, hits: 0 })
})
it('stops announcing a dismissed Host release but still applies nothing from it', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
// Dismissing without an announcement leaves nothing to remember.
b.modules.entries.dismissRelease()
await b.modules.entries.sync(built(r2, row('pet', 'r1')))
b.modules.entries.dismissRelease()
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
await b.modules.entries.sync(built(r2, row('pet', 'r2')))
await b.modules.entries.reload('pet', 'r3')
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
// Only the last dismissed release stays quiet.
await b.modules.entries.sync(built(r3, row('pet', 'r3')))
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r2, host: r3 })
await b.modules.entries.sync(built(r2, row('pet', 'r3')))
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
await b.modules.entries.sync(built(r3, row('pet', 'r3')))
b.modules.entries.dismissRelease()
await b.modules.entries.sync(built(r2, row('pet', 'r3')))
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r3, host: r2 })
expect(warn).toHaveBeenCalledTimes(4)
expect(effects.mounted).toBe(1)
})
it('announces a graph of another release even when its format cannot be read', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
await b.modules.entries.sync({ rev: 'next', release: r3, entries: 'another format' })
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r1, host: r3 })
// A release that cannot be read counts as absent and still differs from the boot release.
await b.modules.entries.sync({ release: { version: 3 } })
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r3, host: undefined })
expect(effects.mounted).toBe(1)
// A graph of the boot release is still fully validated.
expect(() => b.modules.entries.sync({ rev: 'next', release: r1, entries: 'another format' })).toThrow('entries must be an array')
})
it('treats an absent release as one identity', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const b = await bench(graph(), {})
await b.modules.entries.sync(graph())
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
await b.modules.entries.sync(built(r3))
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: undefined, previous: undefined, host: r3 })
expect(warn).toHaveBeenCalledWith(warning('none', JSON.stringify(r3)))
b.modules.entries.dismissRelease()
await b.modules.entries.sync(graph())
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
// A Host that publishes no release counts as another release for a page that booted with one.
const versioned = await bench(built(r1), {})
await versioned.modules.entries.sync(graph())
expect(versioned.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r1, host: undefined })
})
it('notifies every subscriber when one of them throws', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const b = await bench(built(r1), {})
const failure = new Error('subscriber')
const listener = vi.fn()
b.modules.entries.releaseChange.subscribe(() => { throw failure })
b.modules.entries.releaseChange.subscribe(listener)
await b.modules.entries.sync(built(r2))
expect(listener).toHaveBeenCalledOnce()
expect(error).toHaveBeenCalledWith('client-modules: release change subscriber failed', failure)
})
})