feat(test-support): assemble real clients with native test fixtures
This commit is contained in:
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+48
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user