339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
/**
|
|
* Whole-client test carrier: boots an {@link AssemblyPlan} through the
|
|
* production `bootClient` over an in-process module table, with a
|
|
* `RemoteMock` bound to that client's Connection plugin instance.
|
|
* @module @deepseek-ai/dsh-client-test-runtime/src/assembly/test-client
|
|
*/
|
|
import { Context, type Plugin } from '@deepseek-ai/cordis'
|
|
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
|
import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client'
|
|
import {
|
|
installConnection,
|
|
type ConnectionHandle,
|
|
} from '@deepseek-ai/dsh-client-connection/client'
|
|
import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts'
|
|
import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts'
|
|
import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
|
|
import { act } from '@testing-library/react'
|
|
import { createInProcessModules, loadPluginModules } from './modules.ts'
|
|
import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts'
|
|
import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts'
|
|
|
|
/** Carrier options. */
|
|
export interface TestClientOptions {
|
|
/**
|
|
* Mount `uiRenderer` into an element (a fresh `document.body` child when `true`); requires jsdom and a roster
|
|
* that provides `uiRenderer`. Default false.
|
|
*/
|
|
readonly mount?: boolean | HTMLElement
|
|
/** Wait for `ctx.connection.state === 'connected'` before returning. Default true. */
|
|
readonly awaitConnected?: boolean
|
|
/** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */
|
|
readonly connectTimeoutMs?: number
|
|
}
|
|
|
|
/**
|
|
* jsdom shims shared by every live client in this worker. The first holder
|
|
* installs missing shims and the last release removes exactly those shims.
|
|
*/
|
|
class SharedJsdomShims {
|
|
private holders = 0
|
|
private removeShims: (() => void) | undefined
|
|
|
|
/**
|
|
* Hold the shims for one client.
|
|
* @returns the release for this holder.
|
|
*/
|
|
acquire(): () => void {
|
|
if (this.holders === 0) {
|
|
this.removeShims = installJsdomShims()
|
|
}
|
|
this.holders += 1
|
|
return () => {
|
|
this.holders -= 1
|
|
if (this.holders > 0) return
|
|
this.removeShims?.()
|
|
this.removeShims = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
const sharedJsdomShims = new SharedJsdomShims()
|
|
|
|
const CONNECTION_PACKAGE = '@deepseek-ai/dsh-client-connection'
|
|
|
|
/** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */
|
|
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
|
|
|
|
/**
|
|
* Browser globals jsdom lacks that roster plugins touch at apply or mount:
|
|
* client-hmr opens an `EventSource`, layout components observe element size.
|
|
* Inert stand-ins, installed only where the global is absent.
|
|
*/
|
|
const JSDOM_SHIMS: Readonly<Record<string, unknown>> = {
|
|
EventSource: class {
|
|
addEventListener(): void {}
|
|
close(): void {}
|
|
},
|
|
ResizeObserver: class {
|
|
observe(): void {}
|
|
disconnect(): void {}
|
|
},
|
|
}
|
|
|
|
/** Install a shim for each absent global; the disposer deletes exactly those. */
|
|
function installJsdomShims(): () => void {
|
|
const globals = globalThis as Record<string, unknown>
|
|
const installed = Object.keys(JSDOM_SHIMS).filter(name => globals[name] === undefined)
|
|
for (const name of installed) globals[name] = JSDOM_SHIMS[name]
|
|
return () => { for (const name of installed) Reflect.deleteProperty(globals, name) }
|
|
}
|
|
|
|
/** The Error a thrown value stands for: itself, or a new Error carrying its string form. */
|
|
function toError(error: unknown): Error {
|
|
return error instanceof Error ? error : new Error(String(error))
|
|
}
|
|
|
|
function messageOf(error: unknown): string {
|
|
return toError(error).message
|
|
}
|
|
|
|
function connectionOf(ctx: Context): ConnectionHandle {
|
|
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
|
if (connection === undefined) {
|
|
throw new Error('client-test-runtime: the roster provides no `connection` service')
|
|
}
|
|
return connection
|
|
}
|
|
|
|
/** Mount point plus whether `start` created it (and so removes it on dispose). */
|
|
interface MountPoint {
|
|
readonly element: HTMLElement | undefined
|
|
readonly owned: boolean
|
|
}
|
|
|
|
function resolveMountPoint(mount: boolean | HTMLElement | undefined): MountPoint {
|
|
if (mount === undefined || mount === false) return { element: undefined, owned: false }
|
|
if (typeof document === 'undefined') {
|
|
throw new Error('client-test-runtime: mount requires a DOM; add `// @vitest-environment jsdom` to the spec')
|
|
}
|
|
if (mount === true) {
|
|
const element = document.createElement('div')
|
|
document.body.appendChild(element)
|
|
return { element, owned: true }
|
|
}
|
|
return { element: mount, owned: false }
|
|
}
|
|
|
|
/** Run `fn` inside React `act` when a DOM exists; plain await otherwise. */
|
|
async function settle(fn: () => Promise<void>): Promise<void> {
|
|
if (typeof document === 'undefined') {
|
|
await fn()
|
|
return
|
|
}
|
|
await act(async () => { await fn() })
|
|
}
|
|
|
|
async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise<void> {
|
|
const { state } = connectionOf(ctx)
|
|
if (state.getSnapshot() === 'connected') return
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
unsubscribe()
|
|
const unmatched = mock.log.unmatched().map(row => `${row.mode} ${row.endpoint}`)
|
|
const streams = mock.log.streams().map(row => `${row.endpoint} (${row.state})`)
|
|
reject(new Error(
|
|
`client-test-runtime: connection state is ${String(state.getSnapshot())} after ${timeoutMs}ms; `
|
|
+ `unmatched: [${unmatched.join(', ')}]; streams: [${streams.join(', ')}]`,
|
|
))
|
|
}, timeoutMs)
|
|
const unsubscribe = state.subscribe(() => {
|
|
if (state.getSnapshot() !== 'connected') return
|
|
clearTimeout(timer)
|
|
unsubscribe()
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
/** A booted client under test. */
|
|
export class TestClient {
|
|
/**
|
|
* Load the roster's modules, bind this client's mock to its Connection row,
|
|
* hold the jsdom shims, and boot through `bootClient` over the synthesized
|
|
* boot graph; afterwards optionally mount and wait for the connection. The
|
|
* bound row replaces only the page-global input adapter: both paths call
|
|
* `installConnection`, while this path supplies the mock carrier, uses
|
|
* default recovery timings, and captures the current page location once for
|
|
* later reloads. A caller-provided Connection row remains unchanged and owns
|
|
* its readiness behavior. The `@deepseek-ai/dsh-api-remotes` row is
|
|
* dropped from the roster: its generated Remote clients exist only in built
|
|
* `lib/`, and the `remote.<ns>` services the roster injects (plus the
|
|
* namespaces the mock has rules for at this point) are provided as
|
|
* contract-free proxies over the same Connection instead; a `provide` entry
|
|
* for that row is refused. On any failure the context is disposed, an owned
|
|
* mount removed, and this client's hold on the shims released before the
|
|
* original error is rethrown.
|
|
* @param plan - roster and annotations.
|
|
* @param mock - Remote mock answering every Gateway call.
|
|
* @param options - mount and readiness options.
|
|
* @returns the booted client.
|
|
*/
|
|
static async start(
|
|
plan: AssemblyPlan,
|
|
mock: RemoteMock,
|
|
options: TestClientOptions = {},
|
|
): Promise<TestClient> {
|
|
assertPlan(plan)
|
|
if (plan.provide?.[REMOTES_PACKAGE] !== undefined) {
|
|
throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote.<ns> services are the tier's proxies`)
|
|
}
|
|
const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE)
|
|
? plan.roster.without([REMOTES_PACKAGE])
|
|
: plan.roster
|
|
const ctx = new Context()
|
|
const pageLocation = typeof location === 'undefined'
|
|
? undefined
|
|
: { hostname: location.hostname, search: location.search }
|
|
let mountPoint: MountPoint = { element: undefined, owned: false }
|
|
let release: (() => void) | undefined
|
|
const restore = (): void => {
|
|
if (mountPoint.owned) mountPoint.element?.remove()
|
|
release?.()
|
|
}
|
|
try {
|
|
const modules = new Map(await loadPluginModules({ ...plan, roster }))
|
|
const connection = modules.get(CONNECTION_PACKAGE)
|
|
if (connection !== undefined && plan.provide?.[CONNECTION_PACKAGE] === undefined) {
|
|
modules.set(CONNECTION_PACKAGE, {
|
|
...connection,
|
|
apply: (connectionCtx) => {
|
|
installConnection(connectionCtx, {
|
|
transport: { rpc: mock.rpc },
|
|
...(pageLocation === undefined ? {} : { location: pageLocation }),
|
|
})
|
|
},
|
|
})
|
|
}
|
|
mountPoint = resolveMountPoint(options.mount)
|
|
release = sharedJsdomShims.acquire()
|
|
const system = createInProcessModules(graphFromRoster(roster.rows), modules)
|
|
ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin)
|
|
await bootClient({ ctx, modules: system, manifest: system.manifest })
|
|
if (mountPoint.element !== undefined) {
|
|
if (ctx.get('uiRenderer') === undefined) {
|
|
throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`')
|
|
}
|
|
await mountClient(ctx, mountPoint.element)
|
|
}
|
|
if (options.awaitConnected !== false) {
|
|
await awaitConnected(ctx, mock, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
}
|
|
} catch (error) {
|
|
try {
|
|
await settle(() => ctx.fiber.dispose())
|
|
} catch {
|
|
// The boot failure is the error to report; a half-built tree failing to dispose adds nothing to it.
|
|
} finally {
|
|
restore()
|
|
}
|
|
throw error
|
|
}
|
|
return new TestClient(ctx, mock, mountPoint.element, restore)
|
|
}
|
|
|
|
private disposing: Promise<void> | undefined
|
|
|
|
private constructor(
|
|
readonly ctx: Context,
|
|
readonly mock: RemoteMock,
|
|
readonly container: HTMLElement | undefined,
|
|
private readonly restore: () => void,
|
|
) {}
|
|
|
|
/** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */
|
|
get connection(): ConnectionHandle {
|
|
return connectionOf(this.ctx)
|
|
}
|
|
|
|
/** Flush pending React work and microtasks inside `act` (plain microtask flush without a DOM). */
|
|
async flush(): Promise<void> {
|
|
await settle(() => Promise.resolve())
|
|
}
|
|
|
|
/**
|
|
* Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then
|
|
* `entry.refresh()`. Each client's module table retains its own instance-bound
|
|
* Connection plugin, so reloads do not coordinate through process globals.
|
|
* Requires a live client: after `dispose()` the Loader holds no entries and
|
|
* the lookup throws before teardown.
|
|
* @param name - package name of the row.
|
|
*/
|
|
async reload(name: string): Promise<void> {
|
|
const entry = this.entryOf(name)
|
|
await tearDownEntryFiber(entry)
|
|
await entry.refresh()
|
|
await this.ctx.loader.await()
|
|
}
|
|
|
|
/**
|
|
* Remove one Loader entry and wait for its plugin cleanup.
|
|
* @param name - package name of the row.
|
|
*/
|
|
async unload(name: string): Promise<void> {
|
|
const entry = this.entryOf(name)
|
|
const disposal = entry.fiber?.dispose()
|
|
this.ctx.loader.remove(entry.id)
|
|
await disposal
|
|
}
|
|
|
|
/**
|
|
* Dispose the plugin tree, then drop an owned mount and release this
|
|
* client's hold on the shared jsdom shims even when the tree fails to dispose,
|
|
* then `mock.assertNoUnmatched()` last so its failure is the test's reason
|
|
* without skipping the cleanup; when both the tree and the check fail, one
|
|
* error carries both messages. The first call owns the teardown and reports
|
|
* its failure; every later call waits for that teardown and resolves.
|
|
*/
|
|
async dispose(): Promise<void> {
|
|
if (this.disposing !== undefined) {
|
|
await this.disposing.catch(() => undefined)
|
|
return
|
|
}
|
|
this.disposing = this.teardown()
|
|
await this.disposing
|
|
}
|
|
|
|
private async teardown(): Promise<void> {
|
|
let failure: Error | undefined
|
|
try {
|
|
await settle(() => this.ctx.fiber.dispose())
|
|
} catch (error) {
|
|
failure = toError(error)
|
|
} finally {
|
|
this.restore()
|
|
}
|
|
if (failure === undefined) {
|
|
this.mock.assertNoUnmatched()
|
|
return
|
|
}
|
|
try {
|
|
this.mock.assertNoUnmatched()
|
|
} catch (unmatched) {
|
|
throw new Error(`${messageOf(unmatched)}\nclient-test-runtime: the plugin tree also failed to dispose: ${failure.message}`, { cause: failure })
|
|
}
|
|
throw failure
|
|
}
|
|
|
|
private entryOf(name: string): Entry {
|
|
const entries = [...this.ctx.loader.entries()]
|
|
const entry = entries.find(candidate => candidate.options.name === name)
|
|
if (entry === undefined) {
|
|
throw new Error(
|
|
`client-test-runtime: no Loader entry named ${name}; entries: ${entries.map(row => row.options.name).join(', ')}`,
|
|
)
|
|
}
|
|
return entry
|
|
}
|
|
}
|