@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,851 @@
1
+ // src/compare.ts
2
+ import {
3
+ cloneValue,
4
+ computeDirty,
5
+ createState,
6
+ defaultRecord
7
+ } from "@irtio/schema";
8
+ function deepEqual(a, b) {
9
+ if (Object.is(a, b)) return true;
10
+ if (Array.isArray(a) || Array.isArray(b)) {
11
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
12
+ return a.every((v, i) => deepEqual(v, b[i]));
13
+ }
14
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
15
+ const ra = a;
16
+ const rb = b;
17
+ const keys = /* @__PURE__ */ new Set([...Object.keys(ra), ...Object.keys(rb)]);
18
+ for (const k of keys) if (!deepEqual(ra[k], rb[k])) return false;
19
+ return true;
20
+ }
21
+ function materialize(ext, view) {
22
+ const out = createState(ext);
23
+ for (const c of ext.collections) {
24
+ const value = view[c.name];
25
+ if (value === void 0 || value === null) continue;
26
+ if (c.kind === "entity") {
27
+ const src = value;
28
+ const dst = out[c.name];
29
+ for (const id of [...src.ids()]) {
30
+ const record = src.get(id);
31
+ if (!record) continue;
32
+ const values = {};
33
+ for (const f of c.fields) values[f.name] = cloneValue(record[f.name]);
34
+ dst.add(id, values, { owner: src.ownerOf(id) ?? "" });
35
+ }
36
+ } else {
37
+ const record = value;
38
+ const dst = out[c.name];
39
+ for (const f of c.fields) dst[f.name] = cloneValue(record[f.name]);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ function divergence(ext, authority, client, visible) {
45
+ const out = [];
46
+ for (const [name, cd] of computeDirty(ext, authority, client)) {
47
+ if (!visible.has(name)) continue;
48
+ for (const id of cd.added) out.push(`${name}[${id}]: only in the client's view`);
49
+ for (const id of cd.removed) out.push(`${name}[${id}]: missing from the client's view`);
50
+ for (const [id, rd] of cd.updated) {
51
+ const fields = [...rd.mask.fields].map((i) => fieldNameAt(ext, name, i)).filter((n) => n !== void 0);
52
+ const what = rd.owner ? [...fields, "owner"] : fields;
53
+ out.push(`${name}[${id}]: ${what.join(", ")}`);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ function fieldNameAt(ext, collection, index) {
59
+ const c = ext.collections.find((x) => x.name === collection);
60
+ return c?.fields[index]?.name;
61
+ }
62
+ function singletonIsDefault(desc, value) {
63
+ return deepEqual(value, defaultRecord(desc));
64
+ }
65
+
66
+ // src/scheduler.ts
67
+ function clockScheduler(clock) {
68
+ return {
69
+ now: () => clock.now(),
70
+ setTimeout(fn, ms) {
71
+ const handle = clock.setTimeout(fn, ms);
72
+ return () => clock.clearTimeout(handle);
73
+ }
74
+ };
75
+ }
76
+
77
+ // src/harness.ts
78
+ import { joinRoom } from "@irtio/client";
79
+ import {
80
+ ErrorCode,
81
+ FrameType,
82
+ decodeCall,
83
+ decodeFrame,
84
+ decodeHello,
85
+ decodePing,
86
+ decodeReply,
87
+ encodeErrorPayload,
88
+ encodeFrame,
89
+ encodePong,
90
+ encodeWelcome,
91
+ formatError,
92
+ rpcTable
93
+ } from "@irtio/protocol";
94
+ import { Mulberry32, RoomCore, RoomFullError, visibleNames } from "@irtio/runtime";
95
+ import { FakeClock, HarnessHost, frameTypeName } from "@irtio/runtime/test";
96
+ import { decodeDelta } from "@irtio/schema";
97
+
98
+ // src/link.ts
99
+ var InProcessSocket = class {
100
+ constructor(outbound, onLocalClose) {
101
+ this.outbound = outbound;
102
+ this.onLocalClose = onLocalClose;
103
+ }
104
+ outbound;
105
+ onLocalClose;
106
+ onopen = null;
107
+ onmessage = null;
108
+ onclose = null;
109
+ onerror = null;
110
+ /** `true` once either end has closed the link. */
111
+ closed = false;
112
+ send(bytes) {
113
+ if (this.closed) return;
114
+ this.outbound(bytes);
115
+ }
116
+ /** `room.leave()` and `t.stop()` come through here. */
117
+ close() {
118
+ if (this.closed) return;
119
+ this.closed = true;
120
+ this.onLocalClose();
121
+ this.onclose?.();
122
+ }
123
+ /** Harness → client: the socket is up, send `HELLO`. */
124
+ open() {
125
+ if (!this.closed) this.onopen?.();
126
+ }
127
+ /** Harness → client: one frame arrived. */
128
+ deliver(bytes) {
129
+ if (!this.closed) this.onmessage?.(bytes);
130
+ }
131
+ /**
132
+ * Harness → client: the server hung up — `t.stop()` after the room closed, or `client.drop()`
133
+ * simulating a network drop. Unlike `close()` this never calls `onLocalClose`: the harness
134
+ * decides for itself whether that meant a leave (`stop()`) or a disconnect (`drop()`).
135
+ */
136
+ hangUp(info) {
137
+ if (this.closed) return;
138
+ this.closed = true;
139
+ this.onclose?.(info);
140
+ }
141
+ };
142
+
143
+ // src/harness.ts
144
+ var TEST_URL = "ws://localhost:7070";
145
+ var TEST_KEY = "test";
146
+ var DROPPABLE = /* @__PURE__ */ new Set([
147
+ FrameType.WRITE,
148
+ FrameType.DELTA,
149
+ FrameType.CORRECT,
150
+ FrameType.MSG
151
+ ]);
152
+ var MICROTASK_TURNS = 8;
153
+ var SETTLE_ROUNDS = 64;
154
+ var JOIN_TIMEOUT_MS = 3e4;
155
+ async function microtasks(turns = MICROTASK_TURNS) {
156
+ for (let i = 0; i < turns; i++) await Promise.resolve();
157
+ }
158
+ var TestHarness = class {
159
+ clock = new FakeClock();
160
+ host;
161
+ coreRef;
162
+ scheduler;
163
+ rng;
164
+ definition;
165
+ links = [];
166
+ byId = /* @__PURE__ */ new Map();
167
+ resumeTokens = /* @__PURE__ */ new Map();
168
+ clientList = [];
169
+ roleById = /* @__PURE__ */ new Map();
170
+ roleOverrides = /* @__PURE__ */ new Map();
171
+ traceLog = [];
172
+ frameLeaks = [];
173
+ rejectionLog = [];
174
+ /** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
175
+ reconnectCounts = /* @__PURE__ */ new Map();
176
+ nextClientId = 1;
177
+ droppedFrames = 0;
178
+ /** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
179
+ activity = 0;
180
+ inCore = false;
181
+ deferred = [];
182
+ roomId;
183
+ writeIntervalMs;
184
+ latency;
185
+ stopped = false;
186
+ constructor(definition, options) {
187
+ this.definition = configure(definition, options);
188
+ this.roomId = options.roomId ?? "test-room";
189
+ this.latency = options.latency;
190
+ this.writeIntervalMs = options.writeIntervalMs;
191
+ this.rng = new Mulberry32(options.seed ?? 1);
192
+ this.scheduler = clockScheduler(this.clock);
193
+ this.host = new HarnessHost(this.clock);
194
+ this.host.onSend = (clientId, frame) => {
195
+ const link = this.byId.get(clientId);
196
+ if (link?.connected) this.toClient(link, frame);
197
+ };
198
+ this.coreRef = new RoomCore(this.definition, this.host, {
199
+ roomId: this.roomId,
200
+ seed: options.seed ?? 1
201
+ });
202
+ this.coreRef.start();
203
+ }
204
+ // -------------------------------------------------------------------------
205
+ // Accessors
206
+ // -------------------------------------------------------------------------
207
+ get core() {
208
+ return this.coreRef;
209
+ }
210
+ get state() {
211
+ return this.coreRef.state;
212
+ }
213
+ get plain() {
214
+ return this.coreRef.plain;
215
+ }
216
+ get trace() {
217
+ return this.traceLog;
218
+ }
219
+ get rejections() {
220
+ return this.rejectionLog;
221
+ }
222
+ get dropped() {
223
+ return this.droppedFrames;
224
+ }
225
+ get now() {
226
+ return this.clock.time;
227
+ }
228
+ get tickCount() {
229
+ return this.coreRef.tick;
230
+ }
231
+ get mode() {
232
+ return this.coreRef.mode;
233
+ }
234
+ get clients() {
235
+ return this.clientList;
236
+ }
237
+ get intervalMs() {
238
+ return 1e3 / this.definition.config.tickRate;
239
+ }
240
+ /** The extended schema every frame is encoded against. */
241
+ get ext() {
242
+ return this.coreRef.ext;
243
+ }
244
+ // -------------------------------------------------------------------------
245
+ // The core is never re-entered
246
+ // -------------------------------------------------------------------------
247
+ enterCore(fn) {
248
+ if (this.inCore) {
249
+ this.deferred.push(fn);
250
+ return;
251
+ }
252
+ this.inCore = true;
253
+ try {
254
+ fn();
255
+ } finally {
256
+ this.inCore = false;
257
+ }
258
+ while (this.deferred.length > 0) {
259
+ const next = this.deferred.shift();
260
+ if (next) this.enterCore(next);
261
+ }
262
+ }
263
+ // -------------------------------------------------------------------------
264
+ // The link
265
+ // -------------------------------------------------------------------------
266
+ /** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
267
+ transportFor(spec) {
268
+ return {
269
+ connect: () => {
270
+ const link = {
271
+ socket: void 0,
272
+ clientId: `c${this.nextClientId++}`,
273
+ role: "",
274
+ joined: false,
275
+ connected: true,
276
+ latency: spec.latency,
277
+ nextIn: this.clock.time,
278
+ nextOut: this.clock.time,
279
+ calls: /* @__PURE__ */ new Map()
280
+ };
281
+ link.socket = new InProcessSocket(
282
+ (bytes) => this.fromClient(link, bytes),
283
+ () => this.linkClosed(link)
284
+ );
285
+ this.links.push(link);
286
+ this.clock.setTimeout(() => link.socket.open(), 0);
287
+ this.activity++;
288
+ return link.socket;
289
+ }
290
+ };
291
+ }
292
+ /** The client end hung up: a deliberate `leave()`, or `t.stop()`. Treated as a graceful leave. */
293
+ linkClosed(link) {
294
+ if (!link.connected) return;
295
+ link.connected = false;
296
+ this.byId.delete(link.clientId);
297
+ if (!link.joined || this.stopped) return;
298
+ link.joined = false;
299
+ this.enterCore(() => this.coreRef.leave(link.clientId, "left"));
300
+ }
301
+ /**
302
+ * `TestClient.drop()`: severs the transport out from under a client, the way a network drop
303
+ * would — the room only learns the client is *disconnected* (`RoomCore.markDisconnected`, the
304
+ * same call the real supervisor makes when a socket closes mid-session), not that it left. The
305
+ * client's own `onclose` handler then arms its reconnect backoff on the fake clock, exactly as
306
+ * it would over a real killed socket.
307
+ */
308
+ dropClient(clientId, options) {
309
+ const link = this.byId.get(clientId);
310
+ if (!link || !link.connected || link.socket.closed) return;
311
+ link.connected = false;
312
+ this.byId.delete(clientId);
313
+ this.enterCore(() => this.coreRef.markDisconnected(clientId));
314
+ link.socket.hangUp(options);
315
+ }
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
+ schedule(link, dir, type, run) {
321
+ const spec = link.latency ?? this.latency;
322
+ const loss = spec?.loss ?? 0;
323
+ if (loss > 0 && DROPPABLE.has(type) && this.rng.next() < loss) {
324
+ this.droppedFrames++;
325
+ return false;
326
+ }
327
+ const jitterMs = spec?.jitterMs ?? 0;
328
+ const oneWay = (spec?.rttMs ?? 0) / 2 + (jitterMs > 0 ? this.rng.next() * jitterMs : 0);
329
+ const floor = dir === "in" ? link.nextIn : link.nextOut;
330
+ const at = Math.max(this.clock.time + oneWay, floor);
331
+ if (dir === "in") link.nextIn = at;
332
+ else link.nextOut = at;
333
+ this.activity++;
334
+ this.clock.setTimeout(() => {
335
+ this.activity++;
336
+ run();
337
+ }, at - this.clock.time);
338
+ return true;
339
+ }
340
+ // -------------------------------------------------------------------------
341
+ // Client to room
342
+ // -------------------------------------------------------------------------
343
+ fromClient(link, frame) {
344
+ let type;
345
+ let payload;
346
+ try {
347
+ const decoded = decodeFrame(frame);
348
+ type = decoded.type;
349
+ payload = decoded.payload;
350
+ } catch {
351
+ return;
352
+ }
353
+ if (type === FrameType.HELLO) this.preresolve(link, payload);
354
+ if (type === FrameType.CALL) this.noteCall(link, payload);
355
+ this.record("in", link.clientId, type, frame.length);
356
+ this.schedule(link, "in", type, () => this.toRoom(link, type, payload, frame));
357
+ }
358
+ preresolve(link, payload) {
359
+ try {
360
+ const hello = decodeHello(payload);
361
+ const resumed = hello.resumeToken ? this.resumeTokens.get(hello.resumeToken) : void 0;
362
+ if (resumed) link.clientId = resumed;
363
+ } catch {
364
+ }
365
+ }
366
+ noteCall(link, payload) {
367
+ try {
368
+ const call = decodeCall(payload);
369
+ const desc = rpcTable(this.definition.schema).find((r) => r.index === call.rpcId);
370
+ if (desc) link.calls.set(call.reqId, desc.name);
371
+ } catch {
372
+ }
373
+ }
374
+ toRoom(link, type, payload, frame) {
375
+ if (!link.connected || this.stopped) return;
376
+ if (type === FrameType.HELLO) {
377
+ this.handleHello(link, payload);
378
+ return;
379
+ }
380
+ if (type === FrameType.PING) {
381
+ let t = 0;
382
+ try {
383
+ t = decodePing(payload).t;
384
+ } catch {
385
+ }
386
+ this.toClient(
387
+ link,
388
+ encodeFrame(FrameType.PONG, encodePong({ t, serverTick: this.coreRef.tick }))
389
+ );
390
+ return;
391
+ }
392
+ if (type === FrameType.LEAVE) {
393
+ this.handleLeaveFrame(link);
394
+ return;
395
+ }
396
+ if (!link.joined) return;
397
+ this.enterCore(() => this.coreRef.receive(link.clientId, frame));
398
+ }
399
+ /**
400
+ * `LEAVE`, the wire counterpart of `linkClosed`'s deliberate-leave branch: call
401
+ * `RoomCore.leave` with `'left'` right away instead of routing the frame into
402
+ * `RoomCore.receive` (which has no case for it — that would hit its `badFrame` fallback) or
403
+ * waiting for the socket's close event. Marks the link gone first so the close that follows
404
+ * moments later (`leave()` always closes its socket) finds `linkClosed` a no-op.
405
+ */
406
+ handleLeaveFrame(link) {
407
+ if (!link.connected || !link.joined) return;
408
+ link.connected = false;
409
+ link.joined = false;
410
+ this.byId.delete(link.clientId);
411
+ this.enterCore(() => this.coreRef.leave(link.clientId, "left"));
412
+ }
413
+ /**
414
+ * The whole supervisor: allocate an id, `RoomCore.join`, answer with a real `WELCOME`. The
415
+ * resume token is the client id — there is nothing to sign in-process.
416
+ */
417
+ handleHello(link, payload) {
418
+ let role;
419
+ let name;
420
+ let reconnecting = false;
421
+ try {
422
+ const hello = decodeHello(payload);
423
+ role = hello.role;
424
+ name = hello.name;
425
+ reconnecting = hello.resumeToken !== void 0 && this.resumeTokens.has(hello.resumeToken);
426
+ } catch {
427
+ this.fail(link, "E_BAD_FRAME", "malformed HELLO");
428
+ return;
429
+ }
430
+ let result;
431
+ let failure;
432
+ this.enterCore(() => {
433
+ try {
434
+ result = this.coreRef.join(link.clientId, {
435
+ ...role !== void 0 ? { role } : {},
436
+ ...name !== void 0 ? { name } : {},
437
+ ...reconnecting ? { reconnecting: true } : {}
438
+ });
439
+ } catch (err) {
440
+ failure = err;
441
+ }
442
+ });
443
+ if (!result) {
444
+ const full = failure instanceof RoomFullError;
445
+ this.fail(
446
+ link,
447
+ full ? "E_ROOM_FULL" : "E_INTERNAL",
448
+ full ? formatError("E_ROOM_FULL", { roomId: this.roomId }) : failure instanceof Error ? failure.message : String(failure)
449
+ );
450
+ return;
451
+ }
452
+ const joined = result;
453
+ link.joined = true;
454
+ link.connected = true;
455
+ link.role = joined.role;
456
+ this.byId.set(link.clientId, link);
457
+ this.roleById.set(link.clientId, joined.role);
458
+ if (reconnecting) {
459
+ this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
460
+ }
461
+ const resumeToken = `resume:${link.clientId}`;
462
+ this.resumeTokens.set(resumeToken, link.clientId);
463
+ this.toClient(
464
+ link,
465
+ encodeFrame(
466
+ FrameType.WELCOME,
467
+ encodeWelcome({
468
+ clientId: link.clientId,
469
+ role: joined.role,
470
+ tick: joined.tick,
471
+ snapshot: joined.snapshot,
472
+ resumeToken,
473
+ roomId: this.roomId,
474
+ tickIntervalMs: Math.round(this.intervalMs)
475
+ })
476
+ )
477
+ );
478
+ }
479
+ fail(link, code, message) {
480
+ this.toClient(
481
+ link,
482
+ encodeFrame(
483
+ FrameType.ERROR,
484
+ encodeErrorPayload({ code: ErrorCode[code].code, message, fatal: true })
485
+ )
486
+ );
487
+ }
488
+ // -------------------------------------------------------------------------
489
+ // Room to client
490
+ // -------------------------------------------------------------------------
491
+ toClient(link, frame) {
492
+ const type = frame[0] ?? -1;
493
+ if (type === FrameType.DELTA || type === FrameType.CORRECT) this.checkFrame(link, frame);
494
+ if (type === FrameType.REPLY) this.noteReply(link, frame);
495
+ this.record("out", link.clientId, type, frame.length);
496
+ this.schedule(link, "out", type, () => link.socket.deliver(frame));
497
+ }
498
+ /** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
499
+ checkFrame(link, frame) {
500
+ let delta;
501
+ try {
502
+ delta = decodeDelta(this.ext, decodeFrame(frame).payload);
503
+ } catch {
504
+ return;
505
+ }
506
+ const keep = visibleNames(this.ext, link.role);
507
+ for (const dc of delta.collections) {
508
+ if (keep.has(dc.name) || dc.ops.length === 0) continue;
509
+ this.frameLeaks.push({
510
+ clientId: link.clientId,
511
+ role: link.role,
512
+ collection: dc.name,
513
+ kind: "frame",
514
+ ids: dc.ops.map((o) => o.id),
515
+ tick: delta.tick
516
+ });
517
+ }
518
+ }
519
+ noteReply(link, frame) {
520
+ try {
521
+ const reply = decodeReply(decodeFrame(frame).payload);
522
+ const rpc = link.calls.get(reply.reqId);
523
+ if (rpc === void 0) return;
524
+ link.calls.delete(reply.reqId);
525
+ if (reply.ok) return;
526
+ this.rejectionLog.push({
527
+ clientId: link.clientId,
528
+ rpc,
529
+ error: reply.error,
530
+ tick: this.coreRef.tick
531
+ });
532
+ } catch {
533
+ }
534
+ }
535
+ record(dir, clientId, type, bytes) {
536
+ let frame;
537
+ try {
538
+ frame = frameTypeName(type);
539
+ } catch {
540
+ return;
541
+ }
542
+ this.traceLog.push({
543
+ dir,
544
+ clientId,
545
+ frame,
546
+ tick: this.coreRef.tick,
547
+ bytes,
548
+ at: this.clock.time
549
+ });
550
+ }
551
+ // -------------------------------------------------------------------------
552
+ // Clock
553
+ // -------------------------------------------------------------------------
554
+ /** Fires everything due, then lets the clients' promises run, until nothing new is in flight. */
555
+ async settle() {
556
+ for (let i = 0; i < SETTLE_ROUNDS; i++) {
557
+ const before = this.activity;
558
+ await microtasks();
559
+ this.clock.advance(0);
560
+ if (this.activity === before) return;
561
+ }
562
+ throw new Error("testRoom: the frame pump did not settle");
563
+ }
564
+ async tick(n = 1) {
565
+ for (let i = 0; i < n; i++) {
566
+ this.clock.advance(this.mode === "tick" ? this.intervalMs : 0);
567
+ await this.settle();
568
+ }
569
+ }
570
+ async run(ms) {
571
+ this.clock.advance(ms);
572
+ await this.settle();
573
+ }
574
+ /**
575
+ * Advances until `pred()` holds. In **event mode** a tick is a zero-length clock step, so this
576
+ * advances `stepMs` (1 ms by default) of fake time per attempt instead — otherwise a condition
577
+ * that depends on a timer, such as a dropped client's 250 ms reconnect backoff, could never
578
+ * become true no matter how large `maxTicks` was. Found when a test spent its whole budget
579
+ * on `t.until: predicate still false after 1000 ticks`.
580
+ */
581
+ async until(pred, options = {}) {
582
+ const maxTicks = options.maxTicks ?? 1e3;
583
+ const stepMs = options.stepMs ?? (this.mode === "tick" ? this.intervalMs : 1);
584
+ await this.settle();
585
+ if (pred()) return;
586
+ for (let i = 0; i < maxTicks; i++) {
587
+ this.clock.advance(stepMs);
588
+ await this.settle();
589
+ if (pred()) return;
590
+ }
591
+ throw new Error(
592
+ `t.until: predicate still false after ${maxTicks} attempts (${maxTicks * stepMs} ms of fake time, mode=${this.mode})`
593
+ );
594
+ }
595
+ async join(a, b) {
596
+ if (typeof a === "number") {
597
+ const out = [];
598
+ for (let i = 0; i < a; i++) out.push(await this.joinOne(b ?? {}));
599
+ return out;
600
+ }
601
+ return this.joinOne(a ?? {});
602
+ }
603
+ async joinOne(spec) {
604
+ const options = {
605
+ url: TEST_URL,
606
+ key: TEST_KEY,
607
+ room: this.roomId,
608
+ transport: this.transportFor(spec),
609
+ scheduler: this.scheduler,
610
+ ...this.writeIntervalMs !== void 0 ? { writeIntervalMs: this.writeIntervalMs } : {},
611
+ ...spec.role !== void 0 ? { role: spec.role } : {},
612
+ ...spec.name !== void 0 ? { name: spec.name } : {},
613
+ ...spec.rpc !== void 0 ? { rpc: spec.rpc } : {}
614
+ };
615
+ const room = await this.awaitJoin(joinRoom(this.definition.schema, options));
616
+ const client = Object.create(room);
617
+ Object.defineProperties(client, {
618
+ id: { get: () => room.me, enumerable: true },
619
+ roomId: { get: () => room.id, enumerable: true },
620
+ view: { get: () => room.state, enumerable: true },
621
+ reconnects: { get: () => this.reconnectCounts.get(room.me) ?? 0, enumerable: true },
622
+ drop: {
623
+ value: (options2) => this.dropClient(room.me, options2),
624
+ enumerable: true
625
+ }
626
+ });
627
+ this.clientList.push(client);
628
+ return client;
629
+ }
630
+ /**
631
+ * Drives the clock until `joinRoom` settles. A zero-latency join lands inside the first
632
+ * `advance(0)`; a slow link needs the 1 ms steps.
633
+ */
634
+ async awaitJoin(promise) {
635
+ let settled = false;
636
+ let value;
637
+ let error;
638
+ promise.then(
639
+ (v) => {
640
+ settled = true;
641
+ value = v;
642
+ },
643
+ (e) => {
644
+ settled = true;
645
+ error = e instanceof Error ? e : new Error(String(e));
646
+ }
647
+ );
648
+ this.clock.advance(0);
649
+ await microtasks();
650
+ for (let ms = 0; ms < JOIN_TIMEOUT_MS && !settled; ms++) {
651
+ this.clock.advance(1);
652
+ await microtasks();
653
+ }
654
+ if (error !== void 0) throw error;
655
+ if (!settled) throw new Error("testRoom: join never resolved");
656
+ await this.settle();
657
+ return value;
658
+ }
659
+ // -------------------------------------------------------------------------
660
+ // Visibility, convergence, bandwidth
661
+ // -------------------------------------------------------------------------
662
+ /**
663
+ * Judges `clientId` as `role` in `checkVisibility` from now on, without telling the room. The
664
+ * only way to prove the leak detector fires: a correct room never produces a leak, so a test
665
+ * that asserts the checker works has to assert a client against a role it did not join as.
666
+ */
667
+ pretendRole(clientId, role) {
668
+ this.roleOverrides.set(clientId, role);
669
+ }
670
+ roleFor(clientId) {
671
+ return this.roleOverrides.get(clientId) ?? this.roleById.get(clientId) ?? "";
672
+ }
673
+ checkVisibility() {
674
+ const leaks = [...this.frameLeaks];
675
+ for (const client of this.clientList) {
676
+ const role = this.roleFor(client.id);
677
+ const keep = visibleNames(this.ext, role);
678
+ const view = client.view;
679
+ for (const c of this.ext.collections) {
680
+ if (keep.has(c.name)) continue;
681
+ if (c.kind === "entity") {
682
+ const coll = view[c.name];
683
+ if (!coll || coll.size === 0) continue;
684
+ leaks.push({
685
+ clientId: client.id,
686
+ role,
687
+ collection: c.name,
688
+ kind: "view",
689
+ ids: [...coll.ids()],
690
+ tick: this.coreRef.tick
691
+ });
692
+ } else {
693
+ if (singletonIsDefault(c, view[c.name])) continue;
694
+ leaks.push({
695
+ clientId: client.id,
696
+ role,
697
+ collection: c.name,
698
+ kind: "view",
699
+ ids: [""],
700
+ tick: this.coreRef.tick
701
+ });
702
+ }
703
+ }
704
+ }
705
+ return leaks;
706
+ }
707
+ /** Per-client divergence from the authority, restricted to what that client's role may see. */
708
+ convergence() {
709
+ const out = [];
710
+ for (const client of this.clientList) {
711
+ const role = this.roleFor(client.id);
712
+ const view = materialize(this.ext, client.view);
713
+ out.push({
714
+ clientId: client.id,
715
+ differences: divergence(this.ext, this.plain, view, visibleNames(this.ext, role))
716
+ });
717
+ }
718
+ return out;
719
+ }
720
+ /** Outbound bytes per client, and the ticks they were spread over. */
721
+ bandwidth() {
722
+ const ticks = Math.max(1, this.coreRef.tick);
723
+ const totals = /* @__PURE__ */ new Map();
724
+ for (const entry of this.traceLog) {
725
+ if (entry.dir !== "out") continue;
726
+ totals.set(entry.clientId, (totals.get(entry.clientId) ?? 0) + entry.bytes);
727
+ }
728
+ return [...totals].map(([clientId, bytes]) => ({ clientId, bytes, perTick: bytes / ticks }));
729
+ }
730
+ stop() {
731
+ this.stopped = true;
732
+ for (const client of this.clientList) client.leave();
733
+ for (const link of this.links) link.socket.hangUp();
734
+ this.coreRef.stop();
735
+ }
736
+ };
737
+ function configure(definition, options) {
738
+ if (options.mode === void 0 && options.tickRate === void 0) return definition;
739
+ return {
740
+ ...definition,
741
+ config: {
742
+ ...definition.config,
743
+ ...options.mode !== void 0 ? { mode: options.mode } : {},
744
+ ...options.tickRate !== void 0 ? { tickRate: options.tickRate } : {}
745
+ }
746
+ };
747
+ }
748
+
749
+ // src/assertions.ts
750
+ var TRACE_TAIL = 10;
751
+ function harnessOf(received) {
752
+ if (!(received instanceof TestHarness)) {
753
+ throw new TypeError("irtio matchers expect the object returned by testRoom()");
754
+ }
755
+ return received;
756
+ }
757
+ function traceTail(t) {
758
+ const tail = t.trace.slice(-TRACE_TAIL);
759
+ if (tail.length === 0) return "trace: empty";
760
+ const lines = tail.map(
761
+ (e) => ` ${Math.round(e.at)}ms tick ${e.tick} ${e.dir === "in" ? "\u2192" : "\u2190"} ${e.clientId} ${e.frame} ${e.bytes}B`
762
+ );
763
+ return `trace (last ${tail.length}):
764
+ ${lines.join("\n")}`;
765
+ }
766
+ function withTrace(t, body) {
767
+ return `${body}
768
+
769
+ ${traceTail(t)}`;
770
+ }
771
+ function toHaveNoVisibilityLeaks(received) {
772
+ const t = harnessOf(received);
773
+ const leaks = t.checkVisibility();
774
+ return {
775
+ pass: leaks.length === 0,
776
+ message: () => leaks.length === 0 ? withTrace(t, "expected the room to leak something, but every view was role-clean") : withTrace(
777
+ t,
778
+ `expected no visibility leaks, found ${leaks.length}:
779
+ ${leaks.map(
780
+ (l) => ` ${l.clientId} (${l.role}) saw ${l.collection} [${l.ids.join(", ")}] in its ${l.kind} at tick ${l.tick}`
781
+ ).join("\n")}`
782
+ )
783
+ };
784
+ }
785
+ function toHaveConverged(received) {
786
+ const t = harnessOf(received);
787
+ const behind = t.convergence().filter((r) => r.differences.length > 0);
788
+ return {
789
+ pass: behind.length === 0,
790
+ message: () => behind.length === 0 ? withTrace(t, "expected at least one client to differ from the authority, none did") : withTrace(
791
+ t,
792
+ `expected every client to have converged, ${behind.length} had not:
793
+ ${behind.map((r) => ` ${r.clientId}:
794
+ ${r.differences.map((d) => ` ${d}`).join("\n")}`).join("\n")}`
795
+ )
796
+ };
797
+ }
798
+ function toHaveRejected(received, rpcName) {
799
+ const t = harnessOf(received);
800
+ const hits = t.rejections.filter((r) => r.rpc === rpcName);
801
+ const seen = [...new Set(t.rejections.map((r) => r.rpc))];
802
+ return {
803
+ pass: hits.length > 0,
804
+ message: () => hits.length > 0 ? withTrace(
805
+ t,
806
+ `expected no call to ${JSON.stringify(rpcName)} to be rejected, but ${hits.length} was: ${hits.map((h) => `${h.clientId}: ${h.error}`).join("; ")}`
807
+ ) : withTrace(
808
+ t,
809
+ `expected some call to ${JSON.stringify(rpcName)} to be rejected; ` + (seen.length === 0 ? "no rpc was rejected at all" : `rejected rpcs so far: ${seen.join(", ")}`)
810
+ )
811
+ };
812
+ }
813
+ function toStayUnderBandwidth(received, bytesPerTick) {
814
+ const t = harnessOf(received);
815
+ const usage = t.bandwidth();
816
+ const worst = usage.reduce(
817
+ (acc, u) => acc === void 0 || u.perTick > acc.perTick ? u : acc,
818
+ void 0
819
+ );
820
+ const perTick = worst?.perTick ?? 0;
821
+ return {
822
+ pass: perTick <= bytesPerTick,
823
+ message: () => perTick <= bytesPerTick ? withTrace(
824
+ t,
825
+ `expected some client to exceed ${bytesPerTick} B/tick, the worst was ${worst?.clientId ?? "nobody"} at ${perTick.toFixed(1)} B/tick`
826
+ ) : withTrace(
827
+ t,
828
+ `expected every client to stay under ${bytesPerTick} B/tick, but ${worst?.clientId} received ${worst?.bytes} B over ${t.tickCount} ticks (${perTick.toFixed(1)} B/tick)`
829
+ )
830
+ };
831
+ }
832
+ var matchers = {
833
+ toHaveNoVisibilityLeaks,
834
+ toHaveConverged,
835
+ toHaveRejected,
836
+ toStayUnderBandwidth
837
+ };
838
+
839
+ export {
840
+ materialize,
841
+ divergence,
842
+ InProcessSocket,
843
+ clockScheduler,
844
+ TestHarness,
845
+ TRACE_TAIL,
846
+ toHaveNoVisibilityLeaks,
847
+ toHaveConverged,
848
+ toHaveRejected,
849
+ toStayUnderBandwidth,
850
+ matchers
851
+ };