@deepseek-ai/dsh-remote-mock 0.1.6-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +146 -0
- package/README.zh.md +146 -0
- package/lib/index.js +557 -0
- package/lib/types/index.d.ts +17 -0
- package/lib/types/log.d.ts +96 -0
- package/lib/types/remote-mock.d.ts +184 -0
- package/lib/types/remote-proxy.d.ts +15 -0
- package/lib/types/streams.d.ts +81 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/test-support/remote-mock/README.md
|
|
5
|
+
README.md: 82979e37052aecee6ec716702f634d25c969f5ad
|
|
6
|
+
README.zh.md: 201ca011410dd5a4d65b6ebdfb0c47f23742b0d3
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Endpoint-named mock for Typert Remote traffic: a table of unary answers and stream scripts, live stream control, a log, and the Connection carrier face, for test authors booting a real browser client without a Host."
|
|
3
|
+
kind: "package-library"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @deepseek-ai/dsh-remote-mock
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
`dsh-remote-mock` lets tests configure Host responses through `mock.remote.<namespace>.<method>` using native Vitest mock methods. The same functions answer direct calls and real Connection traffic; reusable tables supply default responses, and explicitly declared streams support test-driven frames and cancellation. Missing responses fail the call and are reported again by `assertNoUnmatched()` at teardown. The package runs without a business Host in Node or a browser page, imports no DOM, React, or Node modules, and is consumed from `devDependencies` only.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Model Experience](#model-experience)
|
|
19
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
20
|
+
- [Dev Note](#dev-note)
|
|
21
|
+
|
|
22
|
+
-----
|
|
23
|
+
|
|
24
|
+
<a id="use-this-package"></a>
|
|
25
|
+
## Use this package
|
|
26
|
+
|
|
27
|
+
### When to use it
|
|
28
|
+
|
|
29
|
+
Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client specs bind `mock.rpc` to their Connection instance, while unit specs may call `mock.remote`, `dispatch`, or `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`).
|
|
30
|
+
|
|
31
|
+
<a id="remote-proxy"></a>
|
|
32
|
+
### Use the Remote proxy
|
|
33
|
+
|
|
34
|
+
`mock.remote` exposes every namespace and method without a method list or a domain-specific helper. Each accessed endpoint uses a cached native Vitest mock; `@vitest/spy.fn` is the implementation behind `vi.fn` and also works in a browser page without the Vitest runner. The same mock answers direct calls and Connection traffic, so return overrides and call assertions observe the functions the client actually invokes:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
38
|
+
mock.remote.settings.describe.mockResolvedValue(ok({
|
|
39
|
+
writable: true, hasDocument: false, namespaces: [],
|
|
40
|
+
}))
|
|
41
|
+
mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace))
|
|
42
|
+
// After the client writes:
|
|
43
|
+
expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use `mockResolvedValue` for a persistent response, `mockResolvedValueOnce` or `mockReturnValueOnce` for queued responses, and `mockImplementation` for argument-dependent behavior. Native queued responses run in registration order, then the mock's current implementation answers. Its initial implementation reads the registered default. `mockClear()` preserves responses and queues; `mockReset()` clears overrides and restores that initial implementation, which reads the latest default. Missing defaults still fail after queued responses are exhausted, including direct calls, which can throw synchronously.
|
|
47
|
+
|
|
48
|
+
Only explicit `stream()` or table stream declarations select stream methods; everything else uses unary mocks. Accessing a method does not fabricate a successful business result. Declare stream mode before retaining a method reference: each endpoint/mode owns its own mock. Namespace and method `then` probes and symbol reads are inert.
|
|
49
|
+
|
|
50
|
+
`MockedRemote` uses Vitest's deep mock transformation over the entire generated `TypertRemoteNamespaceMap`. A non-empty map preserves its namespace and method names, parameters, results, and native spy types. An empty map makes only this test proxy `any`, allowing arbitrary namespaces and methods; it does not augment or weaken production Remote declarations. No copied method signatures, optional generated-module suppressions, or compiler-wide flags are needed. Before handing off Remote/mock changes, run `pnpm run typecheck` to generate and check the real Client types; missing, stale, or partial declarations require rebuilding first. Unbuilt test success or `any` inference is not strict type evidence.
|
|
51
|
+
|
|
52
|
+
### Register default responses
|
|
53
|
+
|
|
54
|
+
`load(table)` installs reusable `unary` values or handlers, `stream` scripts, and scriptless `streams` declarations. `unary(endpoint, value)` and `unary(endpoint, fn)` register individual defaults; handlers receive the caller's positional arguments and use existing request types. Each endpoint holds only its latest default, including an explicit `undefined`; updating defaults does not discard native overrides. `ok(value)` builds `{ ok: true, value }`; failures use `{ ok: false, error: { code, message, details } }`. Keep stateful handlers, promises, and native queues local to each test:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
const initial = { writable: true, hasDocument: false, namespaces: [] }
|
|
58
|
+
const mock = RemoteMock.create().load({
|
|
59
|
+
unary: { 'settings/describe': ok(initial) },
|
|
60
|
+
})
|
|
61
|
+
mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true }))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Drive streams
|
|
65
|
+
|
|
66
|
+
A stream script is a function of the open `args` and a `StreamHandle` (`push`, `end`, `fail(error)`, `signal`); the stream stays open after the script returns until the handle ends or fails it. `frames(items)` builds a script that yields the items and ends; `openStream(initial)` one that yields them and stays open. `mock.streams` controls streams the client currently holds open, filtered by the args they were opened with; `opened(endpoint, count)` resolves once the endpoint has been opened that many times, and `drained(endpoint)` once the consumer of every matching stream has pulled everything pushed so far, an open stream's consumer waiting for more and a settled stream's queue empty (pulled from the queue; that equals processed only for a consumer that handles items inside its read loop):
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
mock.stream('session/follow', openStream([snapshotFrame]))
|
|
70
|
+
await mock.streams.opened('session/follow', 1)
|
|
71
|
+
mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID)
|
|
72
|
+
mock.streams.fail('session/follow', new Error('gone'))
|
|
73
|
+
await mock.streams.drained('session/follow')
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`.
|
|
77
|
+
|
|
78
|
+
### Connect a client
|
|
79
|
+
|
|
80
|
+
`mock.rpc` is the `ClientConnectionRpc` face. Pass it as `{ transport: { rpc: mock.rpc } }` to the Connection installer, or let `TestClient` bind it to its instance, so every Remote call reaches `dispatch` and every stream reaches `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream.
|
|
81
|
+
|
|
82
|
+
### Observe and assert
|
|
83
|
+
|
|
84
|
+
`mock.log.calls(endpoint?)` lists unary calls through `dispatch` or `rpc.call` (`args`, `seq`, a live `state` of `pending` / `answered` / `failed`, and the answer or thrown error as `result`), `streams(endpoint?)` lists scripted opens with their live `state` and `pushed` count, `requests(endpoint?)` lists the first positional arg of calls and opens in order (without an endpoint, excluding the Gateway's own `$`-prefixed endpoints), and `unmatched()` lists requests that found no rule. Native `.mock.calls` additionally includes direct proxy calls; carrier stream mocks receive their final cancellation signal. `assertNoUnmatched()` reports misses at teardown. `modeOf(endpoint)` reports explicit registrations; `endpoints()` also includes accessed proxy methods so assembly can provide their namespaces.
|
|
85
|
+
|
|
86
|
+
### What can go wrong
|
|
87
|
+
|
|
88
|
+
- **A request has no rule** — `dispatch` rejects and `open` throws `remote-mock: no rule for <endpoint>; registered: …`, and the log records the miss; register the endpoint.
|
|
89
|
+
- **A payload is not `{ args: unknown[] | object }`** — `rpc.call` rejects and `rpc.open` throws a `TypeError`; the whole-client proxies send the array form and the Gateway's own endpoints the object form, so a hand-written call is at fault.
|
|
90
|
+
- **A second concurrent read on one stream** — the read rejects; the Gateway reads streams sequentially, so this names a test-side misuse.
|
|
91
|
+
|
|
92
|
+
-----
|
|
93
|
+
|
|
94
|
+
<a id="understand-the-implementation"></a>
|
|
95
|
+
## Understand the implementation
|
|
96
|
+
|
|
97
|
+
<details>
|
|
98
|
+
<summary>Implementation internals — click to expand</summary>
|
|
99
|
+
|
|
100
|
+
### Design
|
|
101
|
+
|
|
102
|
+
`dispatch` and `open` take the endpoint and positional args; `rpc` exposes the same core through Connection's decoded carrier interface. Each mock owns its native functions and queued overrides; shared tables supply defaults without copying handlers or answer objects. Each scripted stream owns its queue, its single pending read, and its log entry; the first of `end`, `fail`, or consumer cancellation settles it.
|
|
103
|
+
|
|
104
|
+
### Source map
|
|
105
|
+
|
|
106
|
+
| File | Role |
|
|
107
|
+
|---|---|
|
|
108
|
+
| [`src/index.ts`](src/index.ts) | Public face re-exports |
|
|
109
|
+
| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`: default responses, native mocks, Connection dispatch, controlled streams and missing-response checks; `ok` |
|
|
110
|
+
| [`src/remote-proxy.ts`](src/remote-proxy.ts) | Namespace/method lookup and generated-map mock types |
|
|
111
|
+
| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts and `MockStream` (handle + `AsyncIterable`) |
|
|
112
|
+
| [`src/log.ts`](src/log.ts) | Log store with the shared `seq` counter |
|
|
113
|
+
| — | No runtime invariant companion is published; this test-support library owns no production event stream or mutable process state, and its behavior is exercised by its package tests. |
|
|
114
|
+
|
|
115
|
+
</details>
|
|
116
|
+
|
|
117
|
+
-----
|
|
118
|
+
|
|
119
|
+
<a id="model-experience"></a>
|
|
120
|
+
## Model Experience
|
|
121
|
+
|
|
122
|
+
None, as this package is browser-side test infrastructure; nothing here reaches a model request.
|
|
123
|
+
|
|
124
|
+
#### KV Cache effect
|
|
125
|
+
|
|
126
|
+
None; this package neither assembles nor sends a provider request.
|
|
127
|
+
|
|
128
|
+
## Known Limitations and Deferred Work
|
|
129
|
+
|
|
130
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
131
|
+
|
|
132
|
+
- **In-process carrier only** — `rpc` serves a Connection instance in the same realm; no HTTP or WebSocket carrier for browser-lane specs is provided.
|
|
133
|
+
- **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged.
|
|
134
|
+
- **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields.
|
|
135
|
+
- **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler.
|
|
136
|
+
- **Native stream overrides own their iterable** — an override returning its own iterable bypasses scripted-stream logs, `requests`, `opened`, `drained`, and `push` / `end` / `fail`; the caller also owns cancellation. Native call assertions still work. Use a registered stream script when a scenario needs those controls.
|
|
137
|
+
|
|
138
|
+
<a id="dev-note"></a>
|
|
139
|
+
### Dev Note
|
|
140
|
+
|
|
141
|
+
<details>
|
|
142
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
143
|
+
|
|
144
|
+
None.
|
|
145
|
+
|
|
146
|
+
</details>
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Typert Remote 流量的端点具名 mock:一元应答与流脚本的表、活流控制、日志与 Connection 载体面,供测试作者在没有 Host 的情况下启动真实浏览器客户端。"
|
|
3
|
+
kind: "package-library"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @deepseek-ai/dsh-remote-mock
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
`dsh-remote-mock` 让测试通过 `mock.remote.<namespace>.<method>`,使用原生 Vitest mock 方法配置 Host 响应。同一组函数应答直接调用与真实 Connection 流量;可复用的表提供默认响应,显式声明的流支持测试驱动的推帧与取消。缺少响应时调用失败,`assertNoUnmatched()` 会在收尾时再次报告。本包无需业务 Host 即可在 Node 或浏览器页面中运行,不导入 DOM、React 或 Node 模块,只从 `devDependencies` 消费。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [模型体验](#model-experience)
|
|
19
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
20
|
+
- [开发备注](#dev-note)
|
|
21
|
+
|
|
22
|
+
-----
|
|
23
|
+
|
|
24
|
+
<a id="use-this-package"></a>
|
|
25
|
+
## 使用本包
|
|
26
|
+
|
|
27
|
+
### 何时使用
|
|
28
|
+
|
|
29
|
+
当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:整体客户端测试把 `mock.rpc` 绑定到各自的 Connection 实例,单元测试也可以直接调用 `mock.remote`、`dispatch` 或 `open`。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。
|
|
30
|
+
|
|
31
|
+
<a id="remote-proxy"></a>
|
|
32
|
+
### 使用 Remote Proxy
|
|
33
|
+
|
|
34
|
+
`mock.remote` 无需方法清单或领域专属 Helper 即可提供每个命名空间和方法。每个访问过的端点使用缓存的原生 Vitest mock;`@vitest/spy.fn` 就是 `vi.fn` 背后的实现,也能在没有 Vitest runner 的浏览器页面中运行。同一个 mock 应答直接调用与 Connection 流量,因此返回值覆盖和调用断言观察的是客户端实际调用的函数:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
|
38
|
+
mock.remote.settings.describe.mockResolvedValue(ok({
|
|
39
|
+
writable: true, hasDocument: false, namespaces: [],
|
|
40
|
+
}))
|
|
41
|
+
mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace))
|
|
42
|
+
// After the client writes:
|
|
43
|
+
expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
使用 `mockResolvedValue` 设置持续响应,使用 `mockResolvedValueOnce` 或 `mockReturnValueOnce` 排队设置响应,使用 `mockImplementation` 按参数决定行为。原生队列按登记顺序消费响应,耗尽后由 mock 的当前实现应答;初始实现读取已登记的默认响应。`mockClear()` 保留响应与队列;`mockReset()` 清除覆盖并恢复初始实现,由它读取最新默认响应。若未配置默认响应,排队响应耗尽后仍会失败,包括可能同步抛错的直接调用。
|
|
47
|
+
|
|
48
|
+
只有显式 `stream()` 或响应表中的流声明才选择流方法;其余均使用一元 mock。访问方法不会凭空构造成功的业务结果。保存方法引用前先声明流模式:每个端点/模式拥有各自的 mock。命名空间和方法的 `then` 探测及 symbol 读取均无副作用。
|
|
49
|
+
|
|
50
|
+
`MockedRemote` 使用 Vitest 的深层 mock 类型转换,覆盖完整生成的 `TypertRemoteNamespaceMap`。非空映射保留命名空间与方法名、参数、返回值和原生 spy 类型。映射为空时只有这个测试 Proxy 变成 `any`,允许任意命名空间和方法;它不增补或弱化生产 Remote 声明。不需要复制方法签名、抑制可选生成模块错误或开启编译器级全局 Flag。交付 Remote/mock 改动前运行 `pnpm run typecheck`,生成并检查真实 Client 类型;声明缺失、陈旧或不完整时先重新构建。无构建测试通过或推断为 `any` 都不是严格类型证据。
|
|
51
|
+
|
|
52
|
+
### 登记默认响应
|
|
53
|
+
|
|
54
|
+
`load(table)` 安装可复用的 `unary` 值或 handler、`stream` 脚本以及无脚本的 `streams` 声明。`unary(endpoint, value)` 与 `unary(endpoint, fn)` 登记单个默认响应;handler 接收调用方的位置参数,并使用现有请求类型。每个端点仅保存最新默认响应,包括显式 `undefined`;更新默认响应不会清除原生覆盖。`ok(value)` 构造 `{ ok: true, value }`;失败使用 `{ ok: false, error: { code, message, details } }`。有状态 handler、promise 与原生队列均由各测试独立持有:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
const initial = { writable: true, hasDocument: false, namespaces: [] }
|
|
58
|
+
const mock = RemoteMock.create().load({
|
|
59
|
+
unary: { 'settings/describe': ok(initial) },
|
|
60
|
+
})
|
|
61
|
+
mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true }))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 驾驭流
|
|
65
|
+
|
|
66
|
+
流脚本是一个接收打开时的 `args` 与 `StreamHandle`(`push`、`end`、`fail(error)`、`signal`)的函数;脚本返回后流保持打开,直到句柄结束或失败。`frames(items)` 构造吐完即结束的脚本,`openStream(initial)` 构造吐完后保持打开的脚本。`mock.streams` 控制客户端当前打开着的流,可按打开时的参数过滤;`opened(endpoint, count)` 在该端点被打开达到该次数时 resolve,`drained(endpoint)` 在每条匹配流的消费方都拉完了迄今推入的全部内容时 resolve——打开的流要其消费方再次等待,已定局的流要队列已空(拉完指从队列取走;只有在读循环内处理项的消费方才等于处理完):
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
mock.stream('session/follow', openStream([snapshotFrame]))
|
|
70
|
+
await mock.streams.opened('session/follow', 1)
|
|
71
|
+
mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID)
|
|
72
|
+
mock.streams.fail('session/follow', new Error('gone'))
|
|
73
|
+
await mock.streams.drained('session/follow')
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。
|
|
77
|
+
|
|
78
|
+
### 接上客户端
|
|
79
|
+
|
|
80
|
+
`mock.rpc` 是 `ClientConnectionRpc` 面。把它作为 `{ transport: { rpc: mock.rpc } }` 传给 Connection 安装函数,或让 `TestClient` 将其绑定到自身实例,每次 Remote 调用就会直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。
|
|
81
|
+
|
|
82
|
+
### 观察与断言
|
|
83
|
+
|
|
84
|
+
`mock.log.calls(endpoint?)` 列出经 `dispatch` 或 `rpc.call` 的一元调用(`args`、`seq`、实时 `state` 为 `pending` / `answered` / `failed`,以及作为 `result` 的应答值或抛出的错误),`streams(endpoint?)` 列出脚本流的打开记录及其实时 `state` 与 `pushed` 计数,`requests(endpoint?)` 按顺序列出调用与打开的首个位置参数(不带端点时去掉 Gateway 自己带 `$` 前缀的端点),`unmatched()` 列出没找到规则的请求。原生 `.mock.calls` 还包含直接 Proxy 调用;载体的流 mock 会收到末尾的取消信号。`assertNoUnmatched()` 在收尾时报告漏配。`modeOf(endpoint)` 报告显式登记;`endpoints()` 还包含访问过的 Proxy 方法,使装配能够提供它们的命名空间。
|
|
85
|
+
|
|
86
|
+
### 可能出什么问题
|
|
87
|
+
|
|
88
|
+
- **请求没有规则**——`dispatch` reject、`open` 抛出 `remote-mock: no rule for <endpoint>; registered: …`,日志记下这次漏配;请登记该端点。
|
|
89
|
+
- **payload 不是 `{ args: unknown[] | object }`**——`rpc.call` reject、`rpc.open` 抛 `TypeError`;整机代理发数组形式、Gateway 自身端点发对象形式,所以问题出在手写调用。
|
|
90
|
+
- **同一条流上有第二个并发读取**——该读取 reject;Gateway 顺序读取流,因此这指向测试侧误用。
|
|
91
|
+
|
|
92
|
+
-----
|
|
93
|
+
|
|
94
|
+
<a id="understand-the-implementation"></a>
|
|
95
|
+
## 理解实现
|
|
96
|
+
|
|
97
|
+
<details>
|
|
98
|
+
<summary>实现细节——点击展开</summary>
|
|
99
|
+
|
|
100
|
+
### 设计
|
|
101
|
+
|
|
102
|
+
`dispatch` 与 `open` 接收端点与位置参数;`rpc` 通过 Connection 的已解码载体接口暴露同一个核心。每个 mock 独立持有原生函数及排队覆盖;共享表提供默认响应,不复制 handler 或应答对象。每条脚本流拥有自己的队列、唯一挂起读取和日志条目;`end`、`fail`、消费方取消三者中最先发生者定局。
|
|
103
|
+
|
|
104
|
+
### 源码地图
|
|
105
|
+
|
|
106
|
+
| 文件 | 职责 |
|
|
107
|
+
|---|---|
|
|
108
|
+
| [`src/index.ts`](src/index.ts) | 公开面转出 |
|
|
109
|
+
| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`:默认响应、原生 mock、Connection 分发、受控流与缺失响应检查;`ok` |
|
|
110
|
+
| [`src/remote-proxy.ts`](src/remote-proxy.ts) | 命名空间/方法查找与生成映射的 mock 类型 |
|
|
111
|
+
| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本与 `MockStream`(句柄 + `AsyncIterable`) |
|
|
112
|
+
| [`src/log.ts`](src/log.ts) | 带共享 `seq` 计数器的日志 |
|
|
113
|
+
| — | 不发布运行时不变量伴生件;本测试支持库不拥有任何生产事件流或可变进程状态,其行为由本包测试覆盖。 |
|
|
114
|
+
|
|
115
|
+
</details>
|
|
116
|
+
|
|
117
|
+
-----
|
|
118
|
+
|
|
119
|
+
<a id="model-experience"></a>
|
|
120
|
+
## 模型体验
|
|
121
|
+
|
|
122
|
+
无;本包是浏览器侧测试基础设施,无一物到达模型请求。
|
|
123
|
+
|
|
124
|
+
#### KV Cache 影响
|
|
125
|
+
|
|
126
|
+
无;本包既不组装也不发送提供方请求。
|
|
127
|
+
|
|
128
|
+
## 已知限制与延期工作
|
|
129
|
+
|
|
130
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
131
|
+
|
|
132
|
+
- **仅进程内载体**——`rpc` 服务同一 realm 中的 Connection 实例;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。
|
|
133
|
+
- **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。
|
|
134
|
+
- **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。
|
|
135
|
+
- **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。
|
|
136
|
+
- **原生流覆盖自行管理 iterable**——覆盖返回自有 iterable 时,不参与脚本流日志、`requests`、`opened`、`drained` 以及 `push` / `end` / `fail`;调用方也负责取消。原生调用断言仍然有效。需要这些控制能力的场景应使用已登记的流脚本。
|
|
137
|
+
|
|
138
|
+
<a id="dev-note"></a>
|
|
139
|
+
### 开发备注
|
|
140
|
+
|
|
141
|
+
<details>
|
|
142
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
143
|
+
|
|
144
|
+
无。
|
|
145
|
+
|
|
146
|
+
</details>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { fn } from "@vitest/spy";
|
|
2
|
+
//#region lib/types/log.js
|
|
3
|
+
/** What a `RemoteMock` saw: answered calls, opened streams, and requests without a rule. */
|
|
4
|
+
/** The mock's log store. */
|
|
5
|
+
var MockLogStore = class {
|
|
6
|
+
callEntries = [];
|
|
7
|
+
streamEntries = [];
|
|
8
|
+
missEntries = [];
|
|
9
|
+
seq = 0;
|
|
10
|
+
calls(endpoint) {
|
|
11
|
+
return this.callEntries.filter((entry) => endpoint === void 0 || entry.endpoint === endpoint);
|
|
12
|
+
}
|
|
13
|
+
streams(endpoint) {
|
|
14
|
+
return this.streamEntries.filter((entry) => endpoint === void 0 || entry.endpoint === endpoint);
|
|
15
|
+
}
|
|
16
|
+
requests(endpoint) {
|
|
17
|
+
return [...this.calls(endpoint), ...this.streams(endpoint)].filter((entry) => endpoint !== void 0 || !entry.endpoint.startsWith("$")).sort((a, b) => a.seq - b.seq).map((entry) => entry.args[0]);
|
|
18
|
+
}
|
|
19
|
+
unmatched() {
|
|
20
|
+
return [...this.missEntries];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Record one call whose rule was selected.
|
|
24
|
+
* @param endpoint - endpoint.
|
|
25
|
+
* @param args - positional args.
|
|
26
|
+
* @returns the mutable entry the dispatcher settles.
|
|
27
|
+
*/
|
|
28
|
+
call(endpoint, args) {
|
|
29
|
+
const record = {
|
|
30
|
+
endpoint,
|
|
31
|
+
args,
|
|
32
|
+
state: "pending",
|
|
33
|
+
result: void 0,
|
|
34
|
+
seq: ++this.seq
|
|
35
|
+
};
|
|
36
|
+
this.callEntries.push(record);
|
|
37
|
+
return record;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Record one stream open.
|
|
41
|
+
* @param endpoint - endpoint.
|
|
42
|
+
* @param args - positional args.
|
|
43
|
+
* @returns the mutable entry the stream updates.
|
|
44
|
+
*/
|
|
45
|
+
stream(endpoint, args) {
|
|
46
|
+
const record = {
|
|
47
|
+
endpoint,
|
|
48
|
+
args,
|
|
49
|
+
state: "open",
|
|
50
|
+
pushed: 0,
|
|
51
|
+
seq: ++this.seq
|
|
52
|
+
};
|
|
53
|
+
this.streamEntries.push(record);
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Record one request without a rule.
|
|
58
|
+
* @param endpoint - endpoint.
|
|
59
|
+
* @param mode - how it was requested.
|
|
60
|
+
*/
|
|
61
|
+
miss(endpoint, mode) {
|
|
62
|
+
this.missEntries.push({
|
|
63
|
+
endpoint,
|
|
64
|
+
mode
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region lib/types/streams.js
|
|
70
|
+
/** Stream scripts and the pushable, abort-aware stream a script drives. */
|
|
71
|
+
/**
|
|
72
|
+
* Script yielding `items`, then ending.
|
|
73
|
+
* @param items - items in order.
|
|
74
|
+
* @returns the script.
|
|
75
|
+
*/
|
|
76
|
+
function frames(items) {
|
|
77
|
+
return (_args, stream) => {
|
|
78
|
+
for (const item of items) stream.push(item);
|
|
79
|
+
stream.end();
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Script yielding `initial`, then staying open for `streams.push`.
|
|
84
|
+
* @param initial - items yielded on open.
|
|
85
|
+
* @returns the script.
|
|
86
|
+
*/
|
|
87
|
+
function openStream(initial = []) {
|
|
88
|
+
return (_args, stream) => {
|
|
89
|
+
for (const item of initial) stream.push(item);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** One open stream: a queue the script pushes into and a single consumer reads from; the log entry tracks its state. */
|
|
93
|
+
var MockStream = class {
|
|
94
|
+
record;
|
|
95
|
+
sourceSignal;
|
|
96
|
+
queue = [];
|
|
97
|
+
settled;
|
|
98
|
+
waiting;
|
|
99
|
+
drainWaiters = [];
|
|
100
|
+
cancellation = new AbortController();
|
|
101
|
+
signal = this.cancellation.signal;
|
|
102
|
+
/** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */
|
|
103
|
+
onAbort = () => {
|
|
104
|
+
this.cancel(this.sourceSignal.reason);
|
|
105
|
+
};
|
|
106
|
+
/** Items pushed but not yet pulled by the consumer. */
|
|
107
|
+
get queued() {
|
|
108
|
+
return this.queue.length;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* @param record - log entry this stream updates.
|
|
112
|
+
* @param sourceSignal - cancellation from the caller that opened the stream.
|
|
113
|
+
*/
|
|
114
|
+
constructor(record, sourceSignal) {
|
|
115
|
+
this.record = record;
|
|
116
|
+
this.sourceSignal = sourceSignal;
|
|
117
|
+
if (sourceSignal.aborted) this.cancel(sourceSignal.reason);
|
|
118
|
+
else sourceSignal.addEventListener("abort", this.onAbort, { once: true });
|
|
119
|
+
}
|
|
120
|
+
push(item) {
|
|
121
|
+
if (this.record.state !== "open") return;
|
|
122
|
+
this.record.pushed += 1;
|
|
123
|
+
if (this.waiting !== void 0) {
|
|
124
|
+
const { resolve } = this.waiting;
|
|
125
|
+
this.waiting = void 0;
|
|
126
|
+
resolve({
|
|
127
|
+
value: item,
|
|
128
|
+
done: false
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.queue.push(item);
|
|
133
|
+
}
|
|
134
|
+
end() {
|
|
135
|
+
this.settle({ kind: "end" }, "ended");
|
|
136
|
+
}
|
|
137
|
+
fail(error) {
|
|
138
|
+
this.settle({
|
|
139
|
+
kind: "fail",
|
|
140
|
+
error
|
|
141
|
+
}, "failed");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Start `script` on this stream.
|
|
145
|
+
* @param script - the registered script.
|
|
146
|
+
* @param args - open args.
|
|
147
|
+
*/
|
|
148
|
+
run(script, args) {
|
|
149
|
+
let outcome;
|
|
150
|
+
try {
|
|
151
|
+
outcome = script(args, this);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.fail(toError(error));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (outcome instanceof Promise) outcome.catch((error) => {
|
|
157
|
+
this.fail(toError(error));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve once the consumer has pulled every queued item and waits for the next one, or the stream is no longer
|
|
162
|
+
* open and holds nothing the consumer could still pull (a consumer that returns discards what it left).
|
|
163
|
+
* @returns settles when drained.
|
|
164
|
+
*/
|
|
165
|
+
drained() {
|
|
166
|
+
if (this.isDrained()) return Promise.resolve();
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
this.drainWaiters.push(resolve);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
[Symbol.asyncIterator]() {
|
|
172
|
+
return {
|
|
173
|
+
next: () => this.next(),
|
|
174
|
+
return: () => {
|
|
175
|
+
this.cancel();
|
|
176
|
+
return Promise.resolve({
|
|
177
|
+
value: void 0,
|
|
178
|
+
done: true
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
next() {
|
|
184
|
+
const result = this.pull();
|
|
185
|
+
this.wakeDrained();
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
pull() {
|
|
189
|
+
if (this.queue.length > 0) return Promise.resolve({
|
|
190
|
+
value: this.queue.shift(),
|
|
191
|
+
done: false
|
|
192
|
+
});
|
|
193
|
+
if (this.settled !== void 0) return this.finish(this.settled);
|
|
194
|
+
if (this.record.state === "cancelled") return Promise.resolve({
|
|
195
|
+
value: void 0,
|
|
196
|
+
done: true
|
|
197
|
+
});
|
|
198
|
+
if (this.waiting !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`remote-mock: ${this.record.endpoint} stream has one consumer`));
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
this.waiting = {
|
|
201
|
+
resolve,
|
|
202
|
+
reject
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
finish(settled) {
|
|
207
|
+
return settled.kind === "end" ? Promise.resolve({
|
|
208
|
+
value: void 0,
|
|
209
|
+
done: true
|
|
210
|
+
}) : Promise.reject(settled.error);
|
|
211
|
+
}
|
|
212
|
+
settle(settled, state) {
|
|
213
|
+
if (this.record.state !== "open") return;
|
|
214
|
+
this.record.state = state;
|
|
215
|
+
this.settled = settled;
|
|
216
|
+
this.sourceSignal.removeEventListener("abort", this.onAbort);
|
|
217
|
+
this.wakeDrained();
|
|
218
|
+
const waiting = this.waiting;
|
|
219
|
+
if (waiting === void 0) return;
|
|
220
|
+
this.waiting = void 0;
|
|
221
|
+
this.finish(settled).then(waiting.resolve, waiting.reject);
|
|
222
|
+
}
|
|
223
|
+
cancel(reason) {
|
|
224
|
+
if (this.record.state === "open") {
|
|
225
|
+
this.record.state = "cancelled";
|
|
226
|
+
this.sourceSignal.removeEventListener("abort", this.onAbort);
|
|
227
|
+
const waiting = this.waiting;
|
|
228
|
+
this.waiting = void 0;
|
|
229
|
+
this.queue.length = 0;
|
|
230
|
+
this.cancellation.abort(reason);
|
|
231
|
+
waiting?.resolve({
|
|
232
|
+
value: void 0,
|
|
233
|
+
done: true
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
this.queue.length = 0;
|
|
237
|
+
this.wakeDrained();
|
|
238
|
+
}
|
|
239
|
+
isDrained() {
|
|
240
|
+
return this.queue.length === 0 && (this.waiting !== void 0 || this.record.state !== "open");
|
|
241
|
+
}
|
|
242
|
+
wakeDrained() {
|
|
243
|
+
if (!this.isDrained()) return;
|
|
244
|
+
const waiters = this.drainWaiters;
|
|
245
|
+
this.drainWaiters = [];
|
|
246
|
+
for (const resolve of waiters) resolve();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* The `Error` a thrown value stands for: itself, or a new Error carrying its string form.
|
|
251
|
+
* @param reason - thrown value.
|
|
252
|
+
* @returns the error.
|
|
253
|
+
*/
|
|
254
|
+
function toError(reason) {
|
|
255
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region lib/types/remote-proxy.js
|
|
259
|
+
/**
|
|
260
|
+
* Cache accessed namespaces and expose the current native mock for each method.
|
|
261
|
+
* @param method - selects a stable endpoint mock for its configured invocation mode.
|
|
262
|
+
* @returns enumerable accessed namespaces and methods; symbols and thenable probes remain inert.
|
|
263
|
+
*/
|
|
264
|
+
function createRemoteProxy(method) {
|
|
265
|
+
return new Proxy(Object.create(null), { get(namespaces, namespace) {
|
|
266
|
+
if (typeof namespace !== "string" || namespace === "then") return void 0;
|
|
267
|
+
return namespaces[namespace] ??= new Proxy(Object.create(null), { get(methods, name) {
|
|
268
|
+
if (typeof name !== "string" || name === "then") return void 0;
|
|
269
|
+
return methods[name] = method(`${namespace}/${name}`);
|
|
270
|
+
} });
|
|
271
|
+
} });
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region lib/types/remote-mock.js
|
|
275
|
+
/** `RemoteMock`: an endpoint table (unary answers or stream scripts), live stream control, a log, and the Connection carrier face. */
|
|
276
|
+
const EVENTS_ENDPOINT = "$events";
|
|
277
|
+
/**
|
|
278
|
+
* The success envelope the Client's Remote callers read: `{ ok: true, value }`.
|
|
279
|
+
* @param value - success value.
|
|
280
|
+
* @returns the envelope.
|
|
281
|
+
*/
|
|
282
|
+
function ok(value) {
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
value
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** A missing default answer is an unmatched request, not a selected rule that failed. */
|
|
289
|
+
var MissingUnaryRule = class extends Error {};
|
|
290
|
+
/**
|
|
291
|
+
* Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the
|
|
292
|
+
* same core as the decoded carrier accepted by the Connection installer.
|
|
293
|
+
*/
|
|
294
|
+
var RemoteMock = class RemoteMock {
|
|
295
|
+
/**
|
|
296
|
+
* Create a mock whose `$events` stream answers the Gateway client's opening
|
|
297
|
+
* with one ready frame and then stays open, so the assembled client connects.
|
|
298
|
+
* @param options - host facts for the ready frame.
|
|
299
|
+
* @returns the mock.
|
|
300
|
+
*/
|
|
301
|
+
static create(options = {}) {
|
|
302
|
+
const mock = new RemoteMock();
|
|
303
|
+
const host = options.host ?? { home: "/home/mock" };
|
|
304
|
+
let generation = 0;
|
|
305
|
+
return mock.stream(EVENTS_ENDPOINT, (_args, stream) => {
|
|
306
|
+
generation += 1;
|
|
307
|
+
stream.push({
|
|
308
|
+
type: "ready",
|
|
309
|
+
clientId: `mock-client-${String(generation)}`,
|
|
310
|
+
host
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
unaryDefaults = /* @__PURE__ */ new Map();
|
|
315
|
+
scripts = /* @__PURE__ */ new Map();
|
|
316
|
+
live = [];
|
|
317
|
+
openWaiters = [];
|
|
318
|
+
logStore = new MockLogStore();
|
|
319
|
+
unaryMocks = /* @__PURE__ */ new Map();
|
|
320
|
+
streamMocks = /* @__PURE__ */ new Map();
|
|
321
|
+
proxy = createRemoteProxy((endpoint) => this.modeOf(endpoint) === "stream" ? this.streamMock(endpoint) : this.unaryMock(endpoint));
|
|
322
|
+
/**
|
|
323
|
+
* Native mock functions for every namespace: configure answers and inspect calls without declaring method names.
|
|
324
|
+
* Explicit stream registrations select stream mocks; all other methods use unary mocks.
|
|
325
|
+
* Native overrides belong to this instance; `mockReset()` restores the implementation reading current defaults.
|
|
326
|
+
* Generated declarations provide exact signatures; absent declarations weaken only this test-owned proxy.
|
|
327
|
+
*/
|
|
328
|
+
remote = this.proxy;
|
|
329
|
+
/** Everything observed so far. */
|
|
330
|
+
log = this.logStore;
|
|
331
|
+
/** Control over streams opened by registered scripts. */
|
|
332
|
+
streams = {
|
|
333
|
+
push: (endpoint, item, where) => this.forEachOpen(endpoint, where, (stream) => {
|
|
334
|
+
stream.push(item);
|
|
335
|
+
}),
|
|
336
|
+
end: (endpoint, where) => this.forEachOpen(endpoint, where, (stream) => {
|
|
337
|
+
stream.end();
|
|
338
|
+
}),
|
|
339
|
+
fail: (endpoint, error, where) => this.forEachOpen(endpoint, where, (stream) => {
|
|
340
|
+
stream.fail(error);
|
|
341
|
+
}),
|
|
342
|
+
opened: (endpoint, count) => {
|
|
343
|
+
if (this.logStore.streams(endpoint).length >= count) return Promise.resolve();
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
this.openWaiters.push({
|
|
346
|
+
endpoint,
|
|
347
|
+
count,
|
|
348
|
+
resolve
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
drained: (endpoint, where) => Promise.all(this.matching(endpoint, where).map((stream) => stream.drained())).then(() => void 0)
|
|
353
|
+
};
|
|
354
|
+
/**
|
|
355
|
+
* The Connection carrier face: `call` dispatches, `open` opens; payloads carry
|
|
356
|
+
* `{ args }` as the whole-client proxies (an array) or the Gateway's own
|
|
357
|
+
* endpoints (one object) send them, and a call aborted by its signal rejects.
|
|
358
|
+
*/
|
|
359
|
+
rpc = {
|
|
360
|
+
call: async (_channel, endpoint, payload, signal) => {
|
|
361
|
+
const pending = this.dispatch(endpoint, argsOf(endpoint, payload));
|
|
362
|
+
return await (signal === void 0 ? pending : settleOrAbort(pending, signal));
|
|
363
|
+
},
|
|
364
|
+
open: (_channel, endpoint, payload, signal) => this.open(endpoint, argsOf(endpoint, payload), signal)
|
|
365
|
+
};
|
|
366
|
+
constructor() {}
|
|
367
|
+
unary(endpoint, rule) {
|
|
368
|
+
this.unaryDefaults.set(endpoint, rule);
|
|
369
|
+
return this;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Declare `endpoint` as a stream and replace its default script when supplied. A declaration without a script
|
|
373
|
+
* preserves any existing script; an endpoint declared without one fails an open as a stream miss.
|
|
374
|
+
* @param endpoint - `<namespace>/<method>`.
|
|
375
|
+
* @param script - script (`frames` / `openStream` build the common ones).
|
|
376
|
+
* @returns this.
|
|
377
|
+
*/
|
|
378
|
+
stream(endpoint, script) {
|
|
379
|
+
if (script !== void 0 || !this.scripts.has(endpoint)) this.scripts.set(endpoint, script);
|
|
380
|
+
return this;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Register endpoint defaults without changing native mock overrides.
|
|
384
|
+
* @param table - unary answers and stream scripts.
|
|
385
|
+
* @returns this.
|
|
386
|
+
*/
|
|
387
|
+
load(table) {
|
|
388
|
+
for (const [endpoint, rule] of Object.entries(table.unary ?? {})) this.unary(endpoint, rule);
|
|
389
|
+
for (const endpoint of table.streams ?? []) this.stream(endpoint);
|
|
390
|
+
for (const [endpoint, script] of Object.entries(table.stream ?? {})) this.stream(endpoint, script);
|
|
391
|
+
return this;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Whether `endpoint` is declared a stream (with or without a script) or has a unary rule — the one declaration
|
|
395
|
+
* the whole-client proxies need; an endpoint neither declared nor ruled is dispatched as a unary call.
|
|
396
|
+
* @param endpoint - endpoint.
|
|
397
|
+
* @returns the mode, or undefined when nothing is registered.
|
|
398
|
+
*/
|
|
399
|
+
modeOf(endpoint) {
|
|
400
|
+
if (this.scripts.has(endpoint)) return "stream";
|
|
401
|
+
if (this.unaryDefaults.has(endpoint)) return "unary";
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Registered endpoints and accessed proxy methods, sorted.
|
|
405
|
+
* @returns endpoint names.
|
|
406
|
+
*/
|
|
407
|
+
endpoints() {
|
|
408
|
+
const names = new Set(this.scripts.keys());
|
|
409
|
+
for (const endpoint of this.unaryDefaults.keys()) names.add(endpoint);
|
|
410
|
+
for (const [namespace, methods] of Object.entries(this.proxy)) for (const method of Object.keys(methods)) names.add(`${namespace}/${method}`);
|
|
411
|
+
return [...names].sort();
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Answer one unary call with the registered rule's value, verbatim. The call
|
|
415
|
+
* is logged as soon as its rule is selected and settles with the rule: a
|
|
416
|
+
* rule that throws or rejects fails the call with that error.
|
|
417
|
+
* @param endpoint - endpoint.
|
|
418
|
+
* @param args - positional args.
|
|
419
|
+
* @returns the answer.
|
|
420
|
+
* @throws {Error} when no rule is registered (logged as unmatched).
|
|
421
|
+
*/
|
|
422
|
+
async dispatch(endpoint, args) {
|
|
423
|
+
let answer;
|
|
424
|
+
try {
|
|
425
|
+
answer = this.unaryMock(endpoint)(...args);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof MissingUnaryRule) throw error;
|
|
428
|
+
answer = Promise.reject(toError(error));
|
|
429
|
+
}
|
|
430
|
+
const record = this.logStore.call(endpoint, args);
|
|
431
|
+
try {
|
|
432
|
+
record.result = await answer;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
record.state = "failed";
|
|
435
|
+
record.result = error;
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
record.state = "answered";
|
|
439
|
+
return record.result;
|
|
440
|
+
}
|
|
441
|
+
unaryMock(endpoint) {
|
|
442
|
+
let mock = this.unaryMocks.get(endpoint);
|
|
443
|
+
if (mock === void 0) {
|
|
444
|
+
mock = fn((...args) => Promise.resolve(this.unaryAnswer(endpoint, args)));
|
|
445
|
+
this.unaryMocks.set(endpoint, mock);
|
|
446
|
+
}
|
|
447
|
+
return mock;
|
|
448
|
+
}
|
|
449
|
+
unaryAnswer(endpoint, args) {
|
|
450
|
+
if (!this.unaryDefaults.has(endpoint)) {
|
|
451
|
+
this.logStore.miss(endpoint, "unary");
|
|
452
|
+
throw new MissingUnaryRule(this.noRuleMessage(endpoint));
|
|
453
|
+
}
|
|
454
|
+
const rule = this.unaryDefaults.get(endpoint);
|
|
455
|
+
return isRuleFn(rule) ? answerOf(rule, args) : rule;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Open through the endpoint's native mock; its default runs the registered script as a controlled stream.
|
|
459
|
+
* A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`.
|
|
460
|
+
* @param endpoint - endpoint.
|
|
461
|
+
* @param args - positional args.
|
|
462
|
+
* @param signal - consumer cancellation.
|
|
463
|
+
* @returns the controlled script stream or the native override's caller-owned iterable.
|
|
464
|
+
* @throws {Error} when the default runs without a registered script (logged as unmatched).
|
|
465
|
+
*/
|
|
466
|
+
open(endpoint, args, signal) {
|
|
467
|
+
return this.streamMock(endpoint)(...args, signal);
|
|
468
|
+
}
|
|
469
|
+
streamMock(endpoint) {
|
|
470
|
+
let mock = this.streamMocks.get(endpoint);
|
|
471
|
+
if (mock === void 0) {
|
|
472
|
+
mock = fn((...values) => {
|
|
473
|
+
const args = [...values];
|
|
474
|
+
const signal = args.at(-1) instanceof AbortSignal ? args.pop() : new AbortController().signal;
|
|
475
|
+
return this.openScript(endpoint, args, signal);
|
|
476
|
+
});
|
|
477
|
+
this.streamMocks.set(endpoint, mock);
|
|
478
|
+
}
|
|
479
|
+
return mock;
|
|
480
|
+
}
|
|
481
|
+
openScript(endpoint, args, signal) {
|
|
482
|
+
const script = this.scripts.get(endpoint);
|
|
483
|
+
if (script === void 0) {
|
|
484
|
+
this.logStore.miss(endpoint, "stream");
|
|
485
|
+
throw new Error(this.noRuleMessage(endpoint));
|
|
486
|
+
}
|
|
487
|
+
const stream = new MockStream(this.logStore.stream(endpoint, args), signal);
|
|
488
|
+
this.live.push(stream);
|
|
489
|
+
this.wakeOpened(endpoint);
|
|
490
|
+
stream.run(script, args);
|
|
491
|
+
return stream;
|
|
492
|
+
}
|
|
493
|
+
/** Throw when any request found no rule, naming the endpoints and the registered ones. */
|
|
494
|
+
assertNoUnmatched() {
|
|
495
|
+
const unmatched = this.logStore.unmatched();
|
|
496
|
+
if (unmatched.length === 0) return;
|
|
497
|
+
const lines = unmatched.map((entry) => ` ${entry.endpoint} (${entry.mode})`);
|
|
498
|
+
throw new Error(`remote-mock: ${String(unmatched.length)} unmatched request(s):\n${lines.join("\n")}\nregistered: ${this.endpoints().join(", ")}`);
|
|
499
|
+
}
|
|
500
|
+
noRuleMessage(endpoint) {
|
|
501
|
+
return `remote-mock: no rule for ${endpoint}; registered: ${this.endpoints().join(", ")}`;
|
|
502
|
+
}
|
|
503
|
+
/** Streams on `endpoint` still open or still holding items their consumer has not pulled; the rest are forgotten. */
|
|
504
|
+
matching(endpoint, where) {
|
|
505
|
+
this.live = this.live.filter((stream) => stream.record.state === "open" || stream.queued > 0);
|
|
506
|
+
return this.live.filter((stream) => stream.record.endpoint === endpoint && (where === void 0 || where(stream.record.args)));
|
|
507
|
+
}
|
|
508
|
+
forEachOpen(endpoint, where, action) {
|
|
509
|
+
const targets = this.matching(endpoint, where).filter((stream) => stream.record.state === "open");
|
|
510
|
+
for (const stream of targets) action(stream);
|
|
511
|
+
return targets.length;
|
|
512
|
+
}
|
|
513
|
+
wakeOpened(endpoint) {
|
|
514
|
+
const opened = this.logStore.streams(endpoint).length;
|
|
515
|
+
const ready = this.openWaiters.filter((waiter) => waiter.endpoint === endpoint && opened >= waiter.count);
|
|
516
|
+
this.openWaiters = this.openWaiters.filter((waiter) => !ready.includes(waiter));
|
|
517
|
+
for (const waiter of ready) waiter.resolve();
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
function isRuleFn(rule) {
|
|
521
|
+
return typeof rule === "function";
|
|
522
|
+
}
|
|
523
|
+
/** A rule's synchronous throw becomes a rejection so the call settles through one path. */
|
|
524
|
+
function answerOf(rule, args) {
|
|
525
|
+
try {
|
|
526
|
+
return rule(...args);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return Promise.reject(toError(error));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** Positional args from a carrier payload: the array the whole-client proxies send, or the one object the Gateway's own endpoints send. */
|
|
532
|
+
function argsOf(endpoint, payload) {
|
|
533
|
+
if (typeof payload === "object" && payload !== null && "args" in payload) {
|
|
534
|
+
const { args } = payload;
|
|
535
|
+
if (Array.isArray(args)) return args;
|
|
536
|
+
if (typeof args === "object" && args !== null) return [args];
|
|
537
|
+
}
|
|
538
|
+
throw new TypeError(`remote-mock: payload of ${endpoint} must be { args: unknown[] | object }`);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Settle with the call, or reject with the abort reason first; the call itself
|
|
542
|
+
* always keeps a handler so its own outcome is never an unhandled rejection.
|
|
543
|
+
*/
|
|
544
|
+
function settleOrAbort(pending, signal) {
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
const abort = () => {
|
|
547
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("remote-mock: call aborted", { cause: signal.reason }));
|
|
548
|
+
};
|
|
549
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
550
|
+
pending.then(resolve, reject).finally(() => {
|
|
551
|
+
signal.removeEventListener("abort", abort);
|
|
552
|
+
});
|
|
553
|
+
if (signal.aborted) abort();
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
export { RemoteMock, frames, ok, openStream };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-named mock for Typert Remote traffic: a table of unary answers and
|
|
3
|
+
* stream scripts keyed by `<namespace>/<method>`, scripted-stream control, a
|
|
4
|
+
* carrier log, and the Connection carrier face the `connection` plugin
|
|
5
|
+
* accepts through explicit installation or a page transport. Values are whatever the test
|
|
6
|
+
* registers; the only declaration is whether an endpoint is unary or a
|
|
7
|
+
* stream. Browser-safe: no DOM, React, or Node imports, no runtime import
|
|
8
|
+
* from another harness package.
|
|
9
|
+
* @module @deepseek-ai/dsh-remote-mock
|
|
10
|
+
*/
|
|
11
|
+
export { RemoteMock, ok } from './remote-mock.ts';
|
|
12
|
+
export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts';
|
|
13
|
+
export { frames, openStream } from './streams.ts';
|
|
14
|
+
export type { StreamHandle, StreamScript } from './streams.ts';
|
|
15
|
+
export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts';
|
|
16
|
+
export type { MockedRemote } from './remote-proxy.ts';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** What a `RemoteMock` saw: answered calls, opened streams, and requests without a rule. */
|
|
2
|
+
/** One unary call, logged when its rule is selected and settled when the rule does. */
|
|
3
|
+
export interface LoggedCall {
|
|
4
|
+
readonly endpoint: string;
|
|
5
|
+
/** Positional args as the caller passed them. */
|
|
6
|
+
readonly args: readonly unknown[];
|
|
7
|
+
/** `pending` until the rule settles, then `answered` or `failed`. */
|
|
8
|
+
readonly state: 'pending' | 'answered' | 'failed';
|
|
9
|
+
/** The answer once `answered`, the thrown error once `failed`. */
|
|
10
|
+
readonly result: unknown;
|
|
11
|
+
/** Global order across calls and stream opens. */
|
|
12
|
+
readonly seq: number;
|
|
13
|
+
}
|
|
14
|
+
/** One stream open and its current state. */
|
|
15
|
+
export interface LoggedStream {
|
|
16
|
+
readonly endpoint: string;
|
|
17
|
+
/** Positional args the stream was opened with. */
|
|
18
|
+
readonly args: readonly unknown[];
|
|
19
|
+
readonly state: 'open' | 'ended' | 'failed' | 'cancelled';
|
|
20
|
+
/** Items delivered to the consumer so far. */
|
|
21
|
+
readonly pushed: number;
|
|
22
|
+
/** Global order across calls and stream opens. */
|
|
23
|
+
readonly seq: number;
|
|
24
|
+
}
|
|
25
|
+
/** One request that found no rule. */
|
|
26
|
+
export interface LoggedMiss {
|
|
27
|
+
readonly endpoint: string;
|
|
28
|
+
readonly mode: 'unary' | 'stream';
|
|
29
|
+
}
|
|
30
|
+
/** Read side of the log. */
|
|
31
|
+
export interface MockLog {
|
|
32
|
+
/**
|
|
33
|
+
* Unary calls, oldest first, with their live state.
|
|
34
|
+
* @param endpoint - restrict to one endpoint.
|
|
35
|
+
* @returns matching entries.
|
|
36
|
+
*/
|
|
37
|
+
calls(endpoint?: string): readonly LoggedCall[];
|
|
38
|
+
/**
|
|
39
|
+
* Stream opens, oldest first, with their live state.
|
|
40
|
+
* @param endpoint - restrict to one endpoint.
|
|
41
|
+
* @returns matching entries.
|
|
42
|
+
*/
|
|
43
|
+
streams(endpoint?: string): readonly LoggedStream[];
|
|
44
|
+
/**
|
|
45
|
+
* The first positional arg of every unary call and stream open, oldest first: the request object of the Gateway's
|
|
46
|
+
* one-parameter endpoints. Without `endpoint`, every endpoint the client asked the Host for, except the Gateway's
|
|
47
|
+
* own `$`-prefixed ones (`$events`).
|
|
48
|
+
* @param endpoint - restrict to one endpoint.
|
|
49
|
+
* @returns the requests.
|
|
50
|
+
*/
|
|
51
|
+
requests(endpoint?: string): readonly unknown[];
|
|
52
|
+
/** Requests that found no rule, oldest first. */
|
|
53
|
+
unmatched(): readonly LoggedMiss[];
|
|
54
|
+
}
|
|
55
|
+
/** Mutable call entry the dispatcher settles. */
|
|
56
|
+
export interface CallRecord extends LoggedCall {
|
|
57
|
+
state: LoggedCall['state'];
|
|
58
|
+
result: unknown;
|
|
59
|
+
}
|
|
60
|
+
/** Mutable stream entry shared with the live stream. */
|
|
61
|
+
export interface StreamRecord extends LoggedStream {
|
|
62
|
+
state: LoggedStream['state'];
|
|
63
|
+
pushed: number;
|
|
64
|
+
}
|
|
65
|
+
/** The mock's log store. */
|
|
66
|
+
export declare class MockLogStore implements MockLog {
|
|
67
|
+
private readonly callEntries;
|
|
68
|
+
private readonly streamEntries;
|
|
69
|
+
private readonly missEntries;
|
|
70
|
+
private seq;
|
|
71
|
+
calls(endpoint?: string): readonly LoggedCall[];
|
|
72
|
+
streams(endpoint?: string): readonly LoggedStream[];
|
|
73
|
+
requests(endpoint?: string): readonly unknown[];
|
|
74
|
+
unmatched(): readonly LoggedMiss[];
|
|
75
|
+
/**
|
|
76
|
+
* Record one call whose rule was selected.
|
|
77
|
+
* @param endpoint - endpoint.
|
|
78
|
+
* @param args - positional args.
|
|
79
|
+
* @returns the mutable entry the dispatcher settles.
|
|
80
|
+
*/
|
|
81
|
+
call(endpoint: string, args: readonly unknown[]): CallRecord;
|
|
82
|
+
/**
|
|
83
|
+
* Record one stream open.
|
|
84
|
+
* @param endpoint - endpoint.
|
|
85
|
+
* @param args - positional args.
|
|
86
|
+
* @returns the mutable entry the stream updates.
|
|
87
|
+
*/
|
|
88
|
+
stream(endpoint: string, args: readonly unknown[]): StreamRecord;
|
|
89
|
+
/**
|
|
90
|
+
* Record one request without a rule.
|
|
91
|
+
* @param endpoint - endpoint.
|
|
92
|
+
* @param mode - how it was requested.
|
|
93
|
+
*/
|
|
94
|
+
miss(endpoint: string, mode: 'unary' | 'stream'): void;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=log.d.ts.map
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** `RemoteMock`: an endpoint table (unary answers or stream scripts), live stream control, a log, and the Connection carrier face. */
|
|
2
|
+
import type { ClientConnectionRpc } from '@deepseek-ai/dsh-client-connection/client';
|
|
3
|
+
import { type MockLog } from './log.ts';
|
|
4
|
+
import { type StreamScript } from './streams.ts';
|
|
5
|
+
import { type MockedRemote } from './remote-proxy.ts';
|
|
6
|
+
/** A unary handler receives the caller's positional arguments, without its trailing `AbortSignal`. */
|
|
7
|
+
export type UnaryRuleFn<Args extends readonly unknown[] = readonly unknown[], Result = unknown> = (...args: Args) => Result;
|
|
8
|
+
/** Endpoint defaults: unary values or positional handlers, stream scripts, and stream declarations. */
|
|
9
|
+
export interface RemoteTable {
|
|
10
|
+
readonly unary?: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly stream?: Readonly<Record<string, StreamScript>>;
|
|
12
|
+
/** Endpoints declared as streams without a script, so an open finds a stream miss rather than a unary one. */
|
|
13
|
+
readonly streams?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
/** Construction options. */
|
|
16
|
+
export interface RemoteMockOptions {
|
|
17
|
+
/** Host facts the built-in `$events` ready frame carries; default `{ home: '/home/mock' }`. */
|
|
18
|
+
readonly host?: {
|
|
19
|
+
readonly home: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Filter over the args a stream was opened with. */
|
|
23
|
+
export type StreamFilter = (args: readonly unknown[]) => boolean;
|
|
24
|
+
/** Control and synchronization for streams opened by registered scripts; native overrides are excluded. */
|
|
25
|
+
export interface OpenStreams {
|
|
26
|
+
/**
|
|
27
|
+
* Push one item into every open stream on `endpoint`, optionally filtered by its open args.
|
|
28
|
+
* @param endpoint - endpoint.
|
|
29
|
+
* @param item - item to deliver.
|
|
30
|
+
* @param where - filter over open args.
|
|
31
|
+
* @returns how many streams received it.
|
|
32
|
+
*/
|
|
33
|
+
push(endpoint: string, item: unknown, where?: StreamFilter): number;
|
|
34
|
+
/**
|
|
35
|
+
* End every matching open stream.
|
|
36
|
+
* @param endpoint - endpoint.
|
|
37
|
+
* @param where - filter over open args.
|
|
38
|
+
* @returns how many streams ended.
|
|
39
|
+
*/
|
|
40
|
+
end(endpoint: string, where?: StreamFilter): number;
|
|
41
|
+
/**
|
|
42
|
+
* Fail every matching open stream with `error`.
|
|
43
|
+
* @param endpoint - endpoint.
|
|
44
|
+
* @param error - error the consumer's read rejects with.
|
|
45
|
+
* @param where - filter over open args.
|
|
46
|
+
* @returns how many streams failed.
|
|
47
|
+
*/
|
|
48
|
+
fail(endpoint: string, error: Error, where?: StreamFilter): number;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve once registered scripts have opened `endpoint` at least `count` times in total.
|
|
51
|
+
* @param endpoint - endpoint.
|
|
52
|
+
* @param count - opens to wait for.
|
|
53
|
+
*/
|
|
54
|
+
opened(endpoint: string, count: number): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve once the consumer of every matching stream has pulled everything pushed so far and, for a stream still
|
|
57
|
+
* open, waits for more; a settled stream counts once its queue is empty, and a stream nobody reads never drains.
|
|
58
|
+
* Pulled means taken from the queue: a consumer that processes items asynchronously after pulling them may still
|
|
59
|
+
* be working on the last one.
|
|
60
|
+
* @param endpoint - endpoint.
|
|
61
|
+
* @param where - filter over open args.
|
|
62
|
+
*/
|
|
63
|
+
drained(endpoint: string, where?: StreamFilter): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The success envelope the Client's Remote callers read: `{ ok: true, value }`.
|
|
67
|
+
* @param value - success value.
|
|
68
|
+
* @returns the envelope.
|
|
69
|
+
*/
|
|
70
|
+
export declare function ok<T>(value: T): {
|
|
71
|
+
readonly ok: true;
|
|
72
|
+
readonly value: T;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the
|
|
76
|
+
* same core as the decoded carrier accepted by the Connection installer.
|
|
77
|
+
*/
|
|
78
|
+
export declare class RemoteMock {
|
|
79
|
+
/**
|
|
80
|
+
* Create a mock whose `$events` stream answers the Gateway client's opening
|
|
81
|
+
* with one ready frame and then stays open, so the assembled client connects.
|
|
82
|
+
* @param options - host facts for the ready frame.
|
|
83
|
+
* @returns the mock.
|
|
84
|
+
*/
|
|
85
|
+
static create(options?: RemoteMockOptions): RemoteMock;
|
|
86
|
+
private readonly unaryDefaults;
|
|
87
|
+
private readonly scripts;
|
|
88
|
+
private live;
|
|
89
|
+
private openWaiters;
|
|
90
|
+
private readonly logStore;
|
|
91
|
+
private readonly unaryMocks;
|
|
92
|
+
private readonly streamMocks;
|
|
93
|
+
private readonly proxy;
|
|
94
|
+
/**
|
|
95
|
+
* Native mock functions for every namespace: configure answers and inspect calls without declaring method names.
|
|
96
|
+
* Explicit stream registrations select stream mocks; all other methods use unary mocks.
|
|
97
|
+
* Native overrides belong to this instance; `mockReset()` restores the implementation reading current defaults.
|
|
98
|
+
* Generated declarations provide exact signatures; absent declarations weaken only this test-owned proxy.
|
|
99
|
+
*/
|
|
100
|
+
readonly remote: MockedRemote;
|
|
101
|
+
/** Everything observed so far. */
|
|
102
|
+
readonly log: MockLog;
|
|
103
|
+
/** Control over streams opened by registered scripts. */
|
|
104
|
+
readonly streams: OpenStreams;
|
|
105
|
+
/**
|
|
106
|
+
* The Connection carrier face: `call` dispatches, `open` opens; payloads carry
|
|
107
|
+
* `{ args }` as the whole-client proxies (an array) or the Gateway's own
|
|
108
|
+
* endpoints (one object) send them, and a call aborted by its signal rejects.
|
|
109
|
+
*/
|
|
110
|
+
readonly rpc: ClientConnectionRpc;
|
|
111
|
+
private constructor();
|
|
112
|
+
/**
|
|
113
|
+
* Set the endpoint's default positional-argument handler without changing its native mock overrides.
|
|
114
|
+
* @param endpoint - `<namespace>/<method>`.
|
|
115
|
+
* @param rule - handler computing the answer.
|
|
116
|
+
* @returns this.
|
|
117
|
+
*/
|
|
118
|
+
unary<Args extends readonly unknown[]>(endpoint: string, rule: UnaryRuleFn<Args>): this;
|
|
119
|
+
/**
|
|
120
|
+
* Set the endpoint's default answer, including explicit `undefined`, without changing its native mock overrides.
|
|
121
|
+
* @param endpoint - `<namespace>/<method>`.
|
|
122
|
+
* @param rule - default answer.
|
|
123
|
+
* @returns this.
|
|
124
|
+
*/
|
|
125
|
+
unary(endpoint: string, rule: unknown): this;
|
|
126
|
+
/**
|
|
127
|
+
* Declare `endpoint` as a stream and replace its default script when supplied. A declaration without a script
|
|
128
|
+
* preserves any existing script; an endpoint declared without one fails an open as a stream miss.
|
|
129
|
+
* @param endpoint - `<namespace>/<method>`.
|
|
130
|
+
* @param script - script (`frames` / `openStream` build the common ones).
|
|
131
|
+
* @returns this.
|
|
132
|
+
*/
|
|
133
|
+
stream(endpoint: string, script?: StreamScript): this;
|
|
134
|
+
/**
|
|
135
|
+
* Register endpoint defaults without changing native mock overrides.
|
|
136
|
+
* @param table - unary answers and stream scripts.
|
|
137
|
+
* @returns this.
|
|
138
|
+
*/
|
|
139
|
+
load(table: RemoteTable): this;
|
|
140
|
+
/**
|
|
141
|
+
* Whether `endpoint` is declared a stream (with or without a script) or has a unary rule — the one declaration
|
|
142
|
+
* the whole-client proxies need; an endpoint neither declared nor ruled is dispatched as a unary call.
|
|
143
|
+
* @param endpoint - endpoint.
|
|
144
|
+
* @returns the mode, or undefined when nothing is registered.
|
|
145
|
+
*/
|
|
146
|
+
modeOf(endpoint: string): 'unary' | 'stream' | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* Registered endpoints and accessed proxy methods, sorted.
|
|
149
|
+
* @returns endpoint names.
|
|
150
|
+
*/
|
|
151
|
+
endpoints(): readonly string[];
|
|
152
|
+
/**
|
|
153
|
+
* Answer one unary call with the registered rule's value, verbatim. The call
|
|
154
|
+
* is logged as soon as its rule is selected and settles with the rule: a
|
|
155
|
+
* rule that throws or rejects fails the call with that error.
|
|
156
|
+
* @param endpoint - endpoint.
|
|
157
|
+
* @param args - positional args.
|
|
158
|
+
* @returns the answer.
|
|
159
|
+
* @throws {Error} when no rule is registered (logged as unmatched).
|
|
160
|
+
*/
|
|
161
|
+
dispatch(endpoint: string, args: readonly unknown[]): Promise<unknown>;
|
|
162
|
+
private unaryMock;
|
|
163
|
+
private unaryAnswer;
|
|
164
|
+
/**
|
|
165
|
+
* Open through the endpoint's native mock; its default runs the registered script as a controlled stream.
|
|
166
|
+
* A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`.
|
|
167
|
+
* @param endpoint - endpoint.
|
|
168
|
+
* @param args - positional args.
|
|
169
|
+
* @param signal - consumer cancellation.
|
|
170
|
+
* @returns the controlled script stream or the native override's caller-owned iterable.
|
|
171
|
+
* @throws {Error} when the default runs without a registered script (logged as unmatched).
|
|
172
|
+
*/
|
|
173
|
+
open(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable<unknown>;
|
|
174
|
+
private streamMock;
|
|
175
|
+
private openScript;
|
|
176
|
+
/** Throw when any request found no rule, naming the endpoints and the registered ones. */
|
|
177
|
+
assertNoUnmatched(): void;
|
|
178
|
+
private noRuleMessage;
|
|
179
|
+
/** Streams on `endpoint` still open or still holding items their consumer has not pulled; the rest are forgotten. */
|
|
180
|
+
private matching;
|
|
181
|
+
private forEachOpen;
|
|
182
|
+
private wakeOpened;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=remote-mock.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Lazy namespace access for native Remote mocks; production Remote declarations stay unchanged. */
|
|
2
|
+
import type { MaybeMockedDeep } from '@vitest/spy';
|
|
3
|
+
import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol';
|
|
4
|
+
/**
|
|
5
|
+
* Native mocks for every generated Remote namespace, or an unrestricted local proxy without generated types.
|
|
6
|
+
* @template Api - generated namespace map available in the caller's TypeScript program.
|
|
7
|
+
*/
|
|
8
|
+
export type MockedRemote<Api = TypertRemoteNamespaceMap> = keyof Api extends never ? any : MaybeMockedDeep<Api>;
|
|
9
|
+
/**
|
|
10
|
+
* Cache accessed namespaces and expose the current native mock for each method.
|
|
11
|
+
* @param method - selects a stable endpoint mock for its configured invocation mode.
|
|
12
|
+
* @returns enumerable accessed namespaces and methods; symbols and thenable probes remain inert.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createRemoteProxy(method: (endpoint: string) => unknown): Record<string, object>;
|
|
15
|
+
//# sourceMappingURL=remote-proxy.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Stream scripts and the pushable, abort-aware stream a script drives. */
|
|
2
|
+
import type { StreamRecord } from './log.ts';
|
|
3
|
+
/** Test-side controls over one open stream. */
|
|
4
|
+
export interface StreamHandle {
|
|
5
|
+
/** Queue one item for the consumer. */
|
|
6
|
+
push(item: unknown): void;
|
|
7
|
+
/** End the stream after the queued items drain. */
|
|
8
|
+
end(): void;
|
|
9
|
+
/** Fail the consumer's next read with `error` after the queued items drain. */
|
|
10
|
+
fail(error: Error): void;
|
|
11
|
+
/** Aborts when the opening signal aborts or the consumer returns early. */
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* How a stream endpoint answers an open: receives the open args and the
|
|
16
|
+
* handle; the stream stays open after the script returns until `end()` or
|
|
17
|
+
* `fail()`. A script that throws or rejects fails the stream with that error.
|
|
18
|
+
*/
|
|
19
|
+
export type StreamScript = (args: readonly unknown[], stream: StreamHandle) => void | Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Script yielding `items`, then ending.
|
|
22
|
+
* @param items - items in order.
|
|
23
|
+
* @returns the script.
|
|
24
|
+
*/
|
|
25
|
+
export declare function frames(items: readonly unknown[]): StreamScript;
|
|
26
|
+
/**
|
|
27
|
+
* Script yielding `initial`, then staying open for `streams.push`.
|
|
28
|
+
* @param initial - items yielded on open.
|
|
29
|
+
* @returns the script.
|
|
30
|
+
*/
|
|
31
|
+
export declare function openStream(initial?: readonly unknown[]): StreamScript;
|
|
32
|
+
/** One open stream: a queue the script pushes into and a single consumer reads from; the log entry tracks its state. */
|
|
33
|
+
export declare class MockStream implements StreamHandle, AsyncIterable<unknown> {
|
|
34
|
+
readonly record: StreamRecord;
|
|
35
|
+
private readonly sourceSignal;
|
|
36
|
+
private readonly queue;
|
|
37
|
+
private settled;
|
|
38
|
+
private waiting;
|
|
39
|
+
private drainWaiters;
|
|
40
|
+
private readonly cancellation;
|
|
41
|
+
readonly signal: AbortSignal;
|
|
42
|
+
/** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */
|
|
43
|
+
private readonly onAbort;
|
|
44
|
+
/** Items pushed but not yet pulled by the consumer. */
|
|
45
|
+
get queued(): number;
|
|
46
|
+
/**
|
|
47
|
+
* @param record - log entry this stream updates.
|
|
48
|
+
* @param sourceSignal - cancellation from the caller that opened the stream.
|
|
49
|
+
*/
|
|
50
|
+
constructor(record: StreamRecord, sourceSignal: AbortSignal);
|
|
51
|
+
push(item: unknown): void;
|
|
52
|
+
end(): void;
|
|
53
|
+
fail(error: Error): void;
|
|
54
|
+
/**
|
|
55
|
+
* Start `script` on this stream.
|
|
56
|
+
* @param script - the registered script.
|
|
57
|
+
* @param args - open args.
|
|
58
|
+
*/
|
|
59
|
+
run(script: StreamScript, args: readonly unknown[]): void;
|
|
60
|
+
/**
|
|
61
|
+
* Resolve once the consumer has pulled every queued item and waits for the next one, or the stream is no longer
|
|
62
|
+
* open and holds nothing the consumer could still pull (a consumer that returns discards what it left).
|
|
63
|
+
* @returns settles when drained.
|
|
64
|
+
*/
|
|
65
|
+
drained(): Promise<void>;
|
|
66
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown>;
|
|
67
|
+
private next;
|
|
68
|
+
private pull;
|
|
69
|
+
private finish;
|
|
70
|
+
private settle;
|
|
71
|
+
private cancel;
|
|
72
|
+
private isDrained;
|
|
73
|
+
private wakeDrained;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The `Error` a thrown value stands for: itself, or a new Error carrying its string form.
|
|
77
|
+
* @param reason - thrown value.
|
|
78
|
+
* @returns the error.
|
|
79
|
+
*/
|
|
80
|
+
export declare function toError(reason: unknown): Error;
|
|
81
|
+
//# sourceMappingURL=streams.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-remote-mock",
|
|
3
|
+
"description": "Endpoint-named mock for Typert Remote traffic: unary answers and stream scripts per <namespace>/<method>, live stream control, a log, and the Connection carrier face whole-client specs install",
|
|
4
|
+
"version": "0.1.6-alpha.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/test-support/remote-mock"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./src/*": "./src/*",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@vitest/spy": "^4.1.8"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.6-alpha.1",
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
35
|
+
"@deepseek-ai/dsh-api-settings-controller": "^0.1.6-alpha.1",
|
|
36
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"lib/index.js",
|
|
40
|
+
"lib/types/**/*.d.ts"
|
|
41
|
+
]
|
|
42
|
+
}
|