* 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.
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
|
|
import type {
|
|
ClientBundleRegistration, ClientModuleCreateOptions, ClientModuleLoaderTarget, DshWindow,
|
|
WebBootEntry,
|
|
} from '@deepseek-ai/dsh-client-modules/client'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { AppWebEntry } from '../src/boot.ts'
|
|
|
|
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
|
const REQUIRED_ID = '@deepseek-ai/dsh-client-ui-chat'
|
|
const PROVIDER_CLIENT_ID = 'provider/client'
|
|
const RUNTIME_CLIENT_ID = 'runtime/client'
|
|
const win = globalThis as DshWindow
|
|
const transportGlobal = globalThis as {
|
|
__DSH_TRANSPORT__?: { loadBundle(url: string): Promise<void>; resolveAsset?(reference: string): string }
|
|
}
|
|
const moduleFace = modulesClient as Record<string, unknown>
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
delete win.__DSH_BOOT__
|
|
delete win.__ModuleLoader__
|
|
delete transportGlobal.__DSH_TRANSPORT__
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
/** Install the stable facade shape that the Host injects before AppWebEntry runs. */
|
|
function installFacade(
|
|
create?: (options: ClientModuleCreateOptions) => modulesClient.ClientModuleSystem,
|
|
): ClientModuleLoaderTarget {
|
|
const pendingQueue: ClientBundleRegistration[] = []
|
|
const target: ClientModuleLoaderTarget = {
|
|
mode: 'queue',
|
|
pendingQueue,
|
|
load: (registration) => { pendingQueue.push(registration) },
|
|
create: create ?? (options => modulesClient.createClientModuleSystem(target, {
|
|
id: MODULES_ID,
|
|
exports: moduleFace,
|
|
}, options)),
|
|
}
|
|
win.__ModuleLoader__ = target
|
|
return target
|
|
}
|
|
|
|
async function expectBootFailure(setup: () => void, message: string): Promise<void> {
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
setup()
|
|
const entry = new AppWebEntry(container)
|
|
await entry.run()
|
|
expect(container.textContent).toContain(message)
|
|
expect(error).toHaveBeenCalledOnce()
|
|
await entry.dispose()
|
|
}
|
|
|
|
describe('bootstrap failure rendering', () => {
|
|
it('leaves the loading page visible when a carrier owns fatal presentation', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const entry = new AppWebEntry(container)
|
|
const failure = vi.fn()
|
|
await entry.run(failure)
|
|
expect(failure).toHaveBeenCalledWith(new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing'))
|
|
expect(container.textContent).toContain('Loading plugins')
|
|
expect(container.textContent).not.toContain('Failed to load plugins')
|
|
expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
|
|
await entry.dispose()
|
|
})
|
|
|
|
it.each([false, true])('routes failed required plugin activation with carrier presentation=%s', async (carrier) => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const target = installFacade()
|
|
win.__DSH_BOOT__ = {
|
|
rev: 'graph',
|
|
entries: [{ id: REQUIRED_ID, url: '/broken.js', rev: '1' }],
|
|
batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: [REQUIRED_ID] }],
|
|
}
|
|
const entry = new AppWebEntry(container, {
|
|
loadBundle: async () => {
|
|
target.load({ id: REQUIRED_ID, factory: () => ({ apply() { throw new Error('plugin activation failed') } }) })
|
|
},
|
|
})
|
|
const report = vi.fn<(reason: unknown) => void>()
|
|
try {
|
|
await entry.run(carrier ? report : undefined)
|
|
if (carrier) {
|
|
expect(report).toHaveBeenCalledOnce()
|
|
expect(String(report.mock.calls[0]![0])).toContain(`${REQUIRED_ID} (required): failed: Error: plugin activation failed`)
|
|
expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
|
|
expect(container.textContent).not.toContain('Failed to load plugins')
|
|
} else {
|
|
expect(report).not.toHaveBeenCalled()
|
|
expect(container.textContent).toContain('Failed to load plugins')
|
|
expect(container.textContent).toContain(`${REQUIRED_ID} (required): failed: Error: plugin activation failed`)
|
|
}
|
|
} finally {
|
|
await entry.dispose()
|
|
}
|
|
})
|
|
|
|
it.each([false, true])('mounts past a failed optional plugin without a failure report with carrier presentation=%s', async (carrier) => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const target = installFacade()
|
|
const entries: WebBootEntry[] = [
|
|
{ id: 'broken', url: '/broken.js', rev: '1' },
|
|
{ id: 'renderer', url: '/renderer.js', rev: '1' },
|
|
]
|
|
win.__DSH_BOOT__ = {
|
|
rev: 'graph',
|
|
entries,
|
|
batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: entries.map(row => row.id) }],
|
|
}
|
|
const mounted = vi.fn<(element: HTMLElement) => () => void>(() => () => {})
|
|
const entry = new AppWebEntry(container, {
|
|
loadBundle: async () => {
|
|
target.load({ id: 'broken', factory: () => ({ apply() { throw new Error('plugin activation failed') } }) })
|
|
target.load({
|
|
id: 'renderer',
|
|
factory: () => ({ apply: (ctx: Context) => { ctx.reflect.provide('uiRenderer', { mount: mounted }) } }),
|
|
})
|
|
},
|
|
})
|
|
const report = vi.fn<(reason: unknown) => void>()
|
|
try {
|
|
await entry.run(carrier ? report : undefined)
|
|
expect(mounted).toHaveBeenCalledExactlyOnceWith(container)
|
|
expect(report).not.toHaveBeenCalled()
|
|
expect(warn).toHaveBeenCalledExactlyOnceWith('web boot: warning: 1 entry did not activate\nbroken: failed: Error: plugin activation failed')
|
|
expect(container.textContent).not.toContain('Failed to load plugins')
|
|
} finally {
|
|
await entry.dispose()
|
|
}
|
|
})
|
|
|
|
it('renders a missing bootstrap facade', async () => {
|
|
await expectBootFailure(
|
|
() => { delete win.__ModuleLoader__ },
|
|
'window.__ModuleLoader__ bootstrap facade is missing',
|
|
)
|
|
})
|
|
|
|
it('renders a create failure owned by the facade', async () => {
|
|
await expectBootFailure(() => {
|
|
installFacade(() => { throw new Error('facade create failed') })
|
|
}, 'facade create failed')
|
|
})
|
|
|
|
it('renders a malformed boot manifest', async () => {
|
|
await expectBootFailure(() => {
|
|
installFacade()
|
|
delete win.__DSH_BOOT__
|
|
}, 'window.__DSH_BOOT__ is missing or not an object')
|
|
})
|
|
|
|
it('renders a module-system construction failure', async () => {
|
|
await expectBootFailure(() => {
|
|
installFacade()
|
|
const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' }
|
|
win.__DSH_BOOT__ = {
|
|
rev: 'graph',
|
|
entries: [duplicate, duplicate],
|
|
batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['duplicate'] }],
|
|
}
|
|
}, 'duplicate graph entry "duplicate"')
|
|
})
|
|
})
|
|
|
|
describe('plugin activation', () => {
|
|
it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => {
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const target = installFacade()
|
|
const entries: WebBootEntry[] = [
|
|
{ id: 'consumer', url: '/consumer.js', rev: '1' },
|
|
{
|
|
id: 'runtime',
|
|
url: '/runtime.js',
|
|
rev: '1',
|
|
external: [PROVIDER_CLIENT_ID],
|
|
immediately: true,
|
|
},
|
|
{ id: 'provider', url: '/provider.js', rev: '1' },
|
|
{ id: 'renderer', url: '/renderer.js', rev: '1' },
|
|
]
|
|
const applicationUrl = '/application.js'
|
|
win.__DSH_BOOT__ = {
|
|
rev: 'graph',
|
|
entries,
|
|
batches: [{ phase: 'application', url: applicationUrl, rev: 'batch', entries: entries.map(row => row.id) }],
|
|
}
|
|
const loaded: string[] = []
|
|
const registrations: ClientBundleRegistration[] = [
|
|
{
|
|
id: 'consumer',
|
|
factory: require => ({
|
|
apply: () => {
|
|
expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
|
|
// The transport's asset resolver reaches every bundle factory.
|
|
expect((require(PROVIDER_CLIENT_ID) as { asset: string }).asset).toBe('blob:plugins/provider/assets/a.svg')
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
id: 'provider',
|
|
factory: require => ({ apply: () => {}, marker: 'provider', asset: require.asset('plugins/provider/assets/a.svg') }),
|
|
},
|
|
{
|
|
id: 'runtime',
|
|
factory: require => ({
|
|
apply: () => {},
|
|
marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
|
|
}),
|
|
},
|
|
{
|
|
id: 'renderer',
|
|
factory: () => ({
|
|
apply: (ctx: Context) => {
|
|
ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
|
|
},
|
|
}),
|
|
},
|
|
]
|
|
transportGlobal.__DSH_TRANSPORT__ = {
|
|
resolveAsset: reference => `blob:${reference}`,
|
|
loadBundle: async (url) => {
|
|
loaded.push(url)
|
|
if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`)
|
|
for (const registration of registrations) target.load(registration)
|
|
},
|
|
}
|
|
|
|
const entry = new AppWebEntry(container)
|
|
await entry.run()
|
|
|
|
expect(loaded).toEqual([applicationUrl])
|
|
await entry.dispose()
|
|
})
|
|
|
|
it('allows a modules-dependent row to be created before the modules row', async () => {
|
|
const events: string[] = []
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const target = installFacade()
|
|
const entries: WebBootEntry[] = [
|
|
{ id: 'consumer', url: '/consumer.js', rev: '1' },
|
|
{ id: MODULES_ID, url: '/modules.js', rev: '1' },
|
|
{ id: 'renderer', url: '/renderer.js', rev: '1' },
|
|
]
|
|
win.__DSH_BOOT__ = {
|
|
rev: 'graph',
|
|
entries,
|
|
batches: [{
|
|
phase: 'application',
|
|
url: '/application.js',
|
|
rev: 'batch',
|
|
entries: entries.map(row => row.id),
|
|
}],
|
|
}
|
|
const registrations = new Map<string, ClientBundleRegistration>([
|
|
['/consumer.js', {
|
|
id: 'consumer',
|
|
factory: () => ({
|
|
inject: ['modules'],
|
|
apply: (ctx: Context) => {
|
|
expect(ctx.modules).toBeDefined()
|
|
events.push('consumer')
|
|
},
|
|
}),
|
|
}],
|
|
['/renderer.js', {
|
|
id: 'renderer',
|
|
factory: () => ({
|
|
apply: (ctx: Context) => {
|
|
ctx.reflect.provide('uiRenderer', {
|
|
mount: (element: HTMLElement) => {
|
|
events.push('mount')
|
|
element.textContent = 'mounted'
|
|
return () => {}
|
|
},
|
|
})
|
|
},
|
|
}),
|
|
}],
|
|
])
|
|
const entry = new AppWebEntry(container, {
|
|
loadBundle: async (url) => {
|
|
if (url !== '/application.js') throw new Error(`missing fixture batch ${url}`)
|
|
for (const registration of registrations.values()) target.load(registration)
|
|
},
|
|
})
|
|
|
|
await entry.run()
|
|
|
|
expect(target.mode).toBe('live')
|
|
expect(events).toEqual(['consumer', 'mount'])
|
|
expect(container.textContent).toBe('mounted')
|
|
await entry.dispose()
|
|
})
|
|
})
|
|
|
|
it('draws the shared boot page before Host injections and resumes without replacing the document', async () => {
|
|
const gate = Promise.withResolvers<undefined>()
|
|
vi.stubGlobal('__DSH_BOOT_READY__', gate)
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const create = vi.fn(() => { throw new Error('injections consumed') })
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const entry = new AppWebEntry(container)
|
|
const boot = entry.run()
|
|
try {
|
|
const page = container.querySelector('[data-dsh-boot]')
|
|
const spinner = container.querySelector('[data-dsh-boot-spinner]')
|
|
expect(spinner).not.toBeNull()
|
|
await Promise.resolve()
|
|
expect(create).not.toHaveBeenCalled()
|
|
expect(error).not.toHaveBeenCalled()
|
|
installFacade(create)
|
|
gate.resolve(undefined)
|
|
await boot
|
|
expect(create).toHaveBeenCalledOnce()
|
|
expect(container.querySelector('[data-dsh-boot]')).toBe(page)
|
|
expect(container.textContent).toContain('injections consumed')
|
|
} finally {
|
|
gate.resolve(undefined)
|
|
await boot
|
|
await entry.dispose()
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|