* 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.
151 lines
7.8 KiB
TypeScript
151 lines
7.8 KiB
TypeScript
/** TestClient without a DOM: the api roster boots and connects; mount is refused; flush degrades to a microtask flush. */
|
|
import type { ClientTransportHooks } from '@deepseek-ai/dsh-client-connection/client'
|
|
import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
|
|
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
|
|
|
|
/** The Gateway client and what it injects: the Typert registry and the Connection. */
|
|
const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
|
|
const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry'])
|
|
/** A row on the Web client's required list, so its failure rejects the boot. */
|
|
const RENDERER_ONLY = webApp.pick(['@deepseek-ai/dsh-client-ui-renderer'])
|
|
const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks }
|
|
|
|
describe('TestClient (node environment)', () => {
|
|
it('boots the api roster, connects, and flushes without a DOM', async () => {
|
|
expect(typeof document).toBe('undefined')
|
|
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
const client = await TestClient.start({ roster: API_ROSTER }, mock)
|
|
onTestFinished(() => client.dispose())
|
|
expect(client.connection.state.getSnapshot()).toBe('connected')
|
|
await client.flush()
|
|
}, 60_000)
|
|
|
|
it('closes the client even when the plugin tree fails to dispose, and still rethrows', async () => {
|
|
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
const client = await TestClient.start({ roster: API_ROSTER }, mock)
|
|
const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
|
|
const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
|
|
await dispose()
|
|
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercises normalization of non-Error teardown failures.
|
|
return Promise.reject('teardown boom')
|
|
})
|
|
onTestFinished(async () => {
|
|
failure.mockRestore()
|
|
try { await dispose() } finally { await client.dispose() }
|
|
})
|
|
await expect(client.dispose()).rejects.toThrow('teardown boom')
|
|
expect(globals.__DSH_TRANSPORT__).toBeUndefined()
|
|
expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
|
|
await client.dispose() // idempotent after a failed teardown
|
|
})
|
|
|
|
it('rethrows a required row that fails to apply and lets the next client boot', async () => {
|
|
const failing = { apply(): void { throw new Error('apply boom') } }
|
|
await expect(TestClient.start({ roster: RENDERER_ONLY, provide: { '@deepseek-ai/dsh-client-ui-renderer': failing } }, RemoteMock.create()))
|
|
.rejects.toThrow('@deepseek-ai/dsh-client-ui-renderer (required): failed')
|
|
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
const client = await TestClient.start({ roster: API_ROSTER }, mock)
|
|
onTestFinished(() => client.dispose())
|
|
expect(client.connection.state.getSnapshot()).toBe('connected')
|
|
})
|
|
|
|
it('rejects an optional row that fails to apply by default and names the opt-out', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
onTestFinished(() => { warn.mockRestore() })
|
|
const failing = { apply(): void { throw new Error('apply boom') } }
|
|
await expect(TestClient.start(
|
|
{ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } },
|
|
RemoteMock.create(),
|
|
{ awaitConnected: false },
|
|
)).rejects.toThrow([
|
|
'client-test-runtime: every roster entry must activate; pass allowInactiveEntries to tolerate it',
|
|
'web boot: warning: 1 entry did not activate',
|
|
'@deepseek-ai/dsh-typert-registry: failed: Error: apply boom',
|
|
].join('\n'))
|
|
})
|
|
|
|
it('tolerates an inactive optional row when allowInactiveEntries is set', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
onTestFinished(() => { warn.mockRestore() })
|
|
const failing = { apply(): void { throw new Error('apply boom') } }
|
|
const client = await TestClient.start(
|
|
{ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } },
|
|
RemoteMock.create(),
|
|
{ awaitConnected: false, allowInactiveEntries: true },
|
|
)
|
|
onTestFinished(() => client.dispose())
|
|
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
|
'web boot: warning: 1 entry did not activate\n@deepseek-ai/dsh-typert-registry: failed: Error: apply boom',
|
|
)
|
|
})
|
|
|
|
it.each(['@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access'])(
|
|
'refuses a provide entry for the %s row, whose generated Remote mount the tier replaces',
|
|
async (name) => {
|
|
const roster = webApp.closure([name])
|
|
await expect(TestClient.start({ roster, provide: { [name]: { apply() {} } } }, RemoteMock.create()))
|
|
.rejects.toThrow(`${name} cannot be provided; the tier replaces its generated Remote mount`)
|
|
},
|
|
)
|
|
|
|
it('drops the remote-access row and answers every access question with true', async () => {
|
|
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
const client = await TestClient.start({ roster: webApp.closure(['@deepseek-ai/dsh-api-remote-access']) }, mock)
|
|
onTestFinished(() => client.dispose())
|
|
expect(client.ctx.get('remoteAccess')?.source('session', 's1', 'session/fork').getSnapshot()).toBe(true)
|
|
// The dropped row never mounts its namespace, so no access stream opens.
|
|
expect(mock.log.streams('remoteAccess/follow')).toEqual([])
|
|
})
|
|
|
|
it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => {
|
|
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
const client = await TestClient.start({ roster: API_ROSTER }, mock)
|
|
const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
|
|
const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
|
|
await dispose()
|
|
throw new Error('teardown boom')
|
|
})
|
|
onTestFinished(async () => {
|
|
failure.mockRestore()
|
|
try { await dispose() } finally { await client.dispose() }
|
|
})
|
|
await mock.dispatch('nowhere/call', []).catch(() => undefined)
|
|
await expect(client.dispose()).rejects.toThrow(/nowhere\/call \(unary\)[\s\S]*also failed to dispose: teardown boom/)
|
|
expect(globals.__DSH_TRANSPORT__).toBeUndefined()
|
|
expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
|
|
})
|
|
|
|
it('lets a concurrent second dispose wait for the first teardown instead of returning early', async () => {
|
|
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
const client = await TestClient.start({ roster: API_ROSTER }, mock)
|
|
const first = client.dispose()
|
|
const second = client.dispose()
|
|
await second
|
|
expect(globals.__DSH_TRANSPORT__).toBeUndefined()
|
|
await first
|
|
})
|
|
|
|
it('refuses to mount without a DOM', async () => {
|
|
await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true }))
|
|
.rejects.toThrow('mount requires a DOM')
|
|
})
|
|
|
|
it('fails loud when the roster cannot provide a connection', async () => {
|
|
await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create()))
|
|
.rejects.toThrow('provides no `connection` service')
|
|
})
|
|
|
|
it('skips readiness on request and leaves a pre-existing page transport untouched', async () => {
|
|
const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) }
|
|
globals.__DSH_TRANSPORT__ = previous
|
|
onTestFinished(() => { delete globals.__DSH_TRANSPORT__ })
|
|
const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false })
|
|
expect(globals.__DSH_TRANSPORT__).toBe(previous)
|
|
expect(client.ctx.get('typert')).toBeDefined()
|
|
expect(() => client.connection).toThrow('provides no `connection` service')
|
|
await client.dispose()
|
|
expect(globals.__DSH_TRANSPORT__).toBe(previous)
|
|
})
|
|
})
|