|
|
@@ -1,13 +1,16 @@
|
|
|
/**
|
|
|
* Whole-client test carrier: boots an {@link AssemblyPlan} through the
|
|
|
* production `bootClient` over an in-process module table, with a
|
|
|
- * `RemoteMock` installed as the Connection carrier through `__DSH_TRANSPORT__.rpc`.
|
|
|
+ * `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 type { ClientTransportHooks, ConnectionHandle } from '@deepseek-ai/dsh-client-connection/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'
|
|
|
@@ -29,73 +32,35 @@ export interface TestClientOptions {
|
|
|
readonly connectTimeoutMs?: number
|
|
|
}
|
|
|
|
|
|
-/** Page global the connection plugin reads its carrier from. */
|
|
|
-interface TransportGlobal {
|
|
|
- __DSH_TRANSPORT__?: ClientTransportHooks
|
|
|
-}
|
|
|
-
|
|
|
-const transportGlobal = globalThis as TransportGlobal
|
|
|
-
|
|
|
/**
|
|
|
- * Process globals every live client in this worker shares: the transport the
|
|
|
- * `connection` plugin reads at apply, and the jsdom shims. The first holder
|
|
|
- * installs them and remembers what was there; the last release removes the
|
|
|
- * shims and restores the transport. A holder installs its transport for its
|
|
|
- * own boot and for each rebuild it runs, both under the worker's boot turn, so
|
|
|
- * overlapping clients and out-of-order disposal neither clobber a booting or
|
|
|
- * rebuilding client nor leak into later tests.
|
|
|
+ * 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 SharedGlobals {
|
|
|
+class SharedJsdomShims {
|
|
|
private holders = 0
|
|
|
- private previousTransport: ClientTransportHooks | undefined
|
|
|
private removeShims: (() => void) | undefined
|
|
|
|
|
|
/**
|
|
|
- * Point the transport global at `transport` for the boot or rebuild about to run; the holder must already hold.
|
|
|
- * @param transport - carrier the next `connection` apply reads.
|
|
|
- */
|
|
|
- install(transport: ClientTransportHooks): void {
|
|
|
- transportGlobal.__DSH_TRANSPORT__ = transport
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Hold the globals with `transport` installed.
|
|
|
- * @param transport - carrier the next boot reads.
|
|
|
+ * Hold the shims for one client.
|
|
|
* @returns the release for this holder.
|
|
|
*/
|
|
|
- acquire(transport: ClientTransportHooks): () => void {
|
|
|
+ acquire(): () => void {
|
|
|
if (this.holders === 0) {
|
|
|
- this.previousTransport = transportGlobal.__DSH_TRANSPORT__
|
|
|
this.removeShims = installJsdomShims()
|
|
|
}
|
|
|
this.holders += 1
|
|
|
- this.install(transport)
|
|
|
return () => {
|
|
|
this.holders -= 1
|
|
|
if (this.holders > 0) return
|
|
|
this.removeShims?.()
|
|
|
this.removeShims = undefined
|
|
|
- if (this.previousTransport === undefined) delete transportGlobal.__DSH_TRANSPORT__
|
|
|
- else transportGlobal.__DSH_TRANSPORT__ = this.previousTransport
|
|
|
- this.previousTransport = undefined
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const sharedGlobals = new SharedGlobals()
|
|
|
+const sharedJsdomShims = new SharedJsdomShims()
|
|
|
|
|
|
-/**
|
|
|
- * Boots and entry rebuilds run one at a time per worker: the transport global
|
|
|
- * must stay the acting client's until its `connection` row applies.
|
|
|
- */
|
|
|
-let bootTurn: Promise<unknown> = Promise.resolve()
|
|
|
-
|
|
|
-/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */
|
|
|
-function takeTurn(work: () => Promise<void>): Promise<void> {
|
|
|
- const turn = bootTurn.then(work)
|
|
|
- bootTurn = turn.catch(() => undefined)
|
|
|
- return turn
|
|
|
-}
|
|
|
+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
|
|
|
@@ -194,16 +159,16 @@ async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number)
|
|
|
/** A booted client under test. */
|
|
|
export class TestClient {
|
|
|
/**
|
|
|
- * Load the roster's modules, then, holding this worker's boot turn, install
|
|
|
- * the mock as the Connection carrier and the jsdom shims and boot through
|
|
|
- * `bootClient` over the synthesized boot graph; afterwards optionally mount
|
|
|
- * and wait for the connection. The `@deepseek-ai/dsh-api-remotes` row is
|
|
|
+ * 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
|
|
|
+ * `@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 globals released before the
|
|
|
+ * 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.
|
|
|
@@ -223,7 +188,9 @@ export class TestClient {
|
|
|
? plan.roster.without([REMOTES_PACKAGE])
|
|
|
: plan.roster
|
|
|
const ctx = new Context()
|
|
|
- const transport: ClientTransportHooks = { rpc: mock.rpc }
|
|
|
+ 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 => {
|
|
|
@@ -231,14 +198,24 @@ export class TestClient {
|
|
|
release?.()
|
|
|
}
|
|
|
try {
|
|
|
- const modules = await loadPluginModules({ ...plan, roster })
|
|
|
+ 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)
|
|
|
- await takeTurn(async () => {
|
|
|
- release = sharedGlobals.acquire(transport)
|
|
|
- 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 })
|
|
|
- })
|
|
|
+ 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`')
|
|
|
@@ -270,11 +247,6 @@ export class TestClient {
|
|
|
private readonly restore: () => void,
|
|
|
) {}
|
|
|
|
|
|
- /** The carrier this client's `connection` row reads when it applies. */
|
|
|
- private get transport(): ClientTransportHooks {
|
|
|
- return { rpc: this.mock.rpc }
|
|
|
- }
|
|
|
-
|
|
|
/** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */
|
|
|
get connection(): ConnectionHandle {
|
|
|
return connectionOf(this.ctx)
|
|
|
@@ -286,20 +258,18 @@ export class TestClient {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then `entry.refresh()`. The rebuild
|
|
|
- * takes the worker's boot turn with this client's carrier installed, so a rebuilt `connection` row reads its
|
|
|
- * own mock even while another client is live. Requires a live client: after `dispose()` the Loader holds no
|
|
|
- * entries and the lookup throws before anything is installed.
|
|
|
+ * 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 takeTurn(async () => {
|
|
|
- sharedGlobals.install(this.transport)
|
|
|
- await tearDownEntryFiber(entry)
|
|
|
- await entry.refresh()
|
|
|
- await this.ctx.loader.await()
|
|
|
- })
|
|
|
+ await tearDownEntryFiber(entry)
|
|
|
+ await entry.refresh()
|
|
|
+ await this.ctx.loader.await()
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -315,7 +285,7 @@ export class TestClient {
|
|
|
|
|
|
/**
|
|
|
* Dispose the plugin tree, then drop an owned mount and release this
|
|
|
- * client's hold on the shared globals even when the tree fails to dispose,
|
|
|
+ * 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
|