@irtio/testing 0.1.0
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/dist/assertions-BP4as6ol.d.ts +38 -0
- package/dist/chunk-O3OZKC3D.js +851 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +469 -0
- package/dist/matchers.d.ts +31 -0
- package/dist/matchers.js +18 -0
- package/package.json +44 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { RoleOf, AnySchema, State, PlainState } from '@irtio/schema';
|
|
2
|
+
import { RoomDefinition } from '@irtio/server';
|
|
3
|
+
import { Room, ClientState, RelayRoom, Scheduler } from '@irtio/client';
|
|
4
|
+
import { RoomCore } from '@irtio/runtime';
|
|
5
|
+
import { TraceEntry, HarnessHost, VisibilityLeak, FakeClock } from '@irtio/runtime/test';
|
|
6
|
+
export { TraceEntry, VisibilityLeak } from '@irtio/runtime/test';
|
|
7
|
+
export { M as MatcherResult, T as TRACE_TAIL, m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth } from './assertions-BP4as6ol.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `@irtio/testing`'s public contract: what `testRoom` hands back, and the two option bags.
|
|
11
|
+
*
|
|
12
|
+
* The shapes here are the surface the canonical usage example relies on (the docblock in
|
|
13
|
+
* `index.ts`). Two of them differ from the shipped client and the difference is deliberate:
|
|
14
|
+
*
|
|
15
|
+
* - **`client.id` is the *client* id.** The example writes `expect([a.id, b.id]).toContain(...)` against an
|
|
16
|
+
* owner, so `a.id` has to be `room.me`. `@irtio/client`'s `Room.id` is the *room code*, so a
|
|
17
|
+
* `TestClient` shadows it and re-exposes the room code as `client.roomId`.
|
|
18
|
+
* - **`client.view` is `room.state`.** The example reads `a.view.cards.get('c1')`; the shipped client calls
|
|
19
|
+
* that `room.state`. Both names point at the same object.
|
|
20
|
+
*
|
|
21
|
+
* Two more things live here beyond that example:
|
|
22
|
+
*
|
|
23
|
+
* - **Reconnection.** `TestClient.drop()` severs a client's in-process transport the way a real
|
|
24
|
+
* socket drop would, so the shipped client's own backoff runs on the harness's fake clock —
|
|
25
|
+
* `await t.run(300)` is enough to see it reconnect. `TestClient.reconnects` counts how many
|
|
26
|
+
* times it has.
|
|
27
|
+
* - **Relay.** `testRelay()` is the sibling of `testRoom()` for relay rooms: no schema, no
|
|
28
|
+
* state, no RPCs — presence and a raw message channel over the same in-process, fake-clock link.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Network simulation for the in-process link. Delays are applied on the **fake clock**, so a
|
|
33
|
+
* `rttMs: 100` room still runs at full speed — it just needs 100 ms of `t.run()` to see a reply.
|
|
34
|
+
*/
|
|
35
|
+
interface LatencySpec {
|
|
36
|
+
/** Round trip in ms; each direction gets half. Default 0. */
|
|
37
|
+
readonly rttMs?: number;
|
|
38
|
+
/** Extra uniform `[0, jitterMs)` delay per frame, never enough to reorder. Default 0. */
|
|
39
|
+
readonly jitterMs?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Drop probability in `[0, 1)` for **state** frames (`WRITE`, `DELTA`, `CORRECT`, `MSG`).
|
|
42
|
+
* Session and RPC frames (`HELLO`, `WELCOME`, `CALL`, `REPLY`, `ERROR`, `PING`, `PONG`) are
|
|
43
|
+
* never dropped: a WebSocket is a reliable stream, and a lost `WELCOME` would hang the join
|
|
44
|
+
* rather than teach the test anything.
|
|
45
|
+
*/
|
|
46
|
+
readonly loss?: number;
|
|
47
|
+
}
|
|
48
|
+
interface TestRoomOptions {
|
|
49
|
+
/** Overrides the room definition's mode. The definition is **not** re-validated. */
|
|
50
|
+
readonly mode?: 'tick' | 'event';
|
|
51
|
+
/** Overrides the room definition's tick rate (and therefore what one `t.tick()` costs in ms). */
|
|
52
|
+
readonly tickRate?: number;
|
|
53
|
+
/** `room.random()` and the latency rng share this seed; default 1, same as `RoomCore`. */
|
|
54
|
+
readonly seed?: number;
|
|
55
|
+
/** Room-wide link simulation; `t.join(n, { latency })` overrides it per client. */
|
|
56
|
+
readonly latency?: LatencySpec;
|
|
57
|
+
/** The room code every client joins. Default `'test-room'`. */
|
|
58
|
+
readonly roomId?: string;
|
|
59
|
+
/** The clients' owned-write flush window, in ms of fake time. Default 50 (the client's own). */
|
|
60
|
+
readonly writeIntervalMs?: number;
|
|
61
|
+
}
|
|
62
|
+
interface TestJoinSpec<Role extends string = string> {
|
|
63
|
+
readonly role?: Role;
|
|
64
|
+
readonly name?: string;
|
|
65
|
+
/** Link simulation for this client only. */
|
|
66
|
+
readonly latency?: LatencySpec;
|
|
67
|
+
/**
|
|
68
|
+
* Implementations of the schema's `client(...)` RPCs, handed straight to `joinRoom({ rpc })`.
|
|
69
|
+
* Without this there is no way to test a server-to-client call succeeding — the client replies
|
|
70
|
+
* `no client implementation` and the only reachable path is the failure one.
|
|
71
|
+
*/
|
|
72
|
+
readonly rpc?: Record<string, (params: never) => unknown>;
|
|
73
|
+
}
|
|
74
|
+
interface UntilOptions {
|
|
75
|
+
/** Default 1000. */
|
|
76
|
+
readonly maxTicks?: number;
|
|
77
|
+
/**
|
|
78
|
+
* How much fake time each attempt advances. Defaults to the tick interval in tick mode and to
|
|
79
|
+
* **1 ms in event mode**, where a "tick" is otherwise a zero-length clock step — without this,
|
|
80
|
+
* `until` could spin a thousand times without moving the clock at all, and could never reach a
|
|
81
|
+
* timer-driven condition such as a client's reconnect backoff.
|
|
82
|
+
*/
|
|
83
|
+
readonly stepMs?: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A real `@irtio/client` room, plus the two aliases the canonical example uses. Everything else — `state`,
|
|
87
|
+
* `call`, `requestOwnership`, `on`, `flush`, `leave` — is the shipped client surface, unchanged.
|
|
88
|
+
*/
|
|
89
|
+
type TestClient<S, Role extends string = RoleOf<S> & string> = Omit<Room<S, Role>, 'id'> & {
|
|
90
|
+
/** This client's id (`room.me`). The canonical example spells it `a.id`. */
|
|
91
|
+
readonly id: string;
|
|
92
|
+
/** The room code — what `Room.id` means outside `@irtio/testing`. */
|
|
93
|
+
readonly roomId: string;
|
|
94
|
+
/** Alias of `state`, the name the canonical example uses. */
|
|
95
|
+
readonly view: ClientState<S, Role>;
|
|
96
|
+
/**
|
|
97
|
+
* Severs the in-process transport out from under this client — exactly as a network drop
|
|
98
|
+
* would, **not** a `leave()`. The room sees a disconnect (not a leave), and the shipped
|
|
99
|
+
* client's own reconnect backoff (250 ms doubling to a 5 s cap) is driven by the harness's
|
|
100
|
+
* fake clock: `await t.run(300)` or `await t.until(() => a.status === 'connected')` is enough
|
|
101
|
+
* to see it resume with the same `id`. `on('status')` fires `'reconnecting'` then
|
|
102
|
+
* `'connected'`. Synchronous — the socket-close side effects (status flip, backoff timer
|
|
103
|
+
* armed) happen before `drop()` returns; nothing needs to be awaited to observe them.
|
|
104
|
+
*/
|
|
105
|
+
drop(options?: {
|
|
106
|
+
code?: number;
|
|
107
|
+
reason?: string;
|
|
108
|
+
}): void;
|
|
109
|
+
/** How many times this client has completed a reconnect (a resumed `WELCOME` after a drop). */
|
|
110
|
+
readonly reconnects: number;
|
|
111
|
+
};
|
|
112
|
+
/** One `REPLY` that came back with `ok: false`, keyed by the rpc the client had called. */
|
|
113
|
+
interface RejectedCall {
|
|
114
|
+
readonly clientId: string;
|
|
115
|
+
readonly rpc: string;
|
|
116
|
+
readonly error: string;
|
|
117
|
+
readonly tick: number;
|
|
118
|
+
}
|
|
119
|
+
/** What `testRoom` returns. */
|
|
120
|
+
interface TestRoom<S extends AnySchema> {
|
|
121
|
+
/** The tracked authority — the same object room handlers mutate. */
|
|
122
|
+
readonly state: State<S>;
|
|
123
|
+
/** The authority's plain state (what the codec reads). */
|
|
124
|
+
readonly plain: PlainState;
|
|
125
|
+
/** Every frame that crossed the seam, in order. `in` = client → room, `out` = room → client. */
|
|
126
|
+
readonly trace: readonly TraceEntry[];
|
|
127
|
+
/** Error replies seen so far, for `toHaveRejected`. */
|
|
128
|
+
readonly rejections: readonly RejectedCall[];
|
|
129
|
+
/** Frames the `latency.loss` die dropped. */
|
|
130
|
+
readonly dropped: number;
|
|
131
|
+
/** Escape hatch: the real `RoomCore`. */
|
|
132
|
+
readonly core: RoomCore<S>;
|
|
133
|
+
/** Escape hatch: the `RoomHost` the core talks to (logs, kicks, byte counts). */
|
|
134
|
+
readonly host: HarnessHost;
|
|
135
|
+
/** Fake-clock time in ms. */
|
|
136
|
+
readonly now: number;
|
|
137
|
+
/** The room's tick counter (`tick()` is the method that advances it). */
|
|
138
|
+
readonly tickCount: number;
|
|
139
|
+
readonly mode: 'tick' | 'event';
|
|
140
|
+
/** Every client that joined, in join order. */
|
|
141
|
+
readonly clients: readonly TestClient<S>[];
|
|
142
|
+
join<Role extends string = RoleOf<S> & string>(n: number, spec?: TestJoinSpec<Role> & {
|
|
143
|
+
role?: RoleOf<S> & string;
|
|
144
|
+
}): Promise<TestClient<S, Role>[]>;
|
|
145
|
+
join<Role extends string = RoleOf<S> & string>(spec?: TestJoinSpec<Role> & {
|
|
146
|
+
role?: RoleOf<S> & string;
|
|
147
|
+
}): Promise<TestClient<S, Role>>;
|
|
148
|
+
/** Tick mode: run exactly `n` ticks. Event mode: run whatever is already due. */
|
|
149
|
+
tick(n?: number): Promise<void>;
|
|
150
|
+
/** Advance the fake clock by `ms`, firing ticks and timers in order. */
|
|
151
|
+
run(ms: number): Promise<void>;
|
|
152
|
+
/** Tick until `pred()` holds; rejects when it never does. */
|
|
153
|
+
until(pred: () => boolean, options?: UntilOptions): Promise<void>;
|
|
154
|
+
/** `withBuiltins(schema)`: the schema every frame is encoded against. */
|
|
155
|
+
readonly ext: AnySchema;
|
|
156
|
+
/** Collections a client can see that its role must not. `[]` when the room is clean. */
|
|
157
|
+
checkVisibility(): VisibilityLeak[];
|
|
158
|
+
/**
|
|
159
|
+
* Judges `clientId` as `role` in `checkVisibility` from now on, without telling the room — the
|
|
160
|
+
* only way for a test to prove the leak detector actually fires.
|
|
161
|
+
*/
|
|
162
|
+
pretendRole(clientId: string, role: string): void;
|
|
163
|
+
/** Per-client divergence from the authority; `differences` is empty when that client is level. */
|
|
164
|
+
convergence(): {
|
|
165
|
+
clientId: string;
|
|
166
|
+
differences: string[];
|
|
167
|
+
}[];
|
|
168
|
+
/** Outbound bytes per client and the per-tick average, as `toStayUnderBandwidth` reads them. */
|
|
169
|
+
bandwidth(): {
|
|
170
|
+
clientId: string;
|
|
171
|
+
bytes: number;
|
|
172
|
+
perTick: number;
|
|
173
|
+
}[];
|
|
174
|
+
/** Every client leaves and the room stops. */
|
|
175
|
+
stop(): void;
|
|
176
|
+
}
|
|
177
|
+
interface TestRelayOptions {
|
|
178
|
+
/** Presence order and the latency rng share this seed; default 1, same as `testRoom`. */
|
|
179
|
+
readonly seed?: number;
|
|
180
|
+
/** Room-wide link simulation for every relay client. */
|
|
181
|
+
readonly latency?: LatencySpec;
|
|
182
|
+
/** The room code every client joins. Default `'test-relay'`. */
|
|
183
|
+
readonly roomId?: string;
|
|
184
|
+
/** Default 64, same as `@irtio/server`'s relay default. */
|
|
185
|
+
readonly maxClients?: number;
|
|
186
|
+
}
|
|
187
|
+
interface TestRelayJoinSpec {
|
|
188
|
+
readonly role?: string;
|
|
189
|
+
readonly name?: string;
|
|
190
|
+
}
|
|
191
|
+
/** A real `@irtio/client` relay room, plus `drop()` and a recording of every `onMessage` delivery. */
|
|
192
|
+
type TestRelayClient = RelayRoom & {
|
|
193
|
+
/** Severs the in-process transport, exactly as `TestClient.drop` does for a schema room. */
|
|
194
|
+
drop(options?: {
|
|
195
|
+
code?: number;
|
|
196
|
+
reason?: string;
|
|
197
|
+
}): void;
|
|
198
|
+
/** Every `{ from, bytes }` this client's `onMessage` has delivered, in order. */
|
|
199
|
+
readonly received: readonly {
|
|
200
|
+
from: string;
|
|
201
|
+
bytes: Uint8Array;
|
|
202
|
+
}[];
|
|
203
|
+
};
|
|
204
|
+
/** What `testRelay` returns: `testRoom`'s sibling for a schema-less relay room. */
|
|
205
|
+
interface TestRelay {
|
|
206
|
+
join(n: number, spec?: TestRelayJoinSpec): Promise<TestRelayClient[]>;
|
|
207
|
+
join(spec?: TestRelayJoinSpec): Promise<TestRelayClient>;
|
|
208
|
+
/** Advance the fake clock by `ms`, firing timers (and message delivery) in order. */
|
|
209
|
+
run(ms: number): Promise<void>;
|
|
210
|
+
/** Relay rooms have no tick loop; this just settles whatever is already in flight `n` times. */
|
|
211
|
+
tick(n?: number): Promise<void>;
|
|
212
|
+
/** Advances the fake clock until `pred()` holds; rejects when it never does. */
|
|
213
|
+
until(pred: () => boolean, options?: UntilOptions): Promise<void>;
|
|
214
|
+
/** Every client that joined, in join order. */
|
|
215
|
+
readonly clients: readonly TestRelayClient[];
|
|
216
|
+
/** Every frame that crossed the seam, in order. `in` = client → relay, `out` = relay → client. */
|
|
217
|
+
readonly trace: readonly TraceEntry[];
|
|
218
|
+
/** Every client leaves and the relay stops. */
|
|
219
|
+
stop(): void;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `testRoom` — a real `RoomCore`, real `@irtio/client` instances, and nothing in between but
|
|
224
|
+
* function calls.
|
|
225
|
+
*
|
|
226
|
+
* ## Why this does not wrap `createRoomHarness`
|
|
227
|
+
*
|
|
228
|
+
* That harness owns its own `FakeClient`: its `join()` calls `RoomCore.join()` directly and
|
|
229
|
+
* hands back a `JoinResult`, and the bridge that carries frames is private. A real client needs a
|
|
230
|
+
* `WELCOME` frame, not a `JoinResult`. So this file builds on the harness's **public** pieces —
|
|
231
|
+
* `FakeClock`, `HarnessHost`, `RoomCore`, `visibleNames`, `TraceEntry`, `VisibilityLeak` — and adds
|
|
232
|
+
* the one thing that was missing: a tiny in-process supervisor that speaks the wire.
|
|
233
|
+
*
|
|
234
|
+
* ## Timing model
|
|
235
|
+
*
|
|
236
|
+
* The only clock is `FakeClock`, shared by the room (through `HarnessHost`) and by every client
|
|
237
|
+
* (through `clockScheduler`). **Every** frame crosses the seam as a fake-clock timer, including
|
|
238
|
+
* the zero-latency case. That is what keeps the core re-entrancy-free: `FakeClock.advance` fires
|
|
239
|
+
* timers in a loop, never nested, so `RoomCore.receive` is never called from inside a
|
|
240
|
+
* `RoomCore` send. Latency is then just a larger delay on the same timer.
|
|
241
|
+
*
|
|
242
|
+
* ## Reconnection
|
|
243
|
+
*
|
|
244
|
+
* `TestClient.drop()` (see `dropClient` below) calls `RoomCore.markDisconnected` — the same call
|
|
245
|
+
* the real supervisor makes when a socket closes mid-session — and then `InProcessSocket.hangUp`,
|
|
246
|
+
* which fires the client's `onclose` without going through `linkClosed`/`RoomCore.leave`. The
|
|
247
|
+
* client's own backoff (`Session.onSocketClosed` in `@irtio/client`) is armed with
|
|
248
|
+
* `this.scheduler.setTimeout`, and `this.scheduler` here is `clockScheduler(this.clock)` — the
|
|
249
|
+
* same `FakeClock` everything else runs on — so the backoff genuinely advances with `t.run()`/
|
|
250
|
+
* `t.tick()`, not a real timer running alongside them.
|
|
251
|
+
*
|
|
252
|
+
* ## Why `t.tick()` is async
|
|
253
|
+
*
|
|
254
|
+
* The clock is synchronous but a client is not: `joinRoom` resolves in a microtask, an RPC promise
|
|
255
|
+
* settles in a microtask, and an async client RPC implementation replies in one. So each of
|
|
256
|
+
* `join`, `tick`, `run` and `until` advances the clock, drains microtasks, and repeats until
|
|
257
|
+
* nothing new is in flight — which makes them `Promise`-returning. Callers already await `testRoom`
|
|
258
|
+
* and `t.join`; `await t.tick()` is the same shape.
|
|
259
|
+
*/
|
|
260
|
+
|
|
261
|
+
declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
262
|
+
private readonly clock;
|
|
263
|
+
readonly host: HarnessHost;
|
|
264
|
+
private readonly coreRef;
|
|
265
|
+
private readonly scheduler;
|
|
266
|
+
private readonly rng;
|
|
267
|
+
private readonly definition;
|
|
268
|
+
private readonly links;
|
|
269
|
+
private readonly byId;
|
|
270
|
+
private readonly resumeTokens;
|
|
271
|
+
private readonly clientList;
|
|
272
|
+
private readonly roleById;
|
|
273
|
+
private readonly roleOverrides;
|
|
274
|
+
private readonly traceLog;
|
|
275
|
+
private readonly frameLeaks;
|
|
276
|
+
private readonly rejectionLog;
|
|
277
|
+
/** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
|
|
278
|
+
private readonly reconnectCounts;
|
|
279
|
+
private nextClientId;
|
|
280
|
+
private droppedFrames;
|
|
281
|
+
/** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
|
|
282
|
+
private activity;
|
|
283
|
+
private inCore;
|
|
284
|
+
private readonly deferred;
|
|
285
|
+
private readonly roomId;
|
|
286
|
+
private readonly writeIntervalMs;
|
|
287
|
+
private readonly latency;
|
|
288
|
+
private stopped;
|
|
289
|
+
constructor(definition: RoomDefinition<S>, options: TestRoomOptions);
|
|
290
|
+
get core(): RoomCore<S>;
|
|
291
|
+
get state(): State<S>;
|
|
292
|
+
get plain(): PlainState;
|
|
293
|
+
get trace(): readonly TraceEntry[];
|
|
294
|
+
get rejections(): readonly RejectedCall[];
|
|
295
|
+
get dropped(): number;
|
|
296
|
+
get now(): number;
|
|
297
|
+
get tickCount(): number;
|
|
298
|
+
get mode(): 'tick' | 'event';
|
|
299
|
+
get clients(): readonly TestClient<S>[];
|
|
300
|
+
private get intervalMs();
|
|
301
|
+
/** The extended schema every frame is encoded against. */
|
|
302
|
+
get ext(): AnySchema;
|
|
303
|
+
private enterCore;
|
|
304
|
+
/** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
|
|
305
|
+
private transportFor;
|
|
306
|
+
/** The client end hung up: a deliberate `leave()`, or `t.stop()`. Treated as a graceful leave. */
|
|
307
|
+
private linkClosed;
|
|
308
|
+
/**
|
|
309
|
+
* `TestClient.drop()`: severs the transport out from under a client, the way a network drop
|
|
310
|
+
* would — the room only learns the client is *disconnected* (`RoomCore.markDisconnected`, the
|
|
311
|
+
* same call the real supervisor makes when a socket closes mid-session), not that it left. The
|
|
312
|
+
* client's own `onclose` handler then arms its reconnect backoff on the fake clock, exactly as
|
|
313
|
+
* it would over a real killed socket.
|
|
314
|
+
*/
|
|
315
|
+
private dropClient;
|
|
316
|
+
/**
|
|
317
|
+
* Schedules one frame's arrival on the fake clock. Returns `false` when the loss die dropped it.
|
|
318
|
+
* The `next*` clamp keeps a jittered stream in order: a WebSocket never reorders.
|
|
319
|
+
*/
|
|
320
|
+
private schedule;
|
|
321
|
+
private fromClient;
|
|
322
|
+
private preresolve;
|
|
323
|
+
private noteCall;
|
|
324
|
+
private toRoom;
|
|
325
|
+
/**
|
|
326
|
+
* `LEAVE`, the wire counterpart of `linkClosed`'s deliberate-leave branch: call
|
|
327
|
+
* `RoomCore.leave` with `'left'` right away instead of routing the frame into
|
|
328
|
+
* `RoomCore.receive` (which has no case for it — that would hit its `badFrame` fallback) or
|
|
329
|
+
* waiting for the socket's close event. Marks the link gone first so the close that follows
|
|
330
|
+
* moments later (`leave()` always closes its socket) finds `linkClosed` a no-op.
|
|
331
|
+
*/
|
|
332
|
+
private handleLeaveFrame;
|
|
333
|
+
/**
|
|
334
|
+
* The whole supervisor: allocate an id, `RoomCore.join`, answer with a real `WELCOME`. The
|
|
335
|
+
* resume token is the client id — there is nothing to sign in-process.
|
|
336
|
+
*/
|
|
337
|
+
private handleHello;
|
|
338
|
+
private fail;
|
|
339
|
+
private toClient;
|
|
340
|
+
/** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
|
|
341
|
+
private checkFrame;
|
|
342
|
+
private noteReply;
|
|
343
|
+
private record;
|
|
344
|
+
/** Fires everything due, then lets the clients' promises run, until nothing new is in flight. */
|
|
345
|
+
private settle;
|
|
346
|
+
tick(n?: number): Promise<void>;
|
|
347
|
+
run(ms: number): Promise<void>;
|
|
348
|
+
/**
|
|
349
|
+
* Advances until `pred()` holds. In **event mode** a tick is a zero-length clock step, so this
|
|
350
|
+
* advances `stepMs` (1 ms by default) of fake time per attempt instead — otherwise a condition
|
|
351
|
+
* that depends on a timer, such as a dropped client's 250 ms reconnect backoff, could never
|
|
352
|
+
* become true no matter how large `maxTicks` was. Found when a test spent its whole budget
|
|
353
|
+
* on `t.until: predicate still false after 1000 ticks`.
|
|
354
|
+
*/
|
|
355
|
+
until(pred: () => boolean, options?: UntilOptions): Promise<void>;
|
|
356
|
+
join<Role extends string = RoleOf<S> & string>(n: number, spec?: TestJoinSpec<Role> & {
|
|
357
|
+
role?: RoleOf<S> & string;
|
|
358
|
+
}): Promise<TestClient<S, Role>[]>;
|
|
359
|
+
join<Role extends string = RoleOf<S> & string>(spec?: TestJoinSpec<Role> & {
|
|
360
|
+
role?: RoleOf<S> & string;
|
|
361
|
+
}): Promise<TestClient<S, Role>>;
|
|
362
|
+
private joinOne;
|
|
363
|
+
/**
|
|
364
|
+
* Drives the clock until `joinRoom` settles. A zero-latency join lands inside the first
|
|
365
|
+
* `advance(0)`; a slow link needs the 1 ms steps.
|
|
366
|
+
*/
|
|
367
|
+
private awaitJoin;
|
|
368
|
+
/**
|
|
369
|
+
* Judges `clientId` as `role` in `checkVisibility` from now on, without telling the room. The
|
|
370
|
+
* only way to prove the leak detector fires: a correct room never produces a leak, so a test
|
|
371
|
+
* that asserts the checker works has to assert a client against a role it did not join as.
|
|
372
|
+
*/
|
|
373
|
+
pretendRole(clientId: string, role: string): void;
|
|
374
|
+
private roleFor;
|
|
375
|
+
checkVisibility(): VisibilityLeak[];
|
|
376
|
+
/** Per-client divergence from the authority, restricted to what that client's role may see. */
|
|
377
|
+
convergence(): {
|
|
378
|
+
clientId: string;
|
|
379
|
+
differences: string[];
|
|
380
|
+
}[];
|
|
381
|
+
/** Outbound bytes per client, and the ticks they were spread over. */
|
|
382
|
+
bandwidth(): {
|
|
383
|
+
clientId: string;
|
|
384
|
+
bytes: number;
|
|
385
|
+
perTick: number;
|
|
386
|
+
}[];
|
|
387
|
+
stop(): void;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* `testRelay` — `testRoom`'s sibling for a relay room: no `RoomCore`, no schema, no worker.
|
|
392
|
+
*
|
|
393
|
+
* A relay room's server side is `@irtio/supervisor`'s `RelayRoom` (presence bookkeeping) plus a
|
|
394
|
+
* few lines of `MSG` forwarding in `server.ts` — but `RelayRoom` is not part of that package's
|
|
395
|
+
* public surface (`@irtio/supervisor`'s `package.json` only exports `.`), and going through the
|
|
396
|
+
* supervisor for real would mean a real `ws` server, breaking `@irtio/testing`'s "no sockets"
|
|
397
|
+
* promise. So this file does what `harness.ts` already does for schema rooms: it re-implements
|
|
398
|
+
* the tiny slice of the supervisor a relay client actually needs — presence add/disconnect/remove
|
|
399
|
+
* against `relaySchema`, and `MSG` fan-out — as an in-process peer of the real `@irtio/client`
|
|
400
|
+
* `joinRelay`, wired through the same `InProcessSocket` and driven by the same `FakeClock`. No new
|
|
401
|
+
* workspace dependency: every primitive here (`relaySchema`, `PRESENCE_COLLECTION`, `EntityCollection`,
|
|
402
|
+
* `encodeDelta`/`encodeSnapshot`, …) is already public in `@irtio/protocol` and `@irtio/schema`,
|
|
403
|
+
* which `@irtio/testing` depends on regardless.
|
|
404
|
+
*/
|
|
405
|
+
|
|
406
|
+
declare class TestRelayHarness implements TestRelay {
|
|
407
|
+
private readonly clock;
|
|
408
|
+
private readonly scheduler;
|
|
409
|
+
private readonly rng;
|
|
410
|
+
private readonly relay;
|
|
411
|
+
private readonly roomId;
|
|
412
|
+
private readonly maxClients;
|
|
413
|
+
private readonly latency;
|
|
414
|
+
private readonly links;
|
|
415
|
+
private readonly byId;
|
|
416
|
+
private readonly resumeTokens;
|
|
417
|
+
private readonly clientList;
|
|
418
|
+
private readonly traceLog;
|
|
419
|
+
private nextClientId;
|
|
420
|
+
private activity;
|
|
421
|
+
private stopped;
|
|
422
|
+
constructor(options: TestRelayOptions);
|
|
423
|
+
get clients(): readonly TestRelayClient[];
|
|
424
|
+
get trace(): readonly TraceEntry[];
|
|
425
|
+
private transportFor;
|
|
426
|
+
private linkClosed;
|
|
427
|
+
private dropClient;
|
|
428
|
+
private schedule;
|
|
429
|
+
private fromClient;
|
|
430
|
+
private preresolve;
|
|
431
|
+
private toRelay;
|
|
432
|
+
private handleHello;
|
|
433
|
+
private fail;
|
|
434
|
+
private relayMsg;
|
|
435
|
+
private toClient;
|
|
436
|
+
/** Sends a presence delta to everyone except (optionally) the client it is about. */
|
|
437
|
+
private broadcastDelta;
|
|
438
|
+
private record;
|
|
439
|
+
private settle;
|
|
440
|
+
tick(n?: number): Promise<void>;
|
|
441
|
+
run(ms: number): Promise<void>;
|
|
442
|
+
until(pred: () => boolean, options?: UntilOptions): Promise<void>;
|
|
443
|
+
join(n: number, spec?: TestRelayJoinSpec): Promise<TestRelayClient[]>;
|
|
444
|
+
join(spec?: TestRelayJoinSpec): Promise<TestRelayClient>;
|
|
445
|
+
private joinOne;
|
|
446
|
+
private awaitJoin;
|
|
447
|
+
stop(): void;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The client `Scheduler` backed by the harness `FakeClock`. Injecting this is what makes a
|
|
452
|
+
* `testRoom` test finish in microseconds: the write batcher, the ping timer and every RPC timeout
|
|
453
|
+
* are armed on fake time, so nothing in the room needs a real timer to make progress.
|
|
454
|
+
*
|
|
455
|
+
* `frame()` is deliberately absent. The browser scheduler aligns the write batcher to
|
|
456
|
+
* `requestAnimationFrame` with `writeIntervalMs` as a cap; a fake clock has no frames, and leaving
|
|
457
|
+
* it out means one flush per `writeIntervalMs` exactly — which is what a deterministic test wants.
|
|
458
|
+
*/
|
|
459
|
+
|
|
460
|
+
/** A `Scheduler` whose whole notion of time is `clock`. */
|
|
461
|
+
declare function clockScheduler(clock: FakeClock): Scheduler;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Comparing a client's view with the authority.
|
|
465
|
+
*
|
|
466
|
+
* A `testRoom` client is a real `@irtio/client`, and its decoded state is private to it. What is
|
|
467
|
+
* public is `room.state` — the collection facades and singleton proxies a builder reads — so that
|
|
468
|
+
* is what the matchers check: the view is **materialised** back into a `PlainState` through the
|
|
469
|
+
* public read API, and then diffed against the authority with `computeDirty`. Nothing here reaches
|
|
470
|
+
* into the client's internals, which is the point: a leak the builder can see is a leak.
|
|
471
|
+
*/
|
|
472
|
+
|
|
473
|
+
type AnyRecord = Record<string, unknown>;
|
|
474
|
+
/** Reads a client's `room.state` back into a plain state of the extended schema. */
|
|
475
|
+
declare function materialize(ext: AnySchema, view: AnyRecord): PlainState;
|
|
476
|
+
/**
|
|
477
|
+
* Where a client's view differs from the authority, restricted to the collections its role may
|
|
478
|
+
* see. Empty ⇒ converged.
|
|
479
|
+
*/
|
|
480
|
+
declare function divergence(ext: AnySchema, authority: PlainState, client: PlainState, visible: ReadonlySet<string>): string[];
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* `@irtio/testing` — the blessed test API.
|
|
484
|
+
*
|
|
485
|
+
* ```ts
|
|
486
|
+
* import { test, expect } from 'vitest';
|
|
487
|
+
* import { testRoom } from '@irtio/testing';
|
|
488
|
+
* import '@irtio/testing/matchers';
|
|
489
|
+
* import room from './room';
|
|
490
|
+
*
|
|
491
|
+
* test('two players grab the same card; exactly one wins', async () => {
|
|
492
|
+
* const t = await testRoom(room);
|
|
493
|
+
* const [a, b] = await t.join(2, { role: 'player' });
|
|
494
|
+
* a.requestOwnership('cards', 'c1');
|
|
495
|
+
* b.requestOwnership('cards', 'c1');
|
|
496
|
+
* await t.tick();
|
|
497
|
+
* expect([a.id, b.id]).toContain(t.state.cards.ownerOf('c1'));
|
|
498
|
+
* expect(t).toHaveNoVisibilityLeaks();
|
|
499
|
+
* });
|
|
500
|
+
* ```
|
|
501
|
+
*
|
|
502
|
+
* No sockets, no worker, no real timers, no randomness that is not seeded: a room, its clients and
|
|
503
|
+
* the network between them are one synchronous object graph driven by a fake clock. The clients
|
|
504
|
+
* are the shipped `@irtio/client` — prediction, batching, server-wins and reconnection included —
|
|
505
|
+
* so a test that passes here is testing the SDK your players run, not a stand-in.
|
|
506
|
+
*
|
|
507
|
+
* **Reconnection.** `t.join()`'s clients have a `drop()`: it severs the in-process transport the
|
|
508
|
+
* way a real socket drop would (not a `leave()`), and the shipped client's own backoff — driven
|
|
509
|
+
* by this same fake clock — reconnects it. `await t.run(300)` (or `t.until(...)`) is enough to
|
|
510
|
+
* see `on('status')` fire `'reconnecting'` then `'connected'`; `client.reconnects` counts how
|
|
511
|
+
* many times it has.
|
|
512
|
+
*
|
|
513
|
+
* **Relay.** `testRelay()` is the sibling of `testRoom()` for relay rooms: no schema, no
|
|
514
|
+
* state, no RPCs — just presence and `message()`/`onMessage()`, over the same in-process link.
|
|
515
|
+
*
|
|
516
|
+
* ```ts
|
|
517
|
+
* const relay = await testRelay();
|
|
518
|
+
* const [a, b] = await relay.join(2);
|
|
519
|
+
* a.message('all', new TextEncoder().encode('hi'));
|
|
520
|
+
* await relay.until(() => b.received.length > 0);
|
|
521
|
+
* ```
|
|
522
|
+
*
|
|
523
|
+
* Importing this module does **not** pull Vitest in. `@irtio/testing/matchers` does, and
|
|
524
|
+
* registers the four matchers as a side effect.
|
|
525
|
+
*
|
|
526
|
+
* Every method that touches the clock — `join`, `tick`, `run`, `until` on both `testRoom` and
|
|
527
|
+
* `testRelay` — is `async` and **must be awaited**; see `harness.ts`'s docblock for why.
|
|
528
|
+
*/
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Starts a room in-process and returns the test handle.
|
|
532
|
+
*
|
|
533
|
+
* Two overloads rather than one signature with an optional parameter: a defaulted generic in a
|
|
534
|
+
* contextually-typed position widens to `any`, which is how three schema-inference bugs got in
|
|
535
|
+
* (see `types.test-d.ts`). Splitting the call keeps `S` inferred from `definition` in both shapes.
|
|
536
|
+
*/
|
|
537
|
+
declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>): Promise<TestRoom<S>>;
|
|
538
|
+
declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>, options: TestRoomOptions): Promise<TestRoom<S>>;
|
|
539
|
+
/** Starts a schema-less relay room in-process and returns the test handle. See the module docblock. */
|
|
540
|
+
declare function testRelay(options?: TestRelayOptions): Promise<TestRelay>;
|
|
541
|
+
|
|
542
|
+
export { type LatencySpec, type RejectedCall, type TestClient, TestHarness, type TestJoinSpec, type TestRelay, type TestRelayClient, TestRelayHarness, type TestRelayJoinSpec, type TestRelayOptions, type TestRoom, type TestRoomOptions, type UntilOptions, clockScheduler, divergence, materialize, testRelay, testRoom };
|