Files
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

61 lines
2.6 KiB
TypeScript

/** Test-scoped Remote mock and lazy client boot, owned by native Vitest fixtures. */
import { test, type TestAPI } from 'vitest'
import { RemoteMock, type MockedRemote } from '@deepseek-ai/dsh-remote-mock'
import type { AssemblyPlan } from './roster.ts'
import { remoteDefaultResponses } from './remote-default-responses.ts'
import { TestClient, type TestClientOptions } from './test-client.ts'
/** Per-test controls; configure the mock before awaiting `start()`. */
export interface ClientTestFixtures {
/** Fresh mock with the assembly's default responses already loaded. */
mock: RemoteMock
/** Namespace proxy backed by the same native mocks used by the Connection carrier. */
remote: MockedRemote
/** Await the one client owned by this test; rejects after the fixture closes. */
start: () => Promise<TestClient>
}
/**
* Extend Vitest with a fresh mock and a lazy, automatically disposed client. Repeated `start()` calls share one
* promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection.
* Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned.
* @param plan - roster and replacement modules, shared as configuration rather than as a running client.
* @param options - mount, readiness, and strictness settings passed to `TestClient.start`.
* @returns Vitest's test function with test-scoped `mock` and `start` fixtures.
*/
export function createClientTest(plan: AssemblyPlan, options: TestClientOptions = {}): TestAPI<ClientTestFixtures> {
return test.extend<ClientTestFixtures>({
remote: async ({ mock }, use) => {
await use(mock.remote)
},
mock: async ({}, use) => {
const mock = RemoteMock.create().load(remoteDefaultResponses)
try {
await use(mock)
} finally {
mock.assertNoUnmatched()
}
},
start: async ({ mock }, use) => {
let pending: Promise<TestClient> | undefined
let closed = false
const start = (): Promise<TestClient> => {
if (closed) return Promise.reject(new Error('client-test-runtime: start() called after its test fixture closed'))
if (pending === undefined) {
pending = TestClient.start(plan, mock, options)
// Startup belongs to the caller; keep a rejection handler attached until teardown can await it.
void pending.catch(() => undefined)
}
return pending
}
try {
await use(start)
} finally {
closed = true
const client = await pending?.catch(() => undefined)
await client?.dispose()
}
},
})
}