feat(test-support): assemble real clients with native test fixtures

This commit is contained in:
imccyu committed 2026-09-10 16:34:29 +08:00
1 parent 48d83eca66
commit 627c0f741e
31 files changed
+2010 -191

No files matched your search

@@ -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:^",
@@ -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: {
@@ -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:^",
@@ -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)
@@ -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
@@ -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.<ns>` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.<ns>` 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.<ns>.<method>(...args)` becomes a call on the endpoint `<ns>/<method>` 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.<ns>` 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.<ns>` 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.
@@ -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.<ns>` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.<ns>` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote.<ns>.<method>(...args)` 变成对端点 `<ns>/<method>` 的调用,携带位置参数,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.<ns>` 代理:`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.<ns>` 代理转发位置参数,不经过生成的 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()` 都会让测试失败。
@@ -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"
},
@@ -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<string>()
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 `<name>/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)
@@ -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'
@@ -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<ReadonlyMap<string, ClientPluginModule>> {
const modules = new Map<string, ClientPluginModule>()
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<ClientPluginModule> {
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<string, ClientPluginModule>,
): 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<string, unknown> })
}
return target.create({
boot: graph,
staticModules: {},
loadBundle: url => Promise.reject(new Error(`client-test-runtime: in-process modules never load bundles (${url})`)),
})
}
@@ -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: [] } }]),
},
}
@@ -0,0 +1,87 @@
/**
* `remote.<ns>` services for the whole-client tier, without the generated
* Remote clients. Cordis resolves `ctx.remote.<ns>` to whichever service is
* registered under `remote.<ns>` (vendor cordis `utils.ts`, traceable get), so
* the carrier provides one Proxy per namespace: `remote.<ns>.<method>(...args)`
* calls the endpoint `<ns>/<method>` 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.<ns>` 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<ClientPluginModule>, mock: RemoteMock): readonly string[] {
const names = new Set<string>()
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<string, unknown>, {
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)))
}
},
})
}
@@ -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/<name>/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<string> = 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<string>()
const duplicates = new Set<string>()
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<string>()
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<string> {
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<Record<string, unknown>>
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<Record<string, ClientPluginModule>>
}
/**
* 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(', ')}`,
)
}
}
@@ -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<unknown> = Promise.resolve()
/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */
function takeTurn(work: () => Promise<void>): Promise<void> {
const turn = bootTurn.then(work)
bootTurn = turn.catch(() => undefined)
return turn
}
/** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
/**
* Browser globals jsdom lacks that roster plugins touch at apply or mount:
* client-hmr opens an `EventSource`, layout components observe element size.
* Inert stand-ins, installed only where the global is absent.
*/
const JSDOM_SHIMS: Readonly<Record<string, unknown>> = {
EventSource: class {
addEventListener(): void {}
close(): void {}
},
ResizeObserver: class {
observe(): void {}
disconnect(): void {}
},
}
/** Install a shim for each absent global; the disposer deletes exactly those. */
function installJsdomShims(): () => void {
const globals = globalThis as Record<string, unknown>
const installed = Object.keys(JSDOM_SHIMS).filter(name => globals[name] === undefined)
for (const name of installed) globals[name] = JSDOM_SHIMS[name]
return () => { for (const name of installed) Reflect.deleteProperty(globals, name) }
}
/** The Error a thrown value stands for: itself, or a new Error carrying its string form. */
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function messageOf(error: unknown): string {
return toError(error).message
}
function connectionOf(ctx: Context): ConnectionHandle {
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) {
throw new Error('client-test-runtime: the roster provides no `connection` service')
}
return connection
}
/** Mount point plus whether `start` created it (and so removes it on dispose). */
interface MountPoint {
readonly element: HTMLElement | undefined
readonly owned: boolean
}
function resolveMountPoint(mount: boolean | HTMLElement | undefined): MountPoint {
if (mount === undefined || mount === false) return { element: undefined, owned: false }
if (typeof document === 'undefined') {
throw new Error('client-test-runtime: mount requires a DOM; add `// @vitest-environment jsdom` to the spec')
}
if (mount === true) {
const element = document.createElement('div')
document.body.appendChild(element)
return { element, owned: true }
}
return { element: mount, owned: false }
}
/** Run `fn` inside React `act` when a DOM exists; plain await otherwise. */
async function settle(fn: () => Promise<void>): Promise<void> {
if (typeof document === 'undefined') {
await fn()
return
}
await act(async () => { await fn() })
}
async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise<void> {
const { state } = connectionOf(ctx)
if (state.getSnapshot() === 'connected') return
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
unsubscribe()
const unmatched = mock.log.unmatched().map(row => `${row.mode} ${row.endpoint}`)
const streams = mock.log.streams().map(row => `${row.endpoint} (${row.state})`)
reject(new Error(
`client-test-runtime: connection state is ${String(state.getSnapshot())} after ${timeoutMs}ms; `
+ `unmatched: [${unmatched.join(', ')}]; streams: [${streams.join(', ')}]`,
))
}, timeoutMs)
const unsubscribe = state.subscribe(() => {
if (state.getSnapshot() !== 'connected') return
clearTimeout(timer)
unsubscribe()
resolve()
})
})
}
/** A booted client under test. */
export class TestClient {
/**
* Load the roster's modules, 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.<ns>` services the roster injects (plus the
* namespaces the mock has rules for at this point) are provided as
* contract-free proxies over the same Connection instead; a `provide` entry
* for that row is refused. On any failure the context is disposed, an owned
* mount removed, and this client's hold on the globals released before the
* original error is rethrown.
* @param plan - roster and annotations.
* @param mock - Remote mock answering every Gateway call.
* @param options - mount and readiness options.
* @returns the booted client.
*/
static async start(
plan: AssemblyPlan,
mock: RemoteMock,
options: TestClientOptions = {},
): Promise<TestClient> {
assertPlan(plan)
if (plan.provide?.[REMOTES_PACKAGE] !== undefined) {
throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote.<ns> services are the tier's proxies`)
}
const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE)
? plan.roster.without([REMOTES_PACKAGE])
: plan.roster
const ctx = new Context()
const 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<void> | 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<void> {
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<void> {
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<void> {
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<void> {
if (this.disposing !== undefined) {
await this.disposing.catch(() => undefined)
return
}
this.disposing = this.teardown()
await this.disposing
}
private async teardown(): Promise<void> {
let failure: Error | undefined
try {
await settle(() => this.ctx.fiber.dispose())
} catch (error) {
failure = toError(error)
} finally {
this.restore()
}
if (failure === undefined) {
this.mock.assertNoUnmatched()
return
}
try {
this.mock.assertNoUnmatched()
} catch (unmatched) {
throw new Error(`${messageOf(unmatched)}\nclient-test-runtime: the plugin tree also failed to dispose: ${failure.message}`, { cause: failure })
}
throw failure
}
private entryOf(name: string): Entry {
const entries = [...this.ctx.loader.entries()]
const entry = entries.find(candidate => candidate.options.name === name)
if (entry === undefined) {
throw new Error(
`client-test-runtime: no Loader entry named ${name}; entries: ${entries.map(row => row.options.name).join(', ')}`,
)
}
return entry
}
}
@@ -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<TestClient>
}
/**
* Extend Vitest with a fresh mock and a lazy, automatically disposed client. Repeated `start()` calls share one
* promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection.
* Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned.
* @param plan - roster and replacement modules, shared as configuration rather than as a running client.
* @param options - mount 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<ClientTestFixtures> {
return test.extend<ClientTestFixtures>({
remote: async ({ mock }, use) => {
await use(mock.remote)
},
mock: async ({}, use) => {
const mock = RemoteMock.create().load(remoteDefaultResponses)
try {
await use(mock)
} finally {
mock.assertNoUnmatched()
}
},
start: async ({ mock }, use) => {
let pending: Promise<TestClient> | undefined
let closed = false
const start = (): Promise<TestClient> => {
if (closed) return Promise.reject(new Error('client-test-runtime: start() called after its test fixture closed'))
if (pending === undefined) {
pending = TestClient.start(plan, mock, options)
// Startup belongs to the caller; keep a rejection handler attached until teardown can await it.
void pending.catch(() => undefined)
}
return pending
}
try {
await use(start)
} finally {
closed = true
const client = await pending?.catch(() => undefined)
await client?.dispose()
}
},
})
}
@@ -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 {
@@ -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<View extends ScriptedNamespace> {
/**
* 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<typeof vi.fn>
/** Spy behind `settings.replace`, for argument assertions. */
replace: ReturnType<typeof vi.fn>
/** Spy behind `settings.mutate`, for argument assertions. */
mutate: ReturnType<typeof vi.fn>
/**
* 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<View extends ScriptedNamespace>(
namespaces: readonly View[] = [],
options: { writable?: boolean; hasDocument?: boolean } = {},
): ScriptedSettingsRemote<View> {
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 },
}
}
@@ -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<string, unknown>, files: Record<string, string> = {}): 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<string, unknown> = {}): 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')
})
})
@@ -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)
})
@@ -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)
})
})
@@ -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')
})
})
@@ -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<string, Record<string, (...args: unknown[]) => unknown>>
async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
const items: unknown[] = []
for await (const item of source) items.push(item)
return items
}
describe('remoteNamespacesOf', () => {
it('collects remote.<ns> 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.<ns>.<method> 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, unknown>)[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<unknown>
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<unknown>))
.resolves.toEqual([{ type: 'a' }, { type: 'b' }])
await expect(drain(remote.session!.control!({}) as AsyncIterable<unknown>)).rejects.toThrow('flap')
})
})
@@ -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'])
})
})
@@ -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.<ns> 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)
})
})
@@ -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<TestClient> {
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<unknown> =>
(client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise<unknown> } } }).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()
})
})
@@ -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<TestClient>)[] = []
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)
})
@@ -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: [] },
})
})
})
@@ -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"
}
]
}
+48
View File
@@ -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/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
js-yaml:
specifier: ^4.2.0
version: 4.3.1
vitest:
specifier: ^4.1.8
version: 4.1.8(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -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
+3
View File
@@ -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"],