@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.
@@ -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 };