@irtio/testing 0.11.0 → 3.0.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.
@@ -71,6 +71,9 @@ function singletonIsDefault(desc, value) {
71
71
  return deepEqual(value, defaultRecord(desc));
72
72
  }
73
73
 
74
+ // src/contract.ts
75
+ var DEFAULT_AUTO_PUMP_TICKS = 200;
76
+
74
77
  // src/scheduler.ts
75
78
  function clockScheduler(clock) {
76
79
  return {
@@ -83,7 +86,7 @@ function clockScheduler(clock) {
83
86
  }
84
87
 
85
88
  // src/harness.ts
86
- import { joinRoom } from "@irtio/client";
89
+ import { INTERNAL_SESSION, joinRoom } from "@irtio/client";
87
90
  import {
88
91
  ErrorCode,
89
92
  FrameType,
@@ -154,6 +157,84 @@ var InProcessSocket = class {
154
157
  }
155
158
  };
156
159
 
160
+ // src/physics.ts
161
+ function vec(v) {
162
+ return { x: v.x, y: v.y, z: v.z ?? 0 };
163
+ }
164
+ function createTestPhysics(core) {
165
+ const physicsOf = () => {
166
+ const p = core.physics;
167
+ if (!p) {
168
+ throw new Error(
169
+ "t.physics: this room declares no `physics` config, so there is no world to read or push. Add one to the room definition, or drop the t.physics call."
170
+ );
171
+ }
172
+ return p;
173
+ };
174
+ const bodyOf = (collection, id) => {
175
+ const p = physicsOf();
176
+ const body = p.bodyFor(collection, id);
177
+ if (body === void 0 || body === null) {
178
+ throw new Error(
179
+ `t.physics: no body for ${collection}[${id}]. Either the id is not in that collection, or the collection is not body-backed (the schema needs a \`physics: { body: \u2026 }\` on it).`
180
+ );
181
+ }
182
+ return body;
183
+ };
184
+ return {
185
+ get engine() {
186
+ return core.physics?.engineKind;
187
+ },
188
+ position(collection, id) {
189
+ const body = bodyOf(collection, id);
190
+ if (physicsOf().engineKind === "matter2d") return vec(body.position);
191
+ return vec(body.translation());
192
+ },
193
+ velocity(collection, id) {
194
+ const body = bodyOf(collection, id);
195
+ if (physicsOf().engineKind === "matter2d") return vec(body.velocity);
196
+ return vec(body.linvel());
197
+ },
198
+ teleport(collection, id, to) {
199
+ const p = physicsOf();
200
+ const body = bodyOf(collection, id);
201
+ if (p.engineKind === "matter2d") {
202
+ matterOf(p).Body.setPosition(body, { x: to.x, y: to.y });
203
+ return;
204
+ }
205
+ body.setTranslation(
206
+ p.engineKind === "rapier3d" ? { x: to.x, y: to.y, z: to.z ?? 0 } : { x: to.x, y: to.y },
207
+ true
208
+ );
209
+ },
210
+ impulse(collection, id, by) {
211
+ const p = physicsOf();
212
+ const body = bodyOf(collection, id);
213
+ if (p.engineKind === "matter2d") {
214
+ const m = body;
215
+ matterOf(p).Body.setVelocity(body, {
216
+ x: m.velocity.x + by.x / m.mass,
217
+ y: m.velocity.y + by.y / m.mass
218
+ });
219
+ return;
220
+ }
221
+ body.applyImpulse(
222
+ p.engineKind === "rapier3d" ? { x: by.x, y: by.y, z: by.z ?? 0 } : { x: by.x, y: by.y },
223
+ true
224
+ );
225
+ }
226
+ };
227
+ }
228
+ function matterOf(p) {
229
+ const matter = p.matter;
230
+ if (!matter) {
231
+ throw new Error(
232
+ "t.physics: this room runs matter2d but the matter-js namespace is not loaded. Await `initMatter()` in a `beforeAll` before building the room."
233
+ );
234
+ }
235
+ return matter;
236
+ }
237
+
157
238
  // src/harness.ts
158
239
  var TEST_URL = "ws://localhost:7070";
159
240
  var TEST_KEY = "test";
@@ -205,7 +286,14 @@ var TestHarness = class {
205
286
  roleOverrides = /* @__PURE__ */ new Map();
206
287
  traceLog = [];
207
288
  frameLeaks = [];
208
- spatialSeen = /* @__PURE__ */ new Map();
289
+ /**
290
+ * Record ids this harness has actually delivered to a client, per collection. Started as the
291
+ * join snapshot's ids and kept current by `checkFrame`. It was a spatial-grid-only ledger; every
292
+ * collection is tracked now, because a `room.setRole` demotion catches the client up with
293
+ * `remove` ops for a collection its new role cannot see, and those ids are exactly the ones it
294
+ * was legitimately sent before.
295
+ */
296
+ deliveredSeen = /* @__PURE__ */ new Map();
209
297
  rejectionLog = [];
210
298
  /** Every handler throw the runtime caught, in order (bug 2). */
211
299
  handlerErrorLog = [];
@@ -227,6 +315,11 @@ var TestHarness = class {
227
315
  writeIntervalMs;
228
316
  latency;
229
317
  failOnHandlerError;
318
+ /** M7 pf4 (f): auto-pump settings, resolved once. */
319
+ autoPump;
320
+ autoPumpTicks;
321
+ /** M7 pf4 (g): built once, lazily reading `core.physics` on every call. */
322
+ physicsRef;
230
323
  stopped = false;
231
324
  constructor(definition, options) {
232
325
  this.definition = configure(definition, options);
@@ -234,6 +327,8 @@ var TestHarness = class {
234
327
  this.latency = options.latency;
235
328
  this.writeIntervalMs = options.writeIntervalMs;
236
329
  this.failOnHandlerError = options.failOnHandlerError ?? true;
330
+ this.autoPump = options.autoPump ?? true;
331
+ this.autoPumpTicks = options.autoPumpTicks ?? DEFAULT_AUTO_PUMP_TICKS;
237
332
  this.rng = new Mulberry32(options.seed ?? 1);
238
333
  this.scheduler = clockScheduler(this.clock);
239
334
  this.host = new HarnessHost(this.clock);
@@ -248,8 +343,12 @@ var TestHarness = class {
248
343
  this.coreRef = new RoomCore(this.definition, this.host, {
249
344
  roomId: this.roomId,
250
345
  ...options.seed !== void 0 ? { seed: options.seed } : {},
251
- ...options.profile === true ? { profile: true } : {}
346
+ ...options.profile === true ? { profile: true } : {},
347
+ ...options.traceRpc === true ? { traceRpc: true } : {}
252
348
  });
349
+ this.physicsRef = createTestPhysics(
350
+ this.coreRef
351
+ );
253
352
  this.host.core = this.coreRef;
254
353
  this.host.serializeForSave = () => this.coreRef.snapshot();
255
354
  this.coreRef.onHandlerError = (handler, err) => {
@@ -308,6 +407,14 @@ var TestHarness = class {
308
407
  get profile() {
309
408
  return this.coreRef.profile();
310
409
  }
410
+ /** M7 pf4 (g): the narrow physics handle. Always present; its methods refuse with a reason. */
411
+ get physics() {
412
+ return this.physicsRef;
413
+ }
414
+ /** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
415
+ get rpcTrace() {
416
+ return this.coreRef.rpcTrace();
417
+ }
311
418
  // -------------------------------------------------------------------------
312
419
  // The core is never re-entered
313
420
  // -------------------------------------------------------------------------
@@ -555,11 +662,11 @@ var TestHarness = class {
555
662
  const decodedJoin = decodeSnapshot(this.ext, joined.snapshot).state;
556
663
  const seen = /* @__PURE__ */ new Map();
557
664
  for (const desc of this.ext.collections) {
558
- if (desc.visibility !== "spatial-grid") continue;
665
+ if (desc.kind !== "entity") continue;
559
666
  const collection = decodedJoin[desc.name];
560
- seen.set(desc.name, new Set(collection.ids()));
667
+ seen.set(desc.name, new Set(collection ? collection.ids() : []));
561
668
  }
562
- this.spatialSeen.set(link.clientId, seen);
669
+ this.deliveredSeen.set(link.clientId, seen);
563
670
  this.roleById.set(link.clientId, joined.role);
564
671
  if (reconnecting) {
565
672
  this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
@@ -607,6 +714,36 @@ var TestHarness = class {
607
714
  this.record("out", link.clientId, type, frame.length);
608
715
  this.schedule(link, "out", type, () => link.socket.deliver(frame));
609
716
  }
717
+ /**
718
+ * The room's own role for this client **right now**, not the one its WELCOME carried.
719
+ *
720
+ * `room.setRole` commits `entry.role` before it hands the catch-up `DELTA` to `core.send`, so by
721
+ * the time that frame reaches `onSend` the authoritative role is already the new one. Judging it
722
+ * against the WELCOME-cached role called every legitimate promotion catch-up a leak. Reading the
723
+ * core here keeps `link.role`/`roleById` truthful too, so `roleFor` and the view half of
724
+ * `checkVisibility` follow a mid-session promotion without a separate hook.
725
+ */
726
+ currentRole(link) {
727
+ const role = this.coreRef.clients.get(link.clientId)?.role;
728
+ if (role === void 0 || role === link.role) return link.role;
729
+ link.role = role;
730
+ this.roleById.set(link.clientId, role);
731
+ return role;
732
+ }
733
+ /** The live "already delivered" id set for one client and collection, created on demand. */
734
+ deliveredIds(clientId, collection) {
735
+ let byCollection = this.deliveredSeen.get(clientId);
736
+ if (!byCollection) {
737
+ byCollection = /* @__PURE__ */ new Map();
738
+ this.deliveredSeen.set(clientId, byCollection);
739
+ }
740
+ let ids = byCollection.get(collection);
741
+ if (!ids) {
742
+ ids = /* @__PURE__ */ new Set();
743
+ byCollection.set(collection, ids);
744
+ }
745
+ return ids;
746
+ }
610
747
  /** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
611
748
  checkFrame(link, frame) {
612
749
  let delta;
@@ -615,27 +752,31 @@ var TestHarness = class {
615
752
  } catch {
616
753
  return;
617
754
  }
618
- const keep = visibleNames(this.ext, link.role);
619
- const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, link.role);
755
+ const role = this.currentRole(link);
756
+ const keep = visibleNames(this.ext, role);
757
+ const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, role);
620
758
  for (const dc of delta.collections) {
621
759
  const desc = this.ext.collection(dc.name);
622
- const seen = this.spatialSeen.get(link.clientId)?.get(dc.name) ?? /* @__PURE__ */ new Set();
760
+ const seen = this.deliveredIds(link.clientId, dc.name);
623
761
  const leaked = dc.ops.filter((op) => {
762
+ if (op.op === "remove") {
763
+ if (keep.has(dc.name)) return desc.visibility === "spatial-grid" && !seen.has(op.id);
764
+ return !seen.has(op.id);
765
+ }
624
766
  if (!keep.has(dc.name)) return true;
625
767
  if (desc.visibility !== "spatial-grid") return false;
626
- if (op.op === "remove") return !seen.has(op.id);
627
768
  return !policy.maySeeEntity(desc, op.id);
628
769
  });
629
- if (desc.visibility === "spatial-grid") {
630
- for (const op of dc.ops) {
631
- if (op.op === "remove") seen.delete(op.id);
632
- else if (policy.maySeeEntity(desc, op.id)) seen.add(op.id);
770
+ for (const op of dc.ops) {
771
+ if (op.op === "remove") seen.delete(op.id);
772
+ else if (desc.visibility !== "spatial-grid" || policy.maySeeEntity(desc, op.id)) {
773
+ seen.add(op.id);
633
774
  }
634
775
  }
635
776
  if (leaked.length === 0) continue;
636
777
  this.frameLeaks.push({
637
778
  clientId: link.clientId,
638
- role: link.role,
779
+ role,
639
780
  collection: dc.name,
640
781
  kind: "frame",
641
782
  ids: leaked.map((o) => o.id),
@@ -755,6 +896,70 @@ var TestHarness = class {
755
896
  }
756
897
  return this.joinOne(a ?? {});
757
898
  }
899
+ /**
900
+ * M7 pf4 (f): a `Promise`-shaped thing that advances the fake clock while it is being awaited.
901
+ *
902
+ * Lazy on purpose. The pump starts on the first `then` — that is, when somebody awaits the call
903
+ * — and not when the call is made, so a test can still hold the promise, assert on the state
904
+ * before the room has answered, and drive the clock itself. Eager pumping would have taken that
905
+ * away, and it is the one thing the manual boilerplate was good for.
906
+ *
907
+ * The cap is named in the rejection because a room that never replies is a bug in the room, and
908
+ * a harness that spun on it forever would report the wrong thing in the wrong place.
909
+ *
910
+ * `label` names the operation being awaited (`client.call.start()`, `client.requestOwnership()`,
911
+ * `client.leave()`). Every awaitable the `TestClient` exposes comes through here, so a hang has
912
+ * to say which one hung; the first report of this was a bare vitest timeout on a
913
+ * `requestOwnership` that the harness had never pumped at all.
914
+ */
915
+ pumped(inner, label) {
916
+ if (!this.autoPump) return inner;
917
+ let settled;
918
+ inner.then(
919
+ (value) => {
920
+ settled = { ok: true, value };
921
+ },
922
+ (error) => {
923
+ settled = { ok: false, error };
924
+ }
925
+ );
926
+ const cap = this.autoPumpTicks;
927
+ const drive = async () => {
928
+ const step = this.mode === "tick" ? this.intervalMs : 1;
929
+ await this.settle();
930
+ for (let i = 0; i < cap && settled === void 0; i++) {
931
+ this.clock.advance(step);
932
+ await this.settle();
933
+ }
934
+ if (settled === void 0) {
935
+ throw new Error(
936
+ `testRoom: ${label} never settled \u2014 ${cap} ticks of auto-pumping (${cap * step} ms of fake time) and the room has not answered. Either the handler never returns, or this needs testRoom({ autoPumpTicks }) raised, or autoPump: false and your own clock.`
937
+ );
938
+ }
939
+ if (settled.ok) return settled.value;
940
+ throw settled.error;
941
+ };
942
+ let started;
943
+ const run = () => started ??= drive();
944
+ const thenable = {
945
+ // biome-ignore lint/suspicious/noThenProperty: a lazy thenable is exactly the point — a Promise subclass runs its executor eagerly, which is the property the auto-pump must not have.
946
+ then: (onOk, onErr) => run().then(onOk, onErr),
947
+ catch: (onErr) => run().catch(onErr),
948
+ finally: (onDone) => run().finally(onDone)
949
+ };
950
+ return thenable;
951
+ }
952
+ /**
953
+ * M7 pf4 (e): fire the ping now. See `TestRoom.pingNow` for why a test would want to.
954
+ */
955
+ async pingNow(clientId) {
956
+ for (const client of this.clientList) {
957
+ if (clientId !== void 0 && client.id !== clientId) continue;
958
+ const session = client[INTERNAL_SESSION];
959
+ session?.pingNow();
960
+ }
961
+ await this.settle();
962
+ }
758
963
  async joinOne(spec) {
759
964
  const options = {
760
965
  url: TEST_URL,
@@ -777,7 +982,30 @@ var TestHarness = class {
777
982
  marginMs: (latency?.rttMs ?? 0) + (latency?.jitterMs ?? 0)
778
983
  });
779
984
  const client = Object.create(room);
985
+ const call = new Proxy(
986
+ {},
987
+ {
988
+ get: (_target, prop) => {
989
+ if (typeof prop !== "string") return void 0;
990
+ const method = room.call[prop];
991
+ if (typeof method !== "function") return method;
992
+ return (params) => this.pumped(
993
+ method(params),
994
+ `client.call.${prop}()`
995
+ );
996
+ }
997
+ }
998
+ );
780
999
  Object.defineProperties(client, {
1000
+ call: { get: () => call, enumerable: true },
1001
+ requestOwnership: {
1002
+ value: (entity, id) => this.pumped(room.requestOwnership(entity, id), "client.requestOwnership()"),
1003
+ enumerable: true
1004
+ },
1005
+ leave: {
1006
+ value: () => this.pumped(room.leave(), "client.leave()"),
1007
+ enumerable: true
1008
+ },
781
1009
  id: { get: () => room.me, enumerable: true },
782
1010
  roomId: { get: () => room.id, enumerable: true },
783
1011
  view: { get: () => room.state, enumerable: true },
@@ -1127,6 +1355,7 @@ var matchers = {
1127
1355
  export {
1128
1356
  materialize,
1129
1357
  divergence,
1358
+ DEFAULT_AUTO_PUMP_TICKS,
1130
1359
  InProcessSocket,
1131
1360
  clockScheduler,
1132
1361
  TestHarness,
package/dist/index.d.ts CHANGED
@@ -1,34 +1,56 @@
1
1
  import { RoleOf, AnySchema, State, PlainState } from '@irtio/schema';
2
2
  import { RoomDefinition } from '@irtio/server';
3
+ import * as _irtio_runtime from '@irtio/runtime';
4
+ import { RoomCore, RpcTraceDump } from '@irtio/runtime';
3
5
  import { Room, ClientState, RelayRoom, Scheduler } from '@irtio/client';
4
6
  import { ProfileSnapshot } from '@irtio/protocol';
5
- import { RoomCore } from '@irtio/runtime';
6
7
  import { TraceEntry, HarnessHost, VisibilityLeak, FakeClock } from '@irtio/runtime/test';
7
8
  export { TraceEntry, VisibilityLeak, initMatter, initPhysics, initRapier2d } from '@irtio/runtime/test';
8
9
  export { M as MatcherResult, P as PredictionBounds, T as TRACE_TAIL, m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth, d as toStayWithinPrediction } from './assertions-Cj1Sejhj.js';
9
10
 
10
11
  /**
11
- * `@irtio/testing`'s public contract: what `testRoom` hands back, and the two option bags.
12
+ * M7 pf4 (g): `t.physics` — a narrow, engine-neutral handle on the room's live world.
12
13
  *
13
- * The shapes here are the surface the canonical usage example relies on (the docblock in
14
- * `index.ts`). Two of them differ from the shipped client and the difference is deliberate:
14
+ * Deliberately four things and no more: read a body's position, read its velocity, teleport it,
15
+ * push it. It is **not** a general mid-tick mutation escape hatch and it hands out no raw world
16
+ * handle, because the moment it does, every test that reaches through it is written against one
17
+ * engine's API and the harness has quietly become a Rapier test harness. A test that genuinely
18
+ * needs the world still has `t.core.physics` — one level down, and obviously so.
15
19
  *
16
- * - **`client.id` is the *client* id.** The example writes `expect([a.id, b.id]).toContain(...)` against an
17
- * owner, so `a.id` has to be `room.me`. `@irtio/client`'s `Room.id` is the *room code*, so a
18
- * `TestClient` shadows it and re-exposes the room code as `client.roomId`.
19
- * - **`client.view` is `room.state`.** The example reads `a.view.cards.get('c1')`; the shipped client calls
20
- * that `room.state`. Both names point at the same object.
20
+ * The vectors are 3D-shaped with `z` optional. A 2D engine ignores `z` on the way in and reports
21
+ * `0` on the way out; that is one shape for callers instead of a union they have to narrow, and
22
+ * the alternative (a `z` that silently means something in one engine) is worse than a zero.
21
23
  *
22
- * Two more things live here beyond that example:
23
- *
24
- * - **Reconnection.** `TestClient.drop()` severs a client's in-process transport the way a real
25
- * socket drop would, so the shipped client's own backoff runs on the harness's fake clock —
26
- * `await t.run(300)` is enough to see it reconnect. `TestClient.reconnects` counts how many
27
- * times it has.
28
- * - **Relay.** `testRelay()` is the sibling of `testRoom()` for relay rooms: no schema, no
29
- * state, no RPCs — presence and a raw message channel over the same in-process, fake-clock link.
24
+ * Everything refuses by *saying what is wrong*: a room with no physics, a collection with no
25
+ * body-backed entity, an id that is not there. A test that quietly did nothing would be the worst
26
+ * outcome here, since the assertion after it would fail somewhere else entirely.
30
27
  */
31
28
 
29
+ /** A position or a velocity. `z` is `0` on a 2D engine. */
30
+ interface TestVector {
31
+ readonly x: number;
32
+ readonly y: number;
33
+ readonly z: number;
34
+ }
35
+ /** What you may pass in. `z` is ignored by the 2D engines. */
36
+ interface TestVectorInput {
37
+ readonly x: number;
38
+ readonly y: number;
39
+ readonly z?: number;
40
+ }
41
+ interface TestPhysics {
42
+ /** Which engine this room runs, or `undefined` when it declares no physics at all. */
43
+ readonly engine: 'rapier3d' | 'matter2d' | 'rapier2d' | undefined;
44
+ /** The body's position. Throws when the room has no physics or the body is not there. */
45
+ position(collection: string, id: string): TestVector;
46
+ /** The body's linear velocity. */
47
+ velocity(collection: string, id: string): TestVector;
48
+ /** Moves the body, waking it. The schema catches up on the room's next `sync()` — one tick. */
49
+ teleport(collection: string, id: string, to: TestVectorInput): void;
50
+ /** Applies a linear impulse at the body's centre of mass, waking it. */
51
+ impulse(collection: string, id: string, by: TestVectorInput): void;
52
+ }
53
+
32
54
  /**
33
55
  * Network simulation for the in-process link. Delays are applied on the **fake clock**, so a
34
56
  * `rttMs: 100` room still runs at full speed — it just needs 100 ms of `t.run()` to see a reply.
@@ -46,6 +68,8 @@ interface LatencySpec {
46
68
  */
47
69
  readonly loss?: number;
48
70
  }
71
+ /** M7 pf4 (f): how many ticks `await client.call.*()` will pump before it gives up. */
72
+ declare const DEFAULT_AUTO_PUMP_TICKS = 200;
49
73
  interface TestRoomOptions {
50
74
  /** Overrides the room definition's mode. The definition is **not** re-validated. */
51
75
  readonly mode?: 'tick' | 'event';
@@ -75,6 +99,31 @@ interface TestRoomOptions {
75
99
  * costs nothing — the room constructs no ledger and walks no frame.
76
100
  */
77
101
  readonly profile?: boolean;
102
+ /**
103
+ * M7 pf4 (f): while an `await`ed `client.call.*()` reply is pending, advance the fake clock
104
+ * automatically so the reply can arrive. Default `true`.
105
+ *
106
+ * Without it the boilerplate every RPC test carries is `const p = a.call.x(); await t.tick();
107
+ * await p;` — three lines that say nothing about the test, and one of which is easy to forget in
108
+ * a way that hangs. With it, `await a.call.x()` does what it looks like it does.
109
+ *
110
+ * The pump is lazy: it starts when the returned promise is first awaited, never at the call. So
111
+ * a test that wants to assert on state *between* sending the call and the room answering still
112
+ * can — hold the promise, assert, then advance the clock yourself. `autoPump: false` turns it
113
+ * off entirely for a test whose subject is that ordering.
114
+ */
115
+ readonly autoPump?: boolean;
116
+ /**
117
+ * M7 pf4 (f): how many ticks the auto-pump will advance before giving up, naming this cap in
118
+ * the rejection. Default {@link DEFAULT_AUTO_PUMP_TICKS}. A room that never replies is a bug in
119
+ * the room, and a test that spins forever on one reports the wrong thing.
120
+ */
121
+ readonly autoPumpTicks?: number;
122
+ /**
123
+ * M7 pf4: run the room with an RPC trace ring, readable as `t.rpcTrace`. Off by default, and
124
+ * off costs nothing — the same knob `irtio dev --trace-rpc` sets.
125
+ */
126
+ readonly traceRpc?: boolean;
78
127
  }
79
128
  interface TestJoinSpec<Role extends string = string> {
80
129
  readonly role?: Role;
@@ -228,6 +277,29 @@ interface TestRoom<S extends AnySchema> {
228
277
  bytes: number;
229
278
  perTick: number;
230
279
  }[];
280
+ /**
281
+ * M7 pf4 (g): a narrow handle on the room's live physics world — read a body's position and
282
+ * velocity, teleport it, push it. Deliberately not a general mutation hatch and deliberately no
283
+ * raw world handle; `t.core.physics` is one level down for a test that really needs it.
284
+ */
285
+ readonly physics: TestPhysics;
286
+ /**
287
+ * M7 pf4: the room's RPC trace, or `undefined` unless `testRoom({ traceRpc: true })` armed one.
288
+ * `undefined` and "empty" are different readings, exactly as `profile` is.
289
+ */
290
+ readonly rpcTrace: _irtio_runtime.RpcTraceDump | undefined;
291
+ /**
292
+ * M7 pf4 (e): fire each client's 2 s ping now, without waiting for its timer.
293
+ *
294
+ * A client's idea of the server's tick — and therefore the stamp it puts on its `CALL`s — is
295
+ * refreshed by a `DELTA` or by the `PONG` a ping draws. In a **quiet** room only the ping does
296
+ * it, so stamp staleness saws between zero and one ping interval and a test of it lands
297
+ * wherever the phase happens to put it. This pins the phase: `await t.pingNow()` puts every
298
+ * client at lag zero, and `await t.run(n)` afterwards puts them at a lag you chose.
299
+ *
300
+ * Pass a client id to ping just that one.
301
+ */
302
+ pingNow(clientId?: string): Promise<void>;
231
303
  /** Every client leaves and the room stops. */
232
304
  stop(): void;
233
305
  }
@@ -339,7 +411,14 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
339
411
  private readonly roleOverrides;
340
412
  private readonly traceLog;
341
413
  private readonly frameLeaks;
342
- private readonly spatialSeen;
414
+ /**
415
+ * Record ids this harness has actually delivered to a client, per collection. Started as the
416
+ * join snapshot's ids and kept current by `checkFrame`. It was a spatial-grid-only ledger; every
417
+ * collection is tracked now, because a `room.setRole` demotion catches the client up with
418
+ * `remove` ops for a collection its new role cannot see, and those ids are exactly the ones it
419
+ * was legitimately sent before.
420
+ */
421
+ private readonly deliveredSeen;
343
422
  private readonly rejectionLog;
344
423
  /** Every handler throw the runtime caught, in order (bug 2). */
345
424
  private readonly handlerErrorLog;
@@ -361,6 +440,11 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
361
440
  private readonly writeIntervalMs;
362
441
  private readonly latency;
363
442
  private readonly failOnHandlerError;
443
+ /** M7 pf4 (f): auto-pump settings, resolved once. */
444
+ private readonly autoPump;
445
+ private readonly autoPumpTicks;
446
+ /** M7 pf4 (g): built once, lazily reading `core.physics` on every call. */
447
+ private readonly physicsRef;
364
448
  private stopped;
365
449
  constructor(definition: RoomDefinition<S>, options: TestRoomOptions);
366
450
  get core(): RoomCore<S>;
@@ -379,6 +463,10 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
379
463
  get ext(): AnySchema;
380
464
  /** D65: the room's bandwidth ledger, or `undefined` unless `profile: true` asked for one. */
381
465
  get profile(): ProfileSnapshot | undefined;
466
+ /** M7 pf4 (g): the narrow physics handle. Always present; its methods refuse with a reason. */
467
+ get physics(): TestPhysics;
468
+ /** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
469
+ get rpcTrace(): RpcTraceDump | undefined;
382
470
  private enterCore;
383
471
  /** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
384
472
  private transportFor;
@@ -423,6 +511,18 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
423
511
  private handleHello;
424
512
  private fail;
425
513
  private toClient;
514
+ /**
515
+ * The room's own role for this client **right now**, not the one its WELCOME carried.
516
+ *
517
+ * `room.setRole` commits `entry.role` before it hands the catch-up `DELTA` to `core.send`, so by
518
+ * the time that frame reaches `onSend` the authoritative role is already the new one. Judging it
519
+ * against the WELCOME-cached role called every legitimate promotion catch-up a leak. Reading the
520
+ * core here keeps `link.role`/`roleById` truthful too, so `roleFor` and the view half of
521
+ * `checkVisibility` follow a mid-session promotion without a separate hook.
522
+ */
523
+ private currentRole;
524
+ /** The live "already delivered" id set for one client and collection, created on demand. */
525
+ private deliveredIds;
426
526
  /** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
427
527
  private checkFrame;
428
528
  private noteReply;
@@ -452,6 +552,27 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
452
552
  join<Role extends string = RoleOf<S> & string>(spec?: TestJoinSpec<Role> & {
453
553
  role?: RoleOf<S> & string;
454
554
  }): Promise<TestClient<S, Role>>;
555
+ /**
556
+ * M7 pf4 (f): a `Promise`-shaped thing that advances the fake clock while it is being awaited.
557
+ *
558
+ * Lazy on purpose. The pump starts on the first `then` — that is, when somebody awaits the call
559
+ * — and not when the call is made, so a test can still hold the promise, assert on the state
560
+ * before the room has answered, and drive the clock itself. Eager pumping would have taken that
561
+ * away, and it is the one thing the manual boilerplate was good for.
562
+ *
563
+ * The cap is named in the rejection because a room that never replies is a bug in the room, and
564
+ * a harness that spun on it forever would report the wrong thing in the wrong place.
565
+ *
566
+ * `label` names the operation being awaited (`client.call.start()`, `client.requestOwnership()`,
567
+ * `client.leave()`). Every awaitable the `TestClient` exposes comes through here, so a hang has
568
+ * to say which one hung; the first report of this was a bare vitest timeout on a
569
+ * `requestOwnership` that the harness had never pumped at all.
570
+ */
571
+ private pumped;
572
+ /**
573
+ * M7 pf4 (e): fire the ping now. See `TestRoom.pingNow` for why a test would want to.
574
+ */
575
+ pingNow(clientId?: string): Promise<void>;
455
576
  private joinOne;
456
577
  /**
457
578
  * Drives the clock until `joinRoom` settles. A zero-latency join lands inside the first
@@ -648,4 +769,4 @@ declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>, op
648
769
  /** Starts a schema-less relay room in-process and returns the test handle. See the module docblock. */
649
770
  declare function testRelay(options?: TestRelayOptions): Promise<TestRelay>;
650
771
 
651
- export { type HandlerError, type LatencySpec, type PredictionStats, 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 };
772
+ export { DEFAULT_AUTO_PUMP_TICKS, type HandlerError, type LatencySpec, type PredictionStats, type RejectedCall, type TestClient, TestHarness, type TestJoinSpec, type TestPhysics, type TestRelay, type TestRelayClient, TestRelayHarness, type TestRelayJoinSpec, type TestRelayOptions, type TestRoom, type TestRoomOptions, type TestVector, type TestVectorInput, type UntilOptions, clockScheduler, divergence, materialize, testRelay, testRoom };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_AUTO_PUMP_TICKS,
2
3
  InProcessSocket,
3
4
  TRACE_TAIL,
4
5
  TestHarness,
@@ -11,7 +12,7 @@ import {
11
12
  toHaveRejected,
12
13
  toStayUnderBandwidth,
13
14
  toStayWithinPrediction
14
- } from "./chunk-YMZ3FZYM.js";
15
+ } from "./chunk-3W5KPX3I.js";
15
16
 
16
17
  // src/relay-harness.ts
17
18
  import { joinRelay } from "@irtio/client";
@@ -458,6 +459,7 @@ async function testRelay(options = {}) {
458
459
  return new TestRelayHarness(options);
459
460
  }
460
461
  export {
462
+ DEFAULT_AUTO_PUMP_TICKS,
461
463
  TRACE_TAIL,
462
464
  TestHarness,
463
465
  TestRelayHarness,
package/dist/matchers.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  toHaveRejected,
6
6
  toStayUnderBandwidth,
7
7
  toStayWithinPrediction
8
- } from "./chunk-YMZ3FZYM.js";
8
+ } from "./chunk-3W5KPX3I.js";
9
9
 
10
10
  // src/matchers.ts
11
11
  import { expect } from "vitest";
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@irtio/testing",
3
- "version": "0.11.0",
3
+ "version": "3.0.0",
4
4
  "description": "irtio's blessed test API: testRoom() in-process rooms with real client semantics, plus Vitest matchers",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alex-irt/irtio.git",
9
+ "directory": "packages/testing"
10
+ },
6
11
  "publishConfig": {
7
12
  "access": "public"
8
13
  },
@@ -23,11 +28,11 @@
23
28
  "dist"
24
29
  ],
25
30
  "dependencies": {
26
- "@irtio/protocol": "0.11.0",
27
- "@irtio/client": "0.11.0",
28
- "@irtio/runtime": "0.11.0",
29
- "@irtio/schema": "0.11.0",
30
- "@irtio/server": "0.11.0"
31
+ "@irtio/client": "3.0.0",
32
+ "@irtio/protocol": "3.0.0",
33
+ "@irtio/runtime": "3.0.0",
34
+ "@irtio/schema": "3.0.0",
35
+ "@irtio/server": "3.0.0"
31
36
  },
32
37
  "peerDependencies": {
33
38
  "vitest": ">=3"