diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index a80a2e5563..3903e578d6 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index f802b93b2d..b99b2e2ddd 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -9,11 +9,13 @@ * its Settings row and invalidates that row on host settings changes. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' @@ -40,8 +42,9 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('en') ctx.provide('locale', locale) - const settingsRemote = scriptedSettingsRemote() - const remote = new TestRemote(ctx, { settings: settingsRemote.settings }) + const mock = RemoteMock.create().load(remoteDefaultResponses) + onTestFinished(() => { mock.assertNoUnmatched() }) + const remote = new TestRemote(ctx, { settings: mock.remote.settings }) ctx.slots.register({ name: 'root', children: { diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index b7272db74f..f45aa17d1b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 84a760e1f3..da1dd00f50 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -1,10 +1,12 @@ /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts' +import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { @@ -19,7 +21,8 @@ import { apply as hostApply } from '../src/index.ts' // so browser-language detection never runs and a fresh LocaleRuntime opens on // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. -async function bench(isLoopback = true, settings?: object, services: object = {}) { +async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) { + onTestFinished(() => { mock.assertNoUnmatched() }) const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -37,10 +40,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })), ...services, }, - // Without a settings face the mirror's reads fail and stay contained; the - // Models join itself never fetches until a section actually loads. The real - // ui-settings apply also provides the settingsSchema service. - settings: settings ?? scriptedSettingsRemote().settings, + settings: mock.remote.settings, }) // The fixed Host facts the settings provider reads its persistence from. remote.$host = { home: undefined, isLoopback } @@ -259,25 +259,18 @@ describe('pushed invalidations', () => { it('welcome state follows the shared mirror across document commits', async () => { // The welcome notice derives from its settings scope: a document commit // reaches it through the mirror's one refresh, with no routing here. - const acknowledgement = { current: undefined as string | undefined } - const settings = { - describe: vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, - applies: 'live' as const, - secrets: [], - revision: 0, - }], - }, - })), + const mock = RemoteMock.create().load(remoteDefaultResponses) + const namespace = { + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: {}, + applies: 'live' as const, + secrets: [], + revision: 0, } - const b = await bench(true, settings) + const document = { writable: true, hasDocument: false, namespaces: [namespace] } + mock.remote.settings.describe.mockResolvedValue(ok(document)) + const b = await bench(true, mock) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -290,7 +283,10 @@ describe('pushed invalidations', () => { await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) }) - acknowledgement.current = WELCOME_NOTICE_VERSION + mock.remote.settings.describe.mockResolvedValue(ok({ + ...document, + namespaces: [{ ...namespace, value: { [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, revision: 1 }], + })) b.remote.emit('settings/document-updated', ['ui-onboarding', 1]) await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) @@ -298,24 +294,13 @@ describe('pushed invalidations', () => { }) it('joins the refreshed mirror view on a settings invalidation', async () => { - let revision = 1 - const describe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: 'llm-test', - schema: {}, - value: {}, - applies: 'live' as const, - secrets: [], - revision, - }], - }, - })) + const mock = RemoteMock.create().load(remoteDefaultResponses) + const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 } + const document = { writable: true, hasDocument: false, namespaces: [namespace] } + const describe = mock.remote.settings.describe + describe.mockResolvedValue(ok(document)) const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) - const b = await bench(true, { describe }, { listProviders }) + const b = await bench(true, mock, { listProviders }) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.section') @@ -327,8 +312,8 @@ describe('pushed invalidations', () => { await injected.controller.load() expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) - revision = 2 - b.remote.emit('settings/document-updated', ['llm-test', revision]) + describe.mockResolvedValue(ok({ ...document, namespaces: [{ ...namespace, revision: 2 }] })) + b.remote.emit('settings/document-updated', ['llm-test', 2]) await vi.waitFor(() => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 74f763cef7..addb502c8a 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 61426ccb8002032508feb3c9dbd26718d676177d -README.zh.md: e462f42c1d6ac7a3c7951e65ae23a424679057b0 +README.md: e32f7858b32e15e5200f9a986cb88e29309c44cb +README.zh.md: 1b7da6f38723607dedd5eb16a15ade0b974ed135 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 61426ccb80..e32f7858b3 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -85,15 +85,13 @@ test('registers into the sidebar', async ({ remote, start }) => { The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every namespace in both tiers. An assembled test uses the `remote` fixture; a local `TestRemote` can receive `{ settings: mock.remote.settings }`. Configure returned data and read native `.mock.calls` directly. A mutation response does not update later describe answers automatically: change `remote.settings.describe.mockResolvedValue(...)` explicitly when the scenario publishes new data. The proxy documentation owns unbuilt typing and the required build-backed local typecheck. -### Browser module loading - -A browser page imports `src/assembly/browser.ts`, supplies parsed `ClientRoster` rows, and passes `loadClientModule(name)` in `TestClient.start` options. The importer returns source module namespaces in that page's realm; `provide` replacements and the bootstrap module retain their priority. Node-side bundle reading and Vitest fixtures remain in `src/assembly/index.ts`. Function handlers, native mock queues, and promises are created in the realm where the mock runs, not sent through Playwright serialization. The [shared settings scenario](tests/assembly-settings-scenario.client.ts) runs in jsdom and in [two concurrent Chromium pages](../../../apps/web/tests/client-assembly.e2e.ts), using the web build's source aliases and definitions on independently allocated static servers without a business Host. Source assembly does not test published bundle loading or generated Remote clients. +### Roster and startup behavior `webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it -Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is browser-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. +Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is client-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. ### What can go wrong @@ -130,7 +128,6 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue | | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: transport install, jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | Test-scoped `mock` and lazy `start` fixtures | -| [`src/assembly/browser.ts`](src/assembly/browser.ts) | Realm-local startup without bundle-file reading or Vitest fixtures | | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`: default responses of the Remote endpoints the roster calls at boot | | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | Contract-free `remote.` proxies over the Connection: `remoteNamespacesOf`, `remoteProxiesPlugin` | | [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` and `webApp`: the browser roster read from bundle patch files with the include plugin's own schema and patch application | @@ -173,7 +170,6 @@ None; this package neither assembles nor sends a provider request. These limits define how the bench is consumed. They are current package constraints, not a task backlog. -- **Separate runner entries** — the slot tier and native fixtures use Vitest; browser assembly imports `src/assembly/browser.ts` and supplies a source importer. Neither tier starts a Host or validates HTTP/WebSocket encoding. - **The whole-client tier does not run the generated Remote clients** — `remote.` proxies forward positional arguments without the generated zod validation, wire-name mapping, or scoped-identity injection; mock handlers receive those arguments directly, and the generated clients stay covered by the built-artifact e2e lanes. - **Proxied calls bypass the Gateway client's `invoke` and `invokeStream`** — no `$mount` lifecycle check runs, stream failures are not re-marked through `normalizeConnectionStream`, and a unary rejection is folded by the proxy itself with the Gateway client's exported `carrierFailure` and `cancelledFailure`. `ctx.remote.$stream`, `$on`, and `$host` are the real Gateway client's. - **An undeclared endpoint is dispatched as a unary call** — the proxy learns each endpoint's mode from the mock's registrations; a stream endpoint the spec neither scripts nor declares (`RemoteTable.streams`, `mock.stream(endpoint)`) is logged as a `unary` miss and product code receives a folded result rather than a failing stream. `remoteDefaultResponses` declares the roster's later-opened streams; `dispose()` fails the test either way. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index e462f42c1d..1b7da6f387 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -85,15 +85,13 @@ test('registers into the sidebar', async ({ remote, start }) => { 两档测试的所有命名空间都使用[通用 Remote Proxy](../remote-mock/README.zh.md#remote-proxy)。装配测试使用 `remote` fixture;局部 `TestRemote` 可以接收 `{ settings: mock.remote.settings }`。直接配置返回数据,并读取原生 `.mock.calls`。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 `remote.settings.describe.mockResolvedValue(...)`。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。 -### 浏览器模块加载 - -浏览器页面导入 `src/assembly/browser.ts`,提供已解析的 `ClientRoster` 行,并在 `TestClient.start` 选项中传入 `loadClientModule(name)`。importer 在该页面 realm 内返回源码模块命名空间;`provide` 替换和 bootstrap 模块仍有原来的优先级。Node 侧 bundle 读取与 Vitest fixture 留在 `src/assembly/index.ts`。函数 handler、原生 mock 队列和 promise 在 mock 所在 realm 创建,不经过 Playwright 序列化。[共享 settings 场景](tests/assembly-settings-scenario.client.ts) 同时在 jsdom 和[两个并发 Chromium 页面](../../../apps/web/tests/client-assembly.e2e.ts)中运行,独立分配的静态服务器使用 web 构建的源码别名与定义,不启动业务 Host。源码装配不验证已发布 bundle 加载或生成的 Remote 客户端。 +### Roster 与启动行为 `webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 -当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是浏览器侧测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 +当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是客户端测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 ### 可能出什么问题 @@ -130,7 +128,6 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 | | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:装传输、jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | 测试级 `mock` 与懒启动 `start` fixture | -| [`src/assembly/browser.ts`](src/assembly/browser.ts) | 不读取 bundle 文件、不引入 Vitest fixture 的 realm 内启动入口 | | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`:roster 启动期 Remote 端点的默认响应 | | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | 经 Connection 的无契约 `remote.` 代理:`remoteNamespacesOf`、`remoteProxiesPlugin` | | [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` 与 `webApp`:用 include 插件自己的 schema 与补丁应用从 bundle 补丁文件读出浏览器 roster | @@ -173,7 +170,6 @@ test('registers into the sidebar', async ({ remote, start }) => { 这些限制说明本测试台如何被消费。它们是当前包约束,不是任务积压。 -- **runner 入口分开**——slot 档与原生 fixture 使用 Vitest;浏览器装配导入 `src/assembly/browser.ts` 并提供源码 importer。两档都不启动 Host,也不验证 HTTP/WebSocket 编码。 - **整体档不运行生成的 Remote 客户端**——`remote.` 代理转发位置参数,不经过生成的 zod 校验、wire 名映射或 scoped 身份注入;mock handler 直接接收这些参数,生成客户端仍由 built-artifact e2e 车道覆盖。 - **代理调用绕过 Gateway 客户端的 `invoke` 与 `invokeStream`**——不做 `$mount` 生命周期检查,流失败不经 `normalizeConnectionStream` 重新标记,一元拒绝由代理自己用 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure` 折叠。`ctx.remote.$stream`、`$on`、`$host` 是真 Gateway 客户端的。 - **未声明的端点按一元调用发出**——代理从 mock 的登记学到每个端点的模式;spec 既没给脚本也没声明(`RemoteTable.streams`、`mock.stream(endpoint)`)的流端点记为 `unary` 漏配,产品代码收到的是折叠结果而不是失败的流。`remoteDefaultResponses` 声明了 roster 启动后才打开的流;无论哪种,`dispose()` 都会让测试失败。 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 44cd37bc91..e2b0996fde 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", - "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", + "description": "Browser test runtimes: a jsdom slot bench with test-owned Session and Workspace doubles, and a whole-client tier that boots the web roster through the production bootClient over an endpoint-named Remote mock", "version": "0.1.5-rc.1", "publishConfig": { "access": "public" @@ -25,13 +25,20 @@ "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "js-yaml": "^4.2.0", "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -39,18 +46,28 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -59,12 +76,16 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-web-app": "workspace:^", + "@types/js-yaml": "^4.0.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts new file mode 100644 index 0000000000..14284556f5 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -0,0 +1,138 @@ +/** + * The browser roster of a `dsh --profile`, read from its bundle patch files + * the way the launcher composes them: each bundle's `dsh.bundle.patch` list is + * parsed with the include plugin's YAML dialect (`entryListSchema`) and + * composed by its `applyEntryPatches`; every enabled row whose package + * declares `dsh.client.platform === 'web'` becomes a roster row carrying that + * declaration's `inject` and `immediately`; rows nested in Loader groups count + * like the Loader counts them, a disabled group disabling every row beneath + * it. A patch that matches nothing + * throws here where the launcher warns. Nothing is copied from the bundles: a + * bundle change is visible at the next import. Node only — the + * whole-client tier runs under vitest, and this is the one place it reads the + * repository. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/bundle-roster + */ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client' +import * as yaml from 'js-yaml' +import { ClientRoster, type ClientRosterRow } from './roster.ts' + +/** The `web` profile's bundle layers, in the order `dsh --profile web` applies them (app-boot `PROFILE_TEMPLATES.web`). */ +export const WEB_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] + +interface PackageManifest { + name?: unknown + dsh?: { bundle?: { patch?: unknown }; client?: unknown } +} + +/** One bundle: where its package.json is (plugin names resolve from there) and its parsed patch list. */ +interface BundleLayer { + readonly manifestPath: string + readonly patches: PatchOptions[] +} + +/** + * Compose the browser roster of `bundles`, applied in order. + * @param bundles - bundle package names in application order. + * @param anchor - file whose package resolution locates the bundles; default this package. + * @returns the roster in composition order, one row per package. + * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list + * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression. + */ +export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster { + const layers = bundles.map(name => readLayer(name, anchor)) + const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => { + throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`) + }) + const anchors = layers.map(layer => layer.manifestPath) + const rows: ClientRosterRow[] = [] + const seen = new Set() + for (const { entry, disabled } of flattenGroups(entries)) { + const name = exactPackageSpecifier(entry.name) + if (name === undefined || disabled === true || seen.has(name)) continue + seen.add(name) + const manifestPath = locateManifest(anchors, name) + if (manifestPath === undefined) { + throw new Error(`client-test-runtime: cannot resolve plugin package ${name} from ${bundles.join(', ')}`) + } + const manifest = readManifest(manifestPath) + if (manifest.name !== name) { + throw new Error(`client-test-runtime: ${manifestPath} names ${JSON.stringify(manifest.name)}, expected ${name}`) + } + const declaration = parseDshClient(name, manifest.dsh?.client) + if (declaration === undefined || declaration.platform !== 'web') continue + if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') { + throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`) + } + rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true }) + } + return ClientRoster.of(rows) +} + +function readLayer(bundle: string, anchor: string): BundleLayer { + const manifestPath = locateManifest([anchor], bundle) + if (manifestPath === undefined) throw new Error(`client-test-runtime: cannot resolve bundle ${bundle} from ${anchor}`) + const patch = readManifest(manifestPath).dsh?.bundle?.patch + if (typeof patch !== 'string') throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch in ${manifestPath}`) + const file = join(dirname(manifestPath), patch) + const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema }) + if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`) + return { manifestPath, patches: parsed as PatchOptions[] } +} + +/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */ +interface FlatEntry { + readonly entry: EntryOptions + readonly disabled: unknown +} + +/** + * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's + * `disabled` disables every row beneath it. + * @param entries - composed entries, possibly nested. + * @param inherited - the enclosing group's `disabled` when set. + * @returns the plugin rows. + */ +function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] { + const rows: FlatEntry[] = [] + for (const entry of entries) { + const own = (entry as { disabled?: unknown }).disabled + const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own + if (entry.group === true && Array.isArray(entry.config)) { + rows.push(...flattenGroups(entry.config as EntryOptions[], disabled)) + continue + } + rows.push({ entry, disabled }) + } + return rows +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +/** Locate `/package.json` on the resolution paths of any anchor, without requiring a `./package.json` export. */ +function locateManifest(anchors: readonly string[], name: string): string | undefined { + for (const anchor of anchors) { + for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) { + const candidate = join(searchPath, name, 'package.json') + if (existsSync(candidate)) return candidate + } + } + return undefined +} + +/** The include plugin's `%C` placeholders, filled the way the launcher prints them. */ +function describe(message: string, args: readonly unknown[]): string { + let index = 0 + return message.replace(/%C/g, () => JSON.stringify(args[index++])) +} + +/** The `web` profile's browser roster, composed from its bundles at import. */ +export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES) diff --git a/packages/test-support/client-runtime/src/assembly/index.ts b/packages/test-support/client-runtime/src/assembly/index.ts new file mode 100644 index 0000000000..5223e691b3 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/index.ts @@ -0,0 +1,14 @@ +/** + * Whole-client tier entry (deep import only: + * `@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts`). Kept out of + * the package root so slot-tier specs do not load the assembly machinery. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly + */ +export { ClientRoster } from './roster.ts' +export type { AssemblyPlan, ClientPluginModule, ClientRosterRow } from './roster.ts' +export { TestClient } from './test-client.ts' +export type { TestClientOptions } from './test-client.ts' +export { remoteDefaultResponses } from './remote-default-responses.ts' +export { bundleRoster, webApp } from './bundle-roster.ts' +export { createClientTest } from './vitest.ts' +export type { ClientTestFixtures } from './vitest.ts' diff --git a/packages/test-support/client-runtime/src/assembly/modules.ts b/packages/test-support/client-runtime/src/assembly/modules.ts new file mode 100644 index 0000000000..60a2fdbfdf --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/modules.ts @@ -0,0 +1,80 @@ +/** + * In-process module arrival: import every roster row's `/client` module (or + * the plan's replacement) and register each as a pre-arrived factory on a + * production `ClientModuleSystem`, so neither the Loader's `internal.import` + * nor a stage-one `prefetch` ever fetches a bundle. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/modules + */ +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import { createClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' +import type { ClientModuleLoader, ClientModuleLoaderTarget, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import type { AssemblyPlan, ClientPluginModule } from './roster.ts' + +/** The bootstrap row: always this process's static namespace, never a dynamic import or a `provide` replacement. */ +export const MODULES_PACKAGE = '@deepseek-ai/dsh-client-modules' + +/** + * Resolve each roster row to its plugin module: `plan.provide[name]` when + * present, otherwise a `/client` import resolved by the repository's tsconfig + * path aliases under Vitest. The bootstrap row is the + * statically imported `@deepseek-ai/dsh-client-modules/client` namespace. + * @param plan - validated plan. + * @returns package name → module, in roster order. + * @throws {Error} when an import fails (the package name prefixes the original message) or the bootstrap row is provided. + */ +export async function loadPluginModules(plan: AssemblyPlan): Promise> { + const modules = new Map() + for (const { name } of plan.roster.rows) { + const provided = plan.provide?.[name] + if (name === MODULES_PACKAGE) { + if (provided !== undefined) { + throw new Error(`client-test-runtime: ${MODULES_PACKAGE} is the bootstrap module and cannot be provided`) + } + modules.set(name, modulesClient) + continue + } + modules.set(name, provided ?? await importClient(name)) + } + return modules +} + +async function importClient(name: string): Promise { + let namespace: unknown + try { + namespace = await import(/* @vite-ignore */ `${name}/client`) + } catch (error) { + throw new Error(`client-test-runtime: cannot import ${name}/client: ${String(error)}`, { cause: error }) + } + return namespace as ClientPluginModule +} + +/** + * Build the production module system over queued factories returning the + * loaded namespaces. The bootstrap row uses `bootstrapModule`; `staticModules` + * is empty because namespaces already hold their own imports. Missing factories + * reject through `loadBundle` without fetching. + * @param graph - raw boot graph from `graphFromRoster`; `createClientModuleSystem` parses it. + * @param modules - loaded plugin modules keyed by package name. + * @returns module system to install as `loader.internal`; its `manifest` is the parsed graph. + */ +export function createInProcessModules( + graph: WebBootGraph, + modules: ReadonlyMap, +): ClientModuleLoader { + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue: [], + /* v8 ignore next -- construction replaces this sink before draining the prefilled queue. */ + load: () => { throw new Error('client-test-runtime: module facade is not initialized') }, + create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: modulesClient }, options), + } + for (const [id, namespace] of modules) { + if (id === MODULES_PACKAGE) continue + target.pendingQueue.push({ id, factory: () => namespace as unknown as Record }) + } + return target.create({ + boot: graph, + staticModules: {}, + loadBundle: url => Promise.reject(new Error(`client-test-runtime: in-process modules never load bundles (${url})`)), + }) +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts new file mode 100644 index 0000000000..2c0dfddbd1 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -0,0 +1,45 @@ +/** + * Default responses for every Remote endpoint the web assembly calls while + * booting and rendering with no sessions, no workspaces, and default settings. + * The comment above each row names the plugin that calls it; endpoints boot + * never touches stay absent so a new call fails loud. `$events` is built into + * `RemoteMock`. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses + */ +import { ok, openStream, type RemoteTable } from '@deepseek-ai/dsh-remote-mock' + +/** Default responses of the boot-time Remote endpoints; a spec loads it first and layers its own table on top. */ +export const remoteDefaultResponses: RemoteTable = { + unary: { + // api-session-controller `sessions.handleConnected()` on `connection/reset`. + 'session/list': ok({ items: [] }), + // ui-settings `mirror.ensure()` at apply and again on `connection/reset`. + 'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }), + // ui-model-selection `ModelDirectoryResolver` constructor. + 'session/modelCatalog': ok({ + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: [], + groups: [], + failures: [], + }), + // ui-agent-preset hero chip and header label on first mount. + 'agentPresets/list': ok({ presets: [], authorable: false }), + // cordis-client-runner `ClientCordisInspectRegistry.sync` at apply and on `connection/reset`. + 'dynamicCordisRunner/syncInspectManifest': ok(null), + // ui-cordis inventory at apply and on `connection/reset`. + 'dynamicCordisRunner/inventory': ok([]), + // ui-settings-plugins web-search card `readCredential()` when the settings mirror first publishes. + 'credentials/describe': ok({}), + }, + // Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss. + streams: [ + // api-session-controller `SessionEventStream.follow` when a Session opens. + 'session/follow', + ], + stream: { + // api-session-controller client `apply`: the control stream's opening baseline, then open. + 'session/control': openStream([{ type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }]), + // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. + 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]), + }, +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-proxies.ts b/packages/test-support/client-runtime/src/assembly/remote-proxies.ts new file mode 100644 index 0000000000..d1a3b09142 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/remote-proxies.ts @@ -0,0 +1,87 @@ +/** + * `remote.` services for the whole-client tier, without the generated + * Remote clients. Cordis resolves `ctx.remote.` to whichever service is + * registered under `remote.` (vendor cordis `utils.ts`, traceable get), so + * the carrier provides one Proxy per namespace: `remote..(...args)` + * calls the endpoint `/` over the roster's own Connection with the + * positional `args`, as a stream when the mock registered a stream script for + * it and as a unary call otherwise. A unary answer is returned unchanged and a + * unary rejection is folded the way the generated client folds a carrier + * throw (`gateway/internal`, or `gateway/cancelled` once the caller's signal + * aborted), so product code that never awaits a rejection sees none; stream + * items and failures pass through as the stream yields them. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-proxies + */ +import type { Context } from '@deepseek-ai/cordis' +import { cancelledFailure, carrierFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { ClientPluginModule } from './roster.ts' + +/** The assembly row the proxies stand in for; its generated clients exist only in built `lib/`. */ +export const REMOTES_PACKAGE = '@deepseek-ai/dsh-api-remotes' + +const PREFIX = 'remote.' + +/** + * Namespaces to provide: every `remote.` a roster module injects, plus the + * namespace of every endpoint the mock has a rule for. + * @param modules - loaded roster modules. + * @param mock - the spec's mock. + * @returns sorted namespace names. + */ +export function remoteNamespacesOf(modules: Iterable, mock: RemoteMock): readonly string[] { + const names = new Set() + for (const module of modules) { + for (const service of injectNames(module.inject)) if (service.startsWith(PREFIX)) names.add(service.slice(PREFIX.length)) + } + for (const endpoint of mock.endpoints()) { + const slash = endpoint.indexOf('/') + if (slash > 0 && !endpoint.startsWith('$')) names.add(endpoint.slice(0, slash)) + } + return [...names].sort() +} + +function injectNames(inject: ClientPluginModule['inject']): readonly string[] { + if (inject === undefined) return [] + if (Array.isArray(inject)) return inject as readonly string[] + return Object.keys(inject) +} + +/** + * Plugin providing the namespace proxies; `TestClient.start` mounts it before the Loader rows. + * @param namespaces - namespaces to provide. + * @param mock - the spec's mock, asked for each endpoint's mode. + * @returns the plugin. + */ +export function remoteProxiesPlugin(namespaces: readonly string[], mock: RemoteMock): ClientPluginModule { + return { + inject: ['connection'], + apply(ctx: Context) { + const connection = ctx.get('connection') as ConnectionHandle + for (const namespace of namespaces) ctx.provide(`${PREFIX}${namespace}`, namespaceProxy(namespace, connection, mock)) + }, + } +} + +function namespaceProxy(namespace: string, connection: ConnectionHandle, mock: RemoteMock): object { + return new Proxy(Object.create(null) as Record, { + get: (_target, property) => { + // No `then`: awaiting the namespace object itself must not call a method. + if (typeof property !== 'string' || property === 'then') return undefined + return (...values: readonly unknown[]): unknown => { + const endpoint = `${namespace}/${property}` + const args = [...values] + const signal = args.at(-1) instanceof AbortSignal ? (args.pop() as AbortSignal) : undefined + if (mock.modeOf(endpoint) === 'stream') { + const open = connection.rpc.open + /* v8 ignore next -- the mock transport always supplies openStream, so the Connection carrier exposes open. */ + if (open === undefined) throw new Error(`client-test-runtime: ${endpoint} is a stream but the carrier has no in-process opener`) + return open('/api', endpoint, { args }, signal ?? new AbortController().signal) + } + return connection.rpc.call('/api', endpoint, { args }, signal).catch((error: unknown) => + (signal?.aborted === true ? cancelledFailure(endpoint, error) : carrierFailure(endpoint, error))) + } + }, + }) +} diff --git a/packages/test-support/client-runtime/src/assembly/roster.ts b/packages/test-support/client-runtime/src/assembly/roster.ts new file mode 100644 index 0000000000..82aa14df93 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/roster.ts @@ -0,0 +1,163 @@ +/** + * Client roster: the ordered package-name rows a whole-client test boots, and + * the plan that annotates one with the rows the test provides itself. `webApp` + * and `bundleRoster` (`./bundle-roster.ts`) read rosters from the bundle patch + * files; a spec may also build one inline with {@link ClientRoster.of}. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/roster + */ +import type { Context } from '@deepseek-ai/cordis' +import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import { PLATFORM_MODULES } from '@deepseek-ai/dsh-client-web/src/platform.ts' + +/** One browser plugin row as `dsh.client` declares it, keyed by package name. */ +export interface ClientRosterRow { + /** Package name (== manifest entry id == Loader entry name). */ + readonly name: string + /** Package-name dependency edges from `dsh.client.inject` ([] when absent). */ + readonly inject: readonly string[] + /** Stage-one prefetch mark from `dsh.client.immediately` (false when absent). */ + readonly immediately: boolean +} + +/** Revision stamped on every synthesized row and batch; nothing is fetched by it. */ +const LOCAL_REV = 'local' + +/** + * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch + * holding every row, `rev: 'local'`, placeholder `/plugins//client.js` + * URLs, since every module is seeded in process and never fetched. Validation + * stays with the production `parseBootManifest` inside the module system: + * duplicate names and an empty roster are rejected there, not here. + * @param rows - roster rows in composition order. + * @returns the unparsed graph, as `createClientModuleSystem` consumes it. + */ +export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph { + const entries: WebBootEntry[] = rows.map(row => ({ + id: row.name, + url: `/plugins/${row.name}/client.js`, + rev: LOCAL_REV, + ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}), + ...(row.immediately ? { immediately: true } : {}), + })) + return { + rev: LOCAL_REV, + entries, + batches: [{ + phase: 'application', + url: '/plugins/local.js', + rev: LOCAL_REV, + entries: entries.map(entry => entry.id), + }], + } +} + +/** Module names the shell seeds before any row loads; an inject edge to one of them is satisfied without a row. */ +const PLATFORM_SEED: ReadonlySet = new Set(PLATFORM_MODULES) + +/** Immutable, name-addressable roster. */ +export class ClientRoster { + /** + * Build a roster from rows; duplicate names throw. + * @param rows - roster rows in composition order. + * @returns roster. + */ + static of(rows: readonly ClientRosterRow[]): ClientRoster { + const seen = new Set() + const duplicates = new Set() + for (const { name } of rows) { + if (seen.has(name)) duplicates.add(name) + seen.add(name) + } + if (duplicates.size > 0) { + throw new Error(`client-test-runtime: duplicate roster rows: ${[...duplicates].join(', ')}`) + } + return new ClientRoster(Object.freeze([...rows])) + } + + private constructor(readonly rows: readonly ClientRosterRow[]) {} + + /** + * Keep only `names`, preserving roster order; an unknown name throws with the roster listed. + * @param names - package names to keep. + * @returns sub-roster. + */ + pick(names: readonly string[]): ClientRoster { + const keep = this.known(names, 'pick') + return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name)))) + } + + /** + * The named rows plus every row they inject, transitively, in roster order: the rows a spec needs to boot the + * named plugins as the bundle composes them. The shell's platform modules (`PLATFORM_MODULES`, seeded statically + * rather than loaded as rows) end the walk. An unknown name throws with the roster listed; a row injecting any + * other package outside the roster throws, since the bundle itself would not boot. + * @param names - package names whose dependency cone to keep. + * @returns sub-roster. + */ + closure(names: readonly string[]): ClientRoster { + this.known(names, 'closure') + const byName = new Map(this.rows.map(row => [row.name, row])) + const keep = new Set() + const visit = (name: string, from: string | undefined): void => { + if (keep.has(name) || PLATFORM_SEED.has(name)) return + const row = byName.get(name) + if (row === undefined) { + throw new Error(`client-test-runtime: ${String(from)} injects ${name}, which is outside the roster`) + } + keep.add(name) + for (const dependency of row.inject) visit(dependency, name) + } + for (const name of names) visit(name, undefined) + return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name)))) + } + + /** + * Drop `names`; an unknown name throws with the roster listed. + * @param names - package names to drop. + * @returns sub-roster. + */ + without(names: readonly string[]): ClientRoster { + const drop = this.known(names, 'without') + return new ClientRoster(Object.freeze(this.rows.filter(row => !drop.has(row.name)))) + } + + private known(names: readonly string[], operation: string): ReadonlySet { + const rostered = this.rows.map(row => row.name) + const unknown = names.filter(name => !rostered.includes(name)) + if (unknown.length > 0) { + throw new Error( + `client-test-runtime: ${operation}() names outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`, + ) + } + return new Set(names) + } +} + +/** The module face the Loader materializes for one client plugin row. */ +export interface ClientPluginModule { + apply(ctx: Context, config?: unknown): unknown + readonly inject?: readonly string[] | Readonly> + readonly Config?: unknown +} + +/** What to boot and what the test supplies itself. */ +export interface AssemblyPlan { + readonly roster: ClientRoster + /** Row replacements by package name (the test's own implementation of that row). Names outside the roster throw. */ + readonly provide?: Readonly> +} + +/** + * Validate a plan against its roster. + * @param plan - plan to check. + * @throws {Error} naming any `provide` key outside the roster. + */ +export function assertPlan(plan: AssemblyPlan): void { + const rostered = plan.roster.rows.map(row => row.name) + const unknown = Object.keys(plan.provide ?? {}).filter(name => !rostered.includes(name)) + if (unknown.length > 0) { + throw new Error( + `client-test-runtime: provide names rows outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`, + ) + } +} diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts new file mode 100644 index 0000000000..7e26d9bbc8 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -0,0 +1,361 @@ +/** + * 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`. + * @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 { 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 +} + +/** 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. + */ +class SharedGlobals { + 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. + * @returns the release for this holder. + */ + acquire(transport: ClientTransportHooks): () => 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() + +/** + * 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 = Promise.resolve() + +/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */ +function takeTurn(work: () => Promise): Promise { + const turn = bootTurn.then(work) + bootTurn = turn.catch(() => undefined) + return turn +} + +/** 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> = { + 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 + 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): Promise { + if (typeof document === 'undefined') { + await fn() + return + } + await act(async () => { await fn() }) +} + +async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise { + const { state } = connectionOf(ctx) + if (state.getSnapshot() === 'connected') return + await new Promise((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, 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 + * dropped from the roster: its generated Remote clients exist only in built + * `lib/`, and the `remote.` 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 + * 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 { + assertPlan(plan) + if (plan.provide?.[REMOTES_PACKAGE] !== undefined) { + throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote. 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 transport: ClientTransportHooks = { rpc: mock.rpc } + let mountPoint: MountPoint = { element: undefined, owned: false } + let release: (() => void) | undefined + const restore = (): void => { + if (mountPoint.owned) mountPoint.element?.remove() + release?.() + } + try { + const modules = await loadPluginModules({ ...plan, roster }) + 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 }) + }) + 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 | undefined + + private constructor( + readonly ctx: Context, + readonly mock: RemoteMock, + readonly container: HTMLElement | undefined, + 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) + } + + /** Flush pending React work and microtasks inside `act` (plain microtask flush without a DOM). */ + async flush(): Promise { + await settle(() => Promise.resolve()) + } + + /** + * 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. + * @param name - package name of the row. + */ + async reload(name: string): Promise { + const entry = this.entryOf(name) + await takeTurn(async () => { + sharedGlobals.install(this.transport) + await tearDownEntryFiber(entry) + await entry.refresh() + await this.ctx.loader.await() + }) + } + + /** + * Remove one Loader entry. + * @param name - package name of the row. + */ + async unload(name: string): Promise { + await this.ctx.loader.remove(this.entryOf(name).id) + } + + /** + * 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, + * 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 { + if (this.disposing !== undefined) { + await this.disposing.catch(() => undefined) + return + } + this.disposing = this.teardown() + await this.disposing + } + + private async teardown(): Promise { + 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 + } +} diff --git a/packages/test-support/client-runtime/src/assembly/vitest.ts b/packages/test-support/client-runtime/src/assembly/vitest.ts new file mode 100644 index 0000000000..889ee2adcb --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/vitest.ts @@ -0,0 +1,60 @@ +/** 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 +} + +/** + * 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 and readiness 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 { + return test.extend({ + 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 | undefined + let closed = false + const start = (): Promise => { + 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() + } + }, + }) +} diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 23bc01bdfb..8472514901 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -45,8 +45,6 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' -export { scriptedSettingsRemote } from './settings-remote.ts' -export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts' export { TestWorkspaces } from './workspaces.ts' export { RemoteError, TestRemote } from './remote.ts' export { diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts deleted file mode 100644 index 97c2748480..0000000000 --- a/packages/test-support/client-runtime/src/settings-remote.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** Test double for the `settings` Remote namespace a bench's plugins inject. */ -import { vi } from 'vitest' - -/** The minimum a scripted namespace view carries for the double's own bookkeeping. */ -export interface ScriptedNamespace { - /** Namespace key the write addresses. */ - ns: string -} - -/** One scripted `settings` namespace face plus the controls a bench drives it with. */ -export interface ScriptedSettingsRemote { - /** - * The namespace face handed to `TestRemote` as `settings`. A plugin injecting - * `remote.settings` unparks on it, which is what most benches need; the - * describe answer is the same one the shared mirror would read. - */ - settings: { - describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }> - update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - } - /** Spy behind `settings.update`, for argument assertions. */ - update: ReturnType - /** Spy behind `settings.replace`, for argument assertions. */ - replace: ReturnType - /** Spy behind `settings.mutate`, for argument assertions. */ - mutate: ReturnType - /** - * Replace what the next describe answers with, as a Host commit would. - * @param namespaces - the namespace views to serve from now on. - */ - publish(namespaces: readonly View[]): void -} - -/** - * Build a scripted `settings` Remote namespace for a bench. Each write answers - * with the addressed namespace unchanged, so a bench that only needs its - * plugins to activate scripts nothing; one asserting a write reads the - * corresponding spy or replaces the face. - * @param namespaces - namespace views the first describe answers with. - * @param options - deployment facts the describe answer reports. - * @returns the face and its controls. - */ -export function scriptedSettingsRemote( - namespaces: readonly View[] = [], - options: { writable?: boolean; hasDocument?: boolean } = {}, -): ScriptedSettingsRemote { - let served = namespaces - const writable = options.writable ?? true - const hasDocument = options.hasDocument ?? false - const answer = (ns: string) => { - const view = served.find(candidate => candidate.ns === ns) - return Promise.resolve(view === undefined - ? { - ok: false as const, - error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, - } - : { ok: true as const, value: view }) - } - const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns)) - const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns)) - const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns)) - return { - settings: { - describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }), - update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision), - replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision), - mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision), - }, - update, - replace, - mutate, - publish(next) { served = next }, - } -} diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts new file mode 100644 index 0000000000..e73ce00818 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -0,0 +1,195 @@ +/** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts' +import { afterAll, describe, expect, it } from 'vitest' +import { MODULES_PACKAGE } from '../src/assembly/modules.ts' +import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts' + +describe('webApp (the real web profile)', () => { + it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => { + expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const names = webApp.rows.map(row => row.name) + expect(new Set(names).size).toBe(names.length) + const known = new Set([...names, ...Object.keys(getStaticModules())]) + const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`)) + expect(dangling).toEqual([]) + expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows) + }) + + it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => { + const immediate = new Set(webApp.rows.filter(row => row.immediately).map(row => row.name)) + expect(immediate.has(MODULES_PACKAGE)).toBe(true) + expect(immediate.has('@deepseek-ai/dsh-client-connection')).toBe(true) + expect(webApp.rows.find(row => row.name === '@deepseek-ai/dsh-api-gateway')?.inject) + .toEqual(['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection']) + const names = webApp.rows.map(row => row.name) + expect(names).toContain('@deepseek-ai/dsh-client-ui-settings-general') + expect(names).not.toContain('@deepseek-ai/dsh-llm') // Host only + expect(names).not.toContain('@deepseek-ai/dsh-client-ui-schedule') // inserted disabled + expect(names).not.toContain('@deepseek-ai/dsh-web-app') // Host runtime glue, its `/startup` row is a subpath + }) +}) + +/** A scratch installation: an anchor package, bundles under its node_modules, plugin packages beside them. */ +class Scratch { + readonly root = mkdtempSync(join(tmpdir(), 'bundle-roster-')) + readonly anchor = join(this.root, 'app', 'package.json') + + constructor() { + mkdirSync(join(this.root, 'app'), { recursive: true }) + writeFileSync(this.anchor, JSON.stringify({ name: 'app' })) + } + + pkg(name: string, manifest: Record, files: Record = {}): void { + const dir = join(this.root, 'app', 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...manifest })) + for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content) + } + + bundle(name: string, patch: string): void { + this.pkg(name, { dsh: { bundle: { patch: './cordis.patch.yml' } } }, { 'cordis.patch.yml': patch }) + } + + web(name: string, client: Record = {}): void { + this.pkg(name, { dsh: { client: { platform: 'web', ...client } } }) + } + + roster(bundles: readonly string[]): readonly string[] { + return bundleRoster(bundles, this.anchor).rows.map(row => row.name) + } +} + +describe('bundleRoster on a scratch installation', () => { + const scratch = new Scratch() + afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) }) + + it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => { + scratch.web('@t/a', { inject: ['@t/b'], immediately: true }) + scratch.web('@t/b') + scratch.web('@t/c') + scratch.web('plain') + scratch.pkg('@t/host', { dsh: {} }) + scratch.pkg('@t/node', { dsh: { client: { platform: 'node' } } }) + scratch.bundle('@t/base', ` +- insert: + - id: a + name: '@t/a' + config: + root: !!js process.cwd() + - id: host + name: '@t/host' + disabled: !!js process.platform === 'win32' + - id: node + name: '@t/node' + - id: c + name: '@t/c' + - id: sub + name: '@t/a/extra' + - id: builtin + name: 'cordis:group' +`) + scratch.bundle('@t/web', ` +- id: c + disabled: true +- insert: + - id: b + name: '@t/b' + - id: b-again + name: '@t/b' + - id: plain + name: plain + - id: off + name: '@t/off' + disabled: true +`) + const roster = bundleRoster(['@t/base', '@t/web'], scratch.anchor) + expect(roster.rows).toEqual([ + { name: '@t/a', inject: ['@t/b'], immediately: true }, + { name: '@t/b', inject: [], immediately: false }, + { name: 'plain', inject: [], immediately: false }, + ]) + expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c']) + }) + + it('descends into Loader groups and lets a disabled group disable every row beneath it', () => { + scratch.web('@t/grouped') + scratch.web('@t/grouped-off') + scratch.web('@t/nested') + scratch.bundle('@t/groups', ` +- insert: + - id: on + name: cordis:group + group: true + config: + - id: grouped + name: '@t/grouped' + - id: inner + name: cordis:group + group: true + config: + - id: nested + name: '@t/nested' + - id: off + name: cordis:group + group: true + disabled: true + config: + - id: grouped-off + name: '@t/grouped-off' +`) + expect(scratch.roster(['@t/groups'])).toEqual(['@t/grouped', '@t/nested']) + }) + + it('refuses a browser row whose disabled value is a !!js expression', () => { + scratch.web('@t/maybe') + scratch.bundle('@t/maybe-bundle', ` +- insert: + - id: maybe + name: '@t/maybe' + disabled: !!js process.platform === 'win32' +`) + expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate') + }) + + it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => { + expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from') + scratch.pkg('@t/no-patch', { dsh: {} }) + expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in') + scratch.bundle('@t/not-a-list', 'insert: []\n') + expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches') + }) + + it('fails loud on a patch that does not apply as written', () => { + scratch.bundle('@t/dangling', ` +- id: nowhere + disabled: true +`) + expect(() => scratch.roster(['@t/dangling'])).toThrow('bundle patch patch: entry "nowhere" not found') + }) + + it('fails loud on an enabled row whose package does not resolve or names another package', () => { + scratch.bundle('@t/unresolved', ` +- insert: + - id: ghost + name: '@t/ghost' +`) + expect(() => scratch.roster(['@t/unresolved'])).toThrow('cannot resolve plugin package @t/ghost from @t/unresolved') + scratch.bundle('@t/builtin', ` +- insert: + - id: fs + name: fs +`) + // A Node builtin name has no resolution paths at all. + expect(() => scratch.roster(['@t/builtin'])).toThrow('cannot resolve plugin package fs from @t/builtin') + scratch.pkg('@t/alias', { name: '@t/real' }) + scratch.bundle('@t/misnamed', ` +- insert: + - id: alias + name: '@t/alias' +`) + expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts new file mode 100644 index 0000000000..4bd4835b59 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts @@ -0,0 +1,37 @@ +/** Real bundle dependency closures activate their requested client plugins without extra roster rows. */ +import { FiberState } from '@deepseek-ai/cordis' +import { describe, expect, vi } from 'vitest' +import { createClientTest, webApp } from '../src/assembly/index.ts' + +const HMR = '@deepseek-ai/dsh-client-hmr' +const MODULES = '@deepseek-ai/dsh-client-modules' +const SESSIONS = '@deepseek-ai/dsh-api-session-controller' +const FILE_UPLOAD = '@deepseek-ai/dsh-client-file-upload' +const hmrRoster = webApp.closure([HMR]) +const sessionRoster = webApp.closure([SESSIONS]) +const hmrTest = createClientTest({ roster: hmrRoster }, { awaitConnected: false }) +const sessionTest = createClientTest({ roster: sessionRoster }) + +describe('bundle dependency closures', () => { + hmrTest('activates HMR with its declared module provider and no Connection', async ({ start }) => { + expect(hmrRoster.rows.map(row => row.name)).toContain(MODULES) + const client = await start() + const entry = [...client.ctx.loader.entries()].find(row => row.options.name === HMR) + expect(entry?.fiber?.state).toBe(FiberState.ACTIVE) + expect(client.ctx.get('modules')).toBeDefined() + expect(client.ctx.get('connection')).toBeUndefined() + }, 60_000) + + sessionTest('activates Session Controller with its declared file-upload provider', async ({ mock, start }) => { + expect(sessionRoster.rows.map(row => row.name)).toContain(FILE_UPLOAD) + const client = await start() + const entry = [...client.ctx.loader.entries()].find(row => row.options.name === SESSIONS) + expect(entry?.fiber?.state).toBe(FiberState.ACTIVE) + expect(client.ctx.get('fileUpload')).toBeDefined() + await vi.waitFor(() => { + expect(client.ctx.sessions.list.getSnapshot().phase).toBe('ready') + expect(mock.log.calls('session/list')).toHaveLength(1) + expect(mock.log.streams('session/control')).toHaveLength(1) + }) + }, 60_000) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts new file mode 100644 index 0000000000..999a99ea70 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts @@ -0,0 +1,105 @@ +/** + * In-process module arrival: dynamic `/client` imports resolve through the + * repository path aliases, provided rows replace them, the bootstrap row is the + * static namespace, and the resulting module system serves the vendored Loader + * without ever loading a bundle. + */ +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import { parseBootManifest } from '@deepseek-ai/dsh-client-modules/client' +import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import * as uiRenderer from '@deepseek-ai/dsh-client-ui-renderer/client' +import * as typertRegistry from '@deepseek-ai/dsh-typert-registry/client' +import { describe, expect, it, onTestFinished } from 'vitest' +import { ClientRoster } from '../src/assembly/index.ts' +import { MODULES_PACKAGE, createInProcessModules, loadPluginModules } from '../src/assembly/modules.ts' + +const RENDERER = '@deepseek-ai/dsh-client-ui-renderer' +const TYPERT = '@deepseek-ai/dsh-typert-registry' +const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' +const MISSING = '@deepseek-ai/dsh-client-does-not-exist' + +const row = (name: string, immediately = false) => ({ name, inject: [], immediately }) + +function graph(names: readonly string[]): WebBootGraph { + return { + rev: 'local', + entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })), + batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }], + } +} + +describe('loadPluginModules', () => { + it('imports real rows dynamically, takes provided rows from the plan, and pins the bootstrap row', async () => { + const provided = { apply: () => {} } + const roster = ClientRoster.of([row(TYPERT), row(MODULES_PACKAGE), row(BRAND), row(RENDERER)]) + const modules = await loadPluginModules({ roster, provide: { [BRAND]: provided } }) + expect([...modules.keys()]).toEqual([TYPERT, MODULES_PACKAGE, BRAND, RENDERER]) + expect(modules.get(TYPERT)).toBe(typertRegistry) + expect(modules.get(RENDERER)).toBe(uiRenderer) + expect(modules.get(MODULES_PACKAGE)).toBe(modulesClient) + expect(modules.get(BRAND)).toBe(provided) + }) + + it('prefixes the package name when a /client import fails', async () => { + const roster = ClientRoster.of([row(MISSING)]) + await expect(loadPluginModules({ roster })).rejects.toThrow( + new RegExp(`^client-test-runtime: cannot import ${MISSING}/client: .+`), + ) + }) + + it('refuses a provided bootstrap row', async () => { + const roster = ClientRoster.of([row(MODULES_PACKAGE)]) + await expect(loadPluginModules({ roster, provide: { [MODULES_PACKAGE]: { apply: () => {} } } })) + .rejects.toThrow(`${MODULES_PACKAGE} is the bootstrap module and cannot be provided`) + }) +}) + +describe('createInProcessModules', () => { + it('serves import() and prefetch() from registered factories and never loads a bundle', async () => { + const roster = ClientRoster.of([row(MODULES_PACKAGE, true), row(TYPERT, true), row(RENDERER)]) + const modules = await loadPluginModules({ roster }) + const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules) + + await expect(system.prefetch(TYPERT)).resolves.toBeUndefined() + await expect(system.prefetch(RENDERER)).resolves.toBeUndefined() + await expect(system.prefetch(MODULES_PACKAGE)).resolves.toBeUndefined() + expect(system.loadCache.has(TYPERT)).toBe(false) + expect(system.loadCache.has(RENDERER)).toBe(false) + await expect(system.import(RENDERER, '', {})).resolves.toBe(uiRenderer) + await expect(system.import(`${TYPERT}/client`, '', {})).resolves.toBe(typertRegistry) + await expect(system.import(MODULES_PACKAGE, '', {})).resolves.toBe(modulesClient) + expect(system.loadCache.get(TYPERT)?.exports).toBe(typertRegistry) + expect(system.loadCache.get(RENDERER)?.exports).toBe(uiRenderer) + await expect(system.import('@deepseek-ai/dsh-client-unknown', '', {})).rejects.toThrow('cannot resolve') + }) + + it('rejects loudly instead of fetching when a graph row has no loaded module', async () => { + const system = createInProcessModules(graph(['ghost']), new Map()) + await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + }) + + it('exposes the graph parsed by the production validator as its manifest', () => { + const raw = graph(['a', 'b']) + expect(createInProcessModules(raw, new Map()).manifest).toEqual(parseBootManifest(raw)) + expect(() => createInProcessModules({ ...raw, batches: [] }, new Map())).toThrow('belongs to no initial-load batch') + }) + + it('boots the vendored Loader over the in-process table with every entry active', async () => { + const roster = ClientRoster.of([row(TYPERT, true), row(RENDERER, true), row(MODULES_PACKAGE, true)]) + const modules = await loadPluginModules({ roster }) + const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Loader) + ctx.loader.internal = system as never + await Promise.all(roster.rows.map(r => ctx.loader.create({ name: r.name }))) + await ctx.loader.await() + expect(ctx.get('typert')).toBeDefined() + expect(ctx.get('slots')).toBeDefined() + expect(ctx.get('modules')).toBe(system) + expect([...ctx.loader.entries()].every(entry => entry.fiber !== undefined)).toBe(true) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts new file mode 100644 index 0000000000..502a0f8fe7 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts @@ -0,0 +1,22 @@ +/** assertPlan: provide keys must name roster rows. */ +import { describe, expect, it } from 'vitest' +import { assertPlan } from '../src/assembly/roster.ts' +import { ClientRoster } from '../src/assembly/roster.ts' + +const roster = ClientRoster.of([ + { name: 'a', inject: [], immediately: false }, + { name: 'b', inject: [], immediately: false }, +]) +const plugin = { apply: () => {} } + +describe('assertPlan', () => { + it('accepts a bare roster and provided roster rows', () => { + expect(() => { assertPlan({ roster }) }).not.toThrow() + expect(() => { assertPlan({ roster, provide: { a: plugin, b: plugin } }) }).not.toThrow() + }) + + it('rejects provide keys outside the roster and lists the roster', () => { + expect(() => { assertPlan({ roster, provide: { a: plugin, zz: plugin, yy: plugin } }) }) + .toThrow('provide names rows outside the roster: zz, yy; roster: a, b') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts new file mode 100644 index 0000000000..7ae3666000 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts @@ -0,0 +1,93 @@ +/** Remote proxies: namespace discovery from roster injects and mock rules, and per-call routing over the Connection to the mock. */ +import { RemoteMock, frames, ok, openStream } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished } from 'vitest' +import type { ClientPluginModule } from '../src/assembly/index.ts' +import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' +import { remoteNamespacesOf } from '../src/assembly/remote-proxies.ts' + +/** The api-remotes row and its cone (Gateway client, Typert registry, Connection); TestClient drops the api-remotes row itself. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-remotes']) + +type RemoteFace = Record unknown>> + +async function drain(source: AsyncIterable): Promise { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} + +describe('remoteNamespacesOf', () => { + it('collects remote. injects in both inject forms plus the namespaces of registered endpoints, skipping Gateway-internal ones', () => { + const modules: ClientPluginModule[] = [ + { apply() {}, inject: ['slots', 'remote.settings', 'remote'] }, + { apply() {}, inject: { 'remote.session': { required: true }, connection: { required: false } } }, + { apply() {} }, + ] + const mock = RemoteMock.create().unary('goals/create', ok({ id: 'g' })).stream('workspace/follow', openStream()) + expect(remoteNamespacesOf(modules, mock)).toEqual(['goals', 'session', 'settings', 'workspace']) + }) +}) + +describe('remote proxies over a booted client', () => { + async function booted(configure: (mock: RemoteMock) => void = () => {}) { + const mock = RemoteMock.create().load(remoteDefaultResponses) + configure(mock) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + return { client, mock, remote: (client.ctx as unknown as { remote: RemoteFace }).remote } + } + + it('drops the api-remotes row and answers ctx.remote.. with the mock value, logging positional args', async () => { + const { client, mock, remote } = await booted((m) => { + m.unary('session/rename', ok({ title: 'renamed', seq: 3 })) + m.unary('session/cancel', { ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } }) + }) + expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain('@deepseek-ai/dsh-api-remotes') + await expect(remote.session!.rename!({ sessionId: 's1', title: 'renamed' })).resolves.toEqual({ ok: true, value: { title: 'renamed', seq: 3 } }) + await expect(remote.session!.cancel!({ sessionId: 's1' }, new AbortController().signal)) + .resolves.toEqual({ ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } }) + expect(mock.log.calls('session/rename').map(call => call.args)).toEqual([[{ sessionId: 's1', title: 'renamed' }]]) + expect(mock.log.calls('session/cancel').map(call => call.args)).toEqual([[{ sessionId: 's1' }]]) + expect((remote.session as unknown as { then?: unknown }).then).toBeUndefined() + expect((remote.session as unknown as Record)[Symbol.toStringTag]).toBeUndefined() + }, 60_000) + + it('folds a call without a rule into gateway/internal and leaves the miss in the log for dispose() to report', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const remote = (client.ctx as unknown as { remote: RemoteFace }).remote + await expect(remote.session!.search!({ query: 'x' })).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/internal', message: expect.stringMatching(/^client api: session\/search failed: remote-mock: no rule for session\/search; registered: \$events, /) as string }, + }) + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/search', mode: 'unary' }]) + await expect(client.dispose()).rejects.toThrow('session/search (unary)') + }) + + it('folds a rule rejection like the generated client: gateway/internal, or gateway/cancelled once the signal aborted', async () => { + const { mock, remote } = await booted((m) => { + m.unary('session/rename', () => Promise.reject(new Error('wire down'))) + m.unary('session/cancel', () => new Promise(() => {})) + }) + await expect(remote.session!.rename!({ sessionId: 's1', title: 't' })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: wire down' }, + }) + const controller = new AbortController() + const cancelling = remote.session!.cancel!({ sessionId: 's1' }, controller.signal) as Promise + controller.abort() + await expect(cancelling).resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) + expect(mock.log.calls('session/rename').map(call => call.state)).toEqual(['failed']) + expect(mock.log.calls('session/cancel').map(call => call.state)).toEqual(['pending']) + }) + + it('opens registered streams and hands their items and failures through unchanged', async () => { + const { remote } = await booted((m) => { + m.stream('session/follow', frames([{ type: 'a' }, { type: 'b' }])) + m.stream('session/control', ((_args, stream) => { stream.fail(new Error('flap')) })) + }) + const signal = new AbortController().signal + await expect(drain(remote.session!.follow!({ address: { kind: 'session', sessionId: 's1' } }, signal) as AsyncIterable)) + .resolves.toEqual([{ type: 'a' }, { type: 'b' }]) + await expect(drain(remote.session!.control!({}) as AsyncIterable)).rejects.toThrow('flap') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts new file mode 100644 index 0000000000..d1ccea2644 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts @@ -0,0 +1,112 @@ +/** ClientRoster: construction, duplicate refusal, order-preserving pick/closure/without with loud unknown names; graphFromRoster. */ +import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client' +import { describe, expect, it } from 'vitest' +import { ClientRoster, graphFromRoster, type ClientRosterRow } from '../src/assembly/roster.ts' +import { MODULES_PACKAGE } from '../src/assembly/modules.ts' + +const ROWS: readonly ClientRosterRow[] = [ + { name: MODULES_PACKAGE, inject: [], immediately: true }, + { name: '@x/a', inject: [], immediately: false }, + { name: '@x/b', inject: ['@x/a'], immediately: true }, +] + +describe('graphFromRoster', () => { + it('synthesizes one application batch over every row with placeholder URLs', () => { + expect(graphFromRoster(ROWS)).toEqual({ + rev: 'local', + entries: [ + { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, + { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' }, + { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, + ], + batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], + }) + }) + + it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => { + const manifest = parseBootManifest(graphFromRoster(ROWS)) + expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately }))) + expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js']) + const row = ROWS[1]! + expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"') + expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array') + }) + + it('imports seeded rows without reaching the bundle transport', async () => { + const loaded: string[] = [] + const pendingQueue: ClientBundleRegistration[] = [] + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue, + load: (registration) => { pendingQueue.push(registration) }, + create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: {} }, options), + } + const a = { apply() {} } + const b = { apply() {} } + const modules = target.create({ + boot: graphFromRoster(ROWS), + staticModules: { '@x/a': a, '@x/b': b }, + loadBundle: async (url) => { loaded.push(url) }, + }) + await expect(modules.import('@x/a')).resolves.toBe(a) + await expect(modules.import('@x/b')).resolves.toBe(b) + expect(modules.manifest).toEqual(parseBootManifest(graphFromRoster(ROWS))) + expect(loaded).toEqual([]) + }) +}) + +const A = { name: 'a', inject: [], immediately: true } +const B = { name: 'b', inject: ['a'], immediately: false } +const C = { name: 'c', inject: ['b'], immediately: false } + +describe('ClientRoster', () => { + it('freezes a copy of the rows in composition order', () => { + const rows = [A, B, C] + const roster = ClientRoster.of(rows) + expect(roster.rows).toEqual([A, B, C]) + expect(roster.rows).not.toBe(rows) + expect(Object.isFrozen(roster.rows)).toBe(true) + rows.push(A) + expect(roster.rows).toHaveLength(3) + }) + + it('refuses duplicate names, listing each duplicate once', () => { + expect(() => ClientRoster.of([A, B, A, C, B, A])).toThrow('duplicate roster rows: a, b') + }) + + it('pick keeps roster order regardless of the requested order', () => { + const roster = ClientRoster.of([A, B, C]) + expect(roster.pick(['c', 'a']).rows.map(row => row.name)).toEqual(['a', 'c']) + expect(roster.pick([]).rows).toEqual([]) + expect(Object.isFrozen(roster.pick(['b']).rows)).toBe(true) + }) + + it('without drops the named rows', () => { + const roster = ClientRoster.of([A, B, C]) + expect(roster.without(['b']).rows.map(row => row.name)).toEqual(['a', 'c']) + expect(roster.without(['a', 'b', 'c']).rows).toEqual([]) + }) + + it('closure keeps the named rows and everything they inject, transitively, in roster order', () => { + const D = { name: 'd', inject: [], immediately: false } + const roster = ClientRoster.of([A, D, B, C]) + expect(roster.closure(['c']).rows.map(row => row.name)).toEqual(['a', 'b', 'c']) + expect(roster.closure(['b', 'd']).rows.map(row => row.name)).toEqual(['a', 'd', 'b']) + expect(roster.closure(['a']).rows.map(row => row.name)).toEqual(['a']) + expect(roster.closure([]).rows).toEqual([]) + expect(Object.isFrozen(roster.closure(['c']).rows)).toBe(true) + }) + + it('pick, closure, and without throw on unknown names with the roster listed; closure also refuses an inject outside the roster', () => { + const roster = ClientRoster.of([A, B]) + expect(() => roster.pick(['a', 'zz'])).toThrow('pick() names outside the roster: zz; roster: a, b') + expect(() => roster.closure(['zz'])).toThrow('closure() names outside the roster: zz; roster: a, b') + expect(() => roster.without(['x', 'y'])).toThrow('without() names outside the roster: x, y; roster: a, b') + expect(() => ClientRoster.of([B]).closure(['b'])).toThrow('b injects a, which is outside the roster') + }) + + it('closure treats the platform seed modules as satisfied without a row', () => { + const seeded = { name: 'seeded', inject: ['@deepseek-ai/dsh-client-ui-primitives', 'react', 'a'], immediately: false } + expect(ClientRoster.of([A, seeded]).closure(['seeded']).rows.map(row => row.name)).toEqual(['a', 'seeded']) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts new file mode 100644 index 0000000000..26afee3272 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -0,0 +1,110 @@ +/** 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']) +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('releases the shared globals 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 row that fails to apply, releases the globals, and lets the next boot take its turn', async () => { + 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())) + .rejects.toThrow(/apply boom|typert-registry/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + 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('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => { + const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes']) + await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create())) + .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote. services are the tier\'s proxies') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + 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 before installing the transport', async () => { + await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true })) + .rejects.toThrow('mount requires a DOM') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('fails loud, then restores the transport, when the roster cannot provide a connection', async () => { + await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create())) + .rejects.toThrow('provides no `connection` service') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('skips readiness on request and restores a pre-existing transport on dispose', 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__).not.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) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts new file mode 100644 index 0000000000..788c640b7f --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +/** + * TestClient over the web profile's roster read from its bundles: production + * `bootClient` over in-process modules, every Remote call answered by a + * `RemoteMock` installed as the Connection carrier, mount, HMR-style reload, + * unload, and fail-loud teardown. + */ +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { AssemblyPlan, TestClientOptions } from '../src/assembly/index.ts' +import { ClientRoster, 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 SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +/** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */ +const SIDEBAR_SETTINGS = 'sidebar.settings' as never +const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' +const globals = globalThis as { __DSH_TRANSPORT__?: unknown; EventSource?: unknown; ResizeObserver?: unknown } +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 + +async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start(plan, mock, options) + onTestFinished(() => client.dispose()) + return client +} + +describe('TestClient (jsdom)', () => { + it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp }, mock, { mount: true }) + expect(client.connection.state.getSnapshot()).toBe('connected') + expect(mock.log.streams('$events')).toHaveLength(1) + const container = client.container! + expect(document.body.contains(container)).toBe(true) + expect(container.childElementCount).toBeGreaterThan(0) + expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect(globals.EventSource).toBeDefined() + expect(globals.ResizeObserver).toBeDefined() + await client.dispose() + expect(document.body.contains(container)).toBe(false) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + expect(globals.ResizeObserver).toBeUndefined() + await client.dispose() + }, COLD_BOOT_TIMEOUT_MS) + + it('leaves a pre-existing global alone and removes only the shims it installed', async () => { + const existing = { existing: true } + vi.stubGlobal('ResizeObserver', existing) + onTestFinished(() => { vi.unstubAllGlobals() }) + const client = await started({ roster: API_ROSTER }) + expect(globals.ResizeObserver).toBe(existing) + expect(globals.EventSource).toBeDefined() + await client.dispose() + expect(globals.ResizeObserver).toBe(existing) + expect(globals.EventSource).toBeUndefined() + }) + + it('boots overlapping clients one at a time onto their own mocks and keeps the shared globals until the last dispose', async () => { + const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 })) + const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 })) + const [a, b] = await Promise.all([ + TestClient.start({ roster: API_ROSTER }, mockA), + TestClient.start({ roster: API_ROSTER }, mockB), + ]) + onTestFinished(() => b.dispose()) + const rename = async (client: TestClient): Promise => + (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise } } }).remote.session.rename({ sessionId: 's', title: 't' }) + await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) + await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } }) + expect(mockA.log.calls('session/rename')).toHaveLength(1) + expect(mockB.log.calls('session/rename')).toHaveLength(1) + // A rebuilt connection row reads its own mock even after another client installed the transport last. + await a.reload('@deepseek-ai/dsh-client-connection') + await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') }) + await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) + expect(mockA.log.calls('session/rename')).toHaveLength(2) + expect(mockB.log.calls('session/rename')).toHaveLength(1) + await a.dispose() + expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect(globals.EventSource).toBeDefined() + await b.dispose() + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + }) + + it('boots the api subset without a mount and exposes the typed Remote', async () => { + const client = await started({ roster: API_ROSTER }) + expect(client.container).toBeUndefined() + expect(client.ctx.remote.session).toBeDefined() + expect(client.connection.state.getSnapshot()).toBe('connected') + }) + + it('refuses to mount a roster that provides no uiRenderer instead of returning an empty container', async () => { + const before = document.body.childElementCount + await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true })) + .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`') + expect(document.body.childElementCount).toBe(before) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('creates no mount element when the roster cannot be loaded', async () => { + const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }]) + const before = document.body.childElementCount + await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow() + expect(document.body.childElementCount).toBe(before) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('mounts into a caller-supplied element and leaves it in place on dispose', async () => { + const host = document.createElement('main') + document.body.appendChild(host) + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp }, mock, { mount: host }) + expect(client.container).toBe(host) + expect(host.childElementCount).toBeGreaterThan(0) + await client.dispose() + expect(document.body.contains(host)).toBe(true) + host.remove() + }) + + it('boots with a provided row in place of the real plugin', async () => { + const apply = vi.fn() + const client = await started({ roster: webApp, provide: { [BRAND]: { apply } } }, { mount: true }) + expect(apply).toHaveBeenCalledOnce() + expect([...client.ctx.loader.entries()].some(entry => entry.options.name === BRAND)).toBe(true) + }) + + it('reload rebuilds the declaring entry; unload collapses it', async () => { + const client = await started({ roster: webApp }, { mount: true }) + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) + await client.reload(SIDEBAR) + await client.flush() + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) + await client.unload(SIDEBAR) + await client.flush() + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0) + await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`) + }) + + it('fails loud by teardown at the latest when a boot-time endpoint has no fixture', async () => { + const mock = RemoteMock.create() + const run = TestClient.start({ roster: webApp }, mock, { mount: true }).then(client => client.dispose()) + await expect(run).rejects.toThrow(/session\/control|workspace\/follow|session\/list|settings\/describe/) + }) + + it('waits through a carrier flap for the connection to become ready', async () => { + let opens = 0 + const mock = RemoteMock.create().load(remoteDefaultResponses).stream('$events', (_args, stream) => { + opens += 1 + const first = opens === 1 + setTimeout(() => { + if (first) stream.fail(new Error('flap')) + // Branded on the Gateway side; the test mints a plain string. + else stream.push({ type: 'ready', clientId: 'reconnected' as never, host: { home: '/home/mock' } }) + }, 20) + }) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + expect(mock.log.streams('$events')).toHaveLength(2) + }) + + it('reports the log when the connection never becomes ready', async () => { + // No fixtures: workspace-controller's follow has no rule, so the proxy dispatches it as a unary call the mock + // logs as unmatched, while $events never sends ready. + const roster = webApp.closure(['@deepseek-ai/dsh-api-workspace-controller']) + const mock = RemoteMock.create().stream('$events', openStream([])) + await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 })) + .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts new file mode 100644 index 0000000000..8a31f305ad --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts @@ -0,0 +1,64 @@ +/** Native fixture cleanup remains active after assertion failure and without a started client. */ +import { afterAll, describe, expect } from 'vitest' +import { ok } from '@deepseek-ai/dsh-remote-mock' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createClientTest, webApp, type TestClient } from '../src/assembly/index.ts' + +const test = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) }) +const clients: TestClient[] = [] +const expired: (() => Promise)[] = [] + +afterAll(async () => { + expect(clients).toHaveLength(2) + for (const client of clients) { + expect(client.ctx.get('loader')).toBeUndefined() + expect(client.mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + } + for (const start of expired) { + await expect(start()).rejects.toThrow('after its test fixture closed') + } + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) +}) + +describe('createClientTest', () => { + test('allows response configuration before boot and shares concurrent starts', async ({ mock, remote, start }) => { + expired.push(start) + expect(remote).toBe(mock.remote) + remote.session.rename.mockResolvedValueOnce(ok({ title: 'test title', seq: 1 })) + const first = start() + const second = start() + expect(second).toBe(first) + const client = await first + clients.push(client) + expect(client.mock).toBe(mock) + expect(mock.log.streams('$events')).toHaveLength(1) + await expect(client.ctx.remote.session.rename({ sessionId: 's' as SessionId, title: 't' })) + .resolves.toEqual(ok({ title: 'test title', seq: 1 })) + expect(remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 's', title: 't' }) + }) + + test.fails('disposes its client even when the test assertion fails', async ({ start }) => { + expired.push(start) + clients.push(await start()) + expect.fail('fixture cleanup negative control') + }) + + test('does not boot an unused start fixture', ({ mock, start }) => { + expired.push(start) + expect(mock.log.streams()).toEqual([]) + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) + }) + + test('owns a fresh mock when only the mock fixture is requested', ({ mock }) => { + expect(mock.log.calls()).toEqual([]) + expect(mock.log.streams()).toEqual([]) + expect(mock.endpoints()).not.toContain('session/rename') + }) +}) + +const missingConnection = createClientTest({ roster: webApp.pick(['@deepseek-ai/dsh-typert-registry']) }) +missingConnection('keeps a rejected startup with its caller and releases its globals', async ({ start }) => { + expired.push(start) + await expect(start()).rejects.toThrow('provides no `connection` service') + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) +}) diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index bb297bb0b6..d7bbec5716 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -6,7 +6,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { TestRemote } from '../src/remote.ts' -import { scriptedSettingsRemote } from '../src/settings-remote.ts' describe('TestRemote', () => { it('delivers a forwarded event to its subscribers and stops after disposal', async () => { @@ -60,39 +59,3 @@ describe('TestRemote', () => { await ctx.fiber.dispose() }) }) - -describe('scriptedSettingsRemote', () => { - it('serves, writes, and replaces its scripted namespace list', async () => { - const first = { ns: 'first', revision: 1 } - const second = { ns: 'second', revision: 2 } - const remote = scriptedSettingsRemote([first]) - - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: true, hasDocument: false, namespaces: [first] }, - }) - await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first }) - await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({ - ok: false, - error: { code: 'settings/rejected', details: { ns: 'missing' } }, - }) - await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first }) - expect(remote.update).toHaveBeenCalledWith('first', {}, undefined) - expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined) - expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined) - - remote.publish([second]) - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: true, hasDocument: false, namespaces: [second] }, - }) - }) - - it('reports explicit deployment facts', async () => { - const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true }) - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: false, hasDocument: true, namespaces: [] }, - }) - }) -}) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index bc8fbf0688..d73de457a3 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": ["client-build-environment", "node"] }, "include": [ "src" @@ -11,6 +12,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../api/gateway/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, @@ -55,6 +65,21 @@ }, { "path": "../../subagent/subagent" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../client/modules" + }, + { + "path": "../../client/web" + }, + { + "path": "../remote-mock" + }, + { + "path": "../../client/hmr" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5bafb0d2a..ed003616c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3261,6 +3261,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3655,6 +3658,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-util-values': specifier: workspace:^ version: link:../../util/values @@ -9989,6 +9995,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -9996,6 +10005,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -10005,9 +10026,21 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-base': + specifier: workspace:^ + version: link:../../bundle/base '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-hmr': + specifier: workspace:^ + version: link:../../client/hmr + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store @@ -10032,15 +10065,30 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-web': + specifier: workspace:^ + version: link:../../client/web + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-web-app': + specifier: workspace:^ + version: link:../../bundle/web-app + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/tsconfig.base.json b/tsconfig.base.json index 6359e07899..245d733e82 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -249,6 +249,9 @@ // util/ folders are role-named without the util- prefix their npm names carry, // so these aliases stay hand-written like dsh-util-crypto above. "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], + // session-log-export lives under session-query/, so the generated aliases map only its + // bare name; the browser half needs this explicit subpath for source-mode dynamic imports. + "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"],