Files
deepseek-harness/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts
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

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)
})
})