@irtio/testing 0.1.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The four matchers, as plain functions.
2
+ * The matchers, as plain functions.
3
3
  *
4
4
  * They live apart from `matchers.ts` so the package's main entry can export them without importing
5
5
  * Vitest: `@irtio/testing` has Vitest as an *optional* peer, and a bot or a plain Node script that
@@ -18,12 +18,29 @@ interface MatcherResult {
18
18
  declare const TRACE_TAIL = 10;
19
19
  /** No client can see a collection its role must not — in its view or in a frame it received. */
20
20
  declare function toHaveNoVisibilityLeaks(received: unknown): MatcherResult;
21
- /** Every client's view equals the authority projected to that client's role. */
21
+ /**
22
+ * Every client's view equals the authority projected to that client's role — and, for clients
23
+ * whose state stream has been idle at least `interpDelayMs` of fake time, the interpolated
24
+ * `room.render` path too (a mid-interpolation client is legitimately behind and is skipped).
25
+ */
22
26
  declare function toHaveConverged(received: unknown): MatcherResult;
23
27
  /** Some call to `rpcName` came back as an error `REPLY`. */
24
28
  declare function toHaveRejected(received: unknown, rpcName: string): MatcherResult;
25
29
  /** No client received more than `bytesPerTick` outbound bytes per room tick, on average. */
26
30
  declare function toStayUnderBandwidth(received: unknown, bytesPerTick: number): MatcherResult;
31
+ /** Options for `toStayWithinPrediction`. An omitted bound is not checked. */
32
+ interface PredictionBounds {
33
+ /** Numeric units a single correction may snap a client's prediction. */
34
+ readonly maxMagnitude?: number;
35
+ /** Corrections that may outrun the client's resim window (snap instead of replaying). */
36
+ readonly maxSnaps?: number;
37
+ }
38
+ /**
39
+ * Every client's mispredictions stayed within bounds: no single correction snapped further than
40
+ * `maxMagnitude` numeric units, and no client snapped past the resim window more than `maxSnaps`
41
+ * times. Reads `t.predictionStats()`, which each client's `correct` events feed.
42
+ */
43
+ declare function toStayWithinPrediction(received: unknown, opts: PredictionBounds): MatcherResult;
27
44
  /**
28
45
  * The matcher table. Pass it to `expect.extend` yourself, or import `@irtio/testing/matchers`
29
46
  * for the side effect.
@@ -33,6 +50,7 @@ declare const matchers: {
33
50
  toHaveConverged: typeof toHaveConverged;
34
51
  toHaveRejected: typeof toHaveRejected;
35
52
  toStayUnderBandwidth: typeof toStayUnderBandwidth;
53
+ toStayWithinPrediction: typeof toStayWithinPrediction;
36
54
  };
37
55
 
38
- export { type MatcherResult as M, TRACE_TAIL as T, toHaveNoVisibilityLeaks as a, toHaveRejected as b, toStayUnderBandwidth as c, matchers as m, toHaveConverged as t };
56
+ export { type MatcherResult as M, type PredictionBounds as P, TRACE_TAIL as T, toHaveNoVisibilityLeaks as a, toHaveRejected as b, toStayUnderBandwidth as c, toStayWithinPrediction as d, matchers as m, toHaveConverged as t };
@@ -41,13 +41,21 @@ function materialize(ext, view) {
41
41
  }
42
42
  return out;
43
43
  }
44
- function divergence(ext, authority, client, visible) {
44
+ function divergence(ext, authority, client, visible, memberships = /* @__PURE__ */ new Map()) {
45
45
  const out = [];
46
46
  for (const [name, cd] of computeDirty(ext, authority, client)) {
47
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`);
48
+ const desc = ext.collection(name);
49
+ const membership = desc.visibility === "spatial-grid" ? memberships.get(name) : void 0;
50
+ for (const id of cd.added) {
51
+ if (!membership || !membership.has(id)) out.push(`${name}[${id}]: only in the client's view`);
52
+ }
53
+ for (const id of cd.removed) {
54
+ if (!membership || membership.has(id))
55
+ out.push(`${name}[${id}]: missing from the client's view`);
56
+ }
50
57
  for (const [id, rd] of cd.updated) {
58
+ if (membership && !membership.has(id)) continue;
51
59
  const fields = [...rd.mask.fields].map((i) => fieldNameAt(ext, name, i)).filter((n) => n !== void 0);
52
60
  const what = rd.owner ? [...fields, "owner"] : fields;
53
61
  out.push(`${name}[${id}]: ${what.join(", ")}`);
@@ -91,9 +99,15 @@ import {
91
99
  formatError,
92
100
  rpcTable
93
101
  } from "@irtio/protocol";
94
- import { Mulberry32, RoomCore, RoomFullError, visibleNames } from "@irtio/runtime";
102
+ import {
103
+ Mulberry32,
104
+ RoomCore,
105
+ RoomFullError,
106
+ createVisibilityPolicy,
107
+ visibleNames
108
+ } from "@irtio/runtime";
95
109
  import { FakeClock, HarnessHost, frameTypeName } from "@irtio/runtime/test";
96
- import { decodeDelta } from "@irtio/schema";
110
+ import { decodeDelta, decodeSnapshot } from "@irtio/schema";
97
111
 
98
112
  // src/link.ts
99
113
  var InProcessSocket = class {
@@ -155,6 +169,9 @@ var JOIN_TIMEOUT_MS = 3e4;
155
169
  async function microtasks(turns = MICROTASK_TURNS) {
156
170
  for (let i = 0; i < turns; i++) await Promise.resolve();
157
171
  }
172
+ function detailOf(detail) {
173
+ return detail ? { detail } : {};
174
+ }
158
175
  var TestHarness = class {
159
176
  clock = new FakeClock();
160
177
  host;
@@ -170,9 +187,18 @@ var TestHarness = class {
170
187
  roleOverrides = /* @__PURE__ */ new Map();
171
188
  traceLog = [];
172
189
  frameLeaks = [];
190
+ spatialSeen = /* @__PURE__ */ new Map();
173
191
  rejectionLog = [];
192
+ /** Every handler throw the runtime caught, in order (bug 2). */
193
+ handlerErrorLog = [];
194
+ /** How many of them `reportHandlerErrors` has already raised. */
195
+ reportedErrors = 0;
174
196
  /** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
175
197
  reconnectCounts = /* @__PURE__ */ new Map();
198
+ /** Prediction accounting per client id, fed by each room's `correct` events. */
199
+ predictions = /* @__PURE__ */ new Map();
200
+ /** What the render clock needs per client: its interp delay, plus the link's worst delay. */
201
+ renderMeta = /* @__PURE__ */ new Map();
176
202
  nextClientId = 1;
177
203
  droppedFrames = 0;
178
204
  /** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
@@ -182,12 +208,14 @@ var TestHarness = class {
182
208
  roomId;
183
209
  writeIntervalMs;
184
210
  latency;
211
+ failOnHandlerError;
185
212
  stopped = false;
186
213
  constructor(definition, options) {
187
214
  this.definition = configure(definition, options);
188
215
  this.roomId = options.roomId ?? "test-room";
189
216
  this.latency = options.latency;
190
217
  this.writeIntervalMs = options.writeIntervalMs;
218
+ this.failOnHandlerError = options.failOnHandlerError ?? true;
191
219
  this.rng = new Mulberry32(options.seed ?? 1);
192
220
  this.scheduler = clockScheduler(this.clock);
193
221
  this.host = new HarnessHost(this.clock);
@@ -199,6 +227,15 @@ var TestHarness = class {
199
227
  roomId: this.roomId,
200
228
  seed: options.seed ?? 1
201
229
  });
230
+ this.host.core = this.coreRef;
231
+ this.host.serializeForSave = () => this.coreRef.snapshot();
232
+ this.coreRef.onHandlerError = (handler, err) => {
233
+ this.handlerErrorLog.push({
234
+ handler,
235
+ message: err instanceof Error ? err.message : String(err),
236
+ tick: this.coreRef.tick
237
+ });
238
+ };
202
239
  this.coreRef.start();
203
240
  }
204
241
  // -------------------------------------------------------------------------
@@ -219,6 +256,9 @@ var TestHarness = class {
219
256
  get rejections() {
220
257
  return this.rejectionLog;
221
258
  }
259
+ get handlerErrors() {
260
+ return this.handlerErrorLog;
261
+ }
222
262
  get dropped() {
223
263
  return this.droppedFrames;
224
264
  }
@@ -454,6 +494,14 @@ var TestHarness = class {
454
494
  link.connected = true;
455
495
  link.role = joined.role;
456
496
  this.byId.set(link.clientId, link);
497
+ const decodedJoin = decodeSnapshot(this.ext, joined.snapshot).state;
498
+ const seen = /* @__PURE__ */ new Map();
499
+ for (const desc of this.ext.collections) {
500
+ if (desc.visibility !== "spatial-grid") continue;
501
+ const collection = decodedJoin[desc.name];
502
+ seen.set(desc.name, new Set(collection.ids()));
503
+ }
504
+ this.spatialSeen.set(link.clientId, seen);
457
505
  this.roleById.set(link.clientId, joined.role);
458
506
  if (reconnecting) {
459
507
  this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
@@ -504,15 +552,36 @@ var TestHarness = class {
504
552
  return;
505
553
  }
506
554
  const keep = visibleNames(this.ext, link.role);
555
+ const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, link.role);
507
556
  for (const dc of delta.collections) {
508
- if (keep.has(dc.name) || dc.ops.length === 0) continue;
557
+ const desc = this.ext.collection(dc.name);
558
+ const seen = this.spatialSeen.get(link.clientId)?.get(dc.name) ?? /* @__PURE__ */ new Set();
559
+ const leaked = dc.ops.filter((op) => {
560
+ if (!keep.has(dc.name)) return true;
561
+ if (desc.visibility !== "spatial-grid") return false;
562
+ if (op.op === "remove") return !seen.has(op.id);
563
+ return !policy.maySeeEntity(desc, op.id);
564
+ });
565
+ if (desc.visibility === "spatial-grid") {
566
+ for (const op of dc.ops) {
567
+ if (op.op === "remove") seen.delete(op.id);
568
+ else if (policy.maySeeEntity(desc, op.id)) seen.add(op.id);
569
+ }
570
+ }
571
+ if (leaked.length === 0) continue;
509
572
  this.frameLeaks.push({
510
573
  clientId: link.clientId,
511
574
  role: link.role,
512
575
  collection: dc.name,
513
576
  kind: "frame",
514
- ids: dc.ops.map((o) => o.id),
515
- tick: delta.tick
577
+ ids: leaked.map((o) => o.id),
578
+ tick: delta.tick,
579
+ ...detailOf(
580
+ policy.describeSpatial(
581
+ desc,
582
+ leaked.map((o) => o.id)
583
+ )
584
+ )
516
585
  });
517
586
  }
518
587
  }
@@ -557,10 +626,32 @@ var TestHarness = class {
557
626
  const before = this.activity;
558
627
  await microtasks();
559
628
  this.clock.advance(0);
560
- if (this.activity === before) return;
629
+ if (this.activity === before) return this.reportHandlerErrors();
561
630
  }
562
631
  throw new Error("testRoom: the frame pump did not settle");
563
632
  }
633
+ /**
634
+ * Bug 2: the runtime catches a throwing handler so the room stays up, which in a test means
635
+ * the failure arrives later, somewhere else, wearing a disguise (the one that found this was a
636
+ * frozen tick counter reported ten seconds on as a `t.until` timeout). Every clock method ends
637
+ * here, so the throw surfaces at the line that ran the tick that broke.
638
+ */
639
+ reportHandlerErrors() {
640
+ if (!this.failOnHandlerError) return;
641
+ const fresh = this.handlerErrorLog.slice(this.reportedErrors);
642
+ this.reportedErrors = this.handlerErrorLog.length;
643
+ if (fresh.length === 0) return;
644
+ const lines = fresh.map((e) => ` ${e.handler} (tick ${e.tick}): ${e.message}`);
645
+ const what = fresh.length === 1 ? "a handler" : `${fresh.length} handlers`;
646
+ throw new Error(
647
+ [
648
+ `testRoom: the room threw in ${what}`,
649
+ ...lines,
650
+ "The runtime caught it and the room kept running, which is what it does in production.",
651
+ "Pass `failOnHandlerError: false` to testRoom if a throwing handler is what this test is about."
652
+ ].join("\n")
653
+ );
654
+ }
564
655
  async tick(n = 1) {
565
656
  for (let i = 0; i < n; i++) {
566
657
  this.clock.advance(this.mode === "tick" ? this.intervalMs : 0);
@@ -610,9 +701,17 @@ var TestHarness = class {
610
701
  ...this.writeIntervalMs !== void 0 ? { writeIntervalMs: this.writeIntervalMs } : {},
611
702
  ...spec.role !== void 0 ? { role: spec.role } : {},
612
703
  ...spec.name !== void 0 ? { name: spec.name } : {},
704
+ ...spec.interpDelayMs !== void 0 ? { interpDelayMs: spec.interpDelayMs } : {},
613
705
  ...spec.rpc !== void 0 ? { rpc: spec.rpc } : {}
614
706
  };
615
707
  const room = await this.awaitJoin(joinRoom(this.definition.schema, options));
708
+ room.on("correct", (correction) => this.recordCorrection(room.me, correction));
709
+ const latency = spec.latency ?? this.latency;
710
+ this.renderMeta.set(room.me, {
711
+ // The client's own default: 2 × the tick interval it learned from WELCOME, floored at 50.
712
+ interpDelayMs: spec.interpDelayMs ?? Math.max(50, 2 * Math.round(this.intervalMs)),
713
+ marginMs: (latency?.rttMs ?? 0) + (latency?.jitterMs ?? 0)
714
+ });
616
715
  const client = Object.create(room);
617
716
  Object.defineProperties(client, {
618
717
  id: { get: () => room.me, enumerable: true },
@@ -675,8 +774,25 @@ var TestHarness = class {
675
774
  for (const client of this.clientList) {
676
775
  const role = this.roleFor(client.id);
677
776
  const keep = visibleNames(this.ext, role);
777
+ const policy = createVisibilityPolicy(this.ext, this.plain, client.id, role);
678
778
  const view = client.view;
679
779
  for (const c of this.ext.collections) {
780
+ if (c.visibility === "spatial-grid") {
781
+ const coll = view[c.name];
782
+ const ids = coll ? [...coll.ids()].filter((id) => !policy.maySeeEntity(c, id)) : [];
783
+ if (ids.length > 0) {
784
+ leaks.push({
785
+ clientId: client.id,
786
+ role,
787
+ collection: c.name,
788
+ kind: "view",
789
+ ids,
790
+ tick: this.coreRef.tick,
791
+ ...detailOf(policy.describeSpatial(c, ids))
792
+ });
793
+ }
794
+ continue;
795
+ }
680
796
  if (keep.has(c.name)) continue;
681
797
  if (c.kind === "entity") {
682
798
  const coll = view[c.name];
@@ -704,15 +820,96 @@ var TestHarness = class {
704
820
  }
705
821
  return leaks;
706
822
  }
823
+ recordCorrection(clientId, correction) {
824
+ let stats = this.predictions.get(clientId);
825
+ if (!stats) {
826
+ stats = { corrections: 0, magnitude: 0, maxMagnitude: 0, snaps: 0, replayed: 0 };
827
+ this.predictions.set(clientId, stats);
828
+ }
829
+ let magnitude = 0;
830
+ for (const field of correction.fields) {
831
+ const prev = correction.previous[field];
832
+ const next = correction.patch[field];
833
+ if (typeof prev === "number" && typeof next === "number") {
834
+ magnitude += Math.abs(next - prev);
835
+ }
836
+ }
837
+ stats.corrections++;
838
+ stats.magnitude += magnitude;
839
+ if (magnitude > stats.maxMagnitude) stats.maxMagnitude = magnitude;
840
+ if (correction.snapped) stats.snaps++;
841
+ stats.replayed += correction.replayed;
842
+ }
843
+ /** Prediction accounting per client id. Every joined client has an entry, zeroed if untouched. */
844
+ predictionStats() {
845
+ const out = /* @__PURE__ */ new Map();
846
+ for (const client of this.clientList) {
847
+ const stats = this.predictions.get(client.id);
848
+ out.set(
849
+ client.id,
850
+ stats ? { ...stats } : { corrections: 0, magnitude: 0, maxMagnitude: 0, snaps: 0, replayed: 0 }
851
+ );
852
+ }
853
+ return out;
854
+ }
855
+ /**
856
+ * Render-path divergence (D20): `room.render` diffed against the authority, but **only** for
857
+ * clients whose inbound state stream has been idle for at least `interpDelayMs` of fake time
858
+ * (plus the link's worst-case delay). The render clock draws at `now − interpDelayMs` and holds
859
+ * past the newest delta, so an idle client's render must equal its settled state — while a
860
+ * client mid-interpolation is legitimately behind and is skipped, never flaked on.
861
+ */
862
+ renderConvergence() {
863
+ const out = [];
864
+ for (const client of this.clientList) {
865
+ if (client.status !== "connected") continue;
866
+ const meta = this.renderMeta.get(client.id);
867
+ if (!meta) continue;
868
+ const idleMs = this.clock.time - this.lastStateFrameAt(client.id);
869
+ if (idleMs < meta.interpDelayMs + meta.marginMs) continue;
870
+ const role = this.roleFor(client.id);
871
+ const policy = createVisibilityPolicy(this.ext, this.plain, client.id, role);
872
+ const view = materialize(this.ext, client.render);
873
+ out.push({
874
+ clientId: client.id,
875
+ differences: divergence(
876
+ this.ext,
877
+ this.plain,
878
+ view,
879
+ visibleNames(this.ext, role),
880
+ policy.memberships
881
+ ).map((d) => `render: ${d}`)
882
+ });
883
+ }
884
+ return out;
885
+ }
886
+ /** When the room last sent this client a state-bearing frame, per the trace (send time). */
887
+ lastStateFrameAt(clientId) {
888
+ for (let i = this.traceLog.length - 1; i >= 0; i--) {
889
+ const entry = this.traceLog[i];
890
+ if (!entry || entry.dir !== "out" || entry.clientId !== clientId) continue;
891
+ if (entry.frame === "DELTA" || entry.frame === "CORRECT" || entry.frame === "WELCOME") {
892
+ return entry.at;
893
+ }
894
+ }
895
+ return Number.NEGATIVE_INFINITY;
896
+ }
707
897
  /** Per-client divergence from the authority, restricted to what that client's role may see. */
708
898
  convergence() {
709
899
  const out = [];
710
900
  for (const client of this.clientList) {
711
901
  const role = this.roleFor(client.id);
902
+ const policy = createVisibilityPolicy(this.ext, this.plain, client.id, role);
712
903
  const view = materialize(this.ext, client.view);
713
904
  out.push({
714
905
  clientId: client.id,
715
- differences: divergence(this.ext, this.plain, view, visibleNames(this.ext, role))
906
+ differences: divergence(
907
+ this.ext,
908
+ this.plain,
909
+ view,
910
+ visibleNames(this.ext, role),
911
+ policy.memberships
912
+ )
716
913
  });
717
914
  }
718
915
  return out;
@@ -777,14 +974,19 @@ function toHaveNoVisibilityLeaks(received) {
777
974
  t,
778
975
  `expected no visibility leaks, found ${leaks.length}:
779
976
  ${leaks.map(
780
- (l) => ` ${l.clientId} (${l.role}) saw ${l.collection} [${l.ids.join(", ")}] in its ${l.kind} at tick ${l.tick}`
977
+ (l) => ` ${l.clientId} (${l.role}) saw ${l.collection} [${l.ids.join(", ")}] in its ${l.kind} at tick ${l.tick}${l.detail ? ` (${l.detail})` : ""}`
781
978
  ).join("\n")}`
782
979
  )
783
980
  };
784
981
  }
785
982
  function toHaveConverged(received) {
786
983
  const t = harnessOf(received);
787
- const behind = t.convergence().filter((r) => r.differences.length > 0);
984
+ const byClient = /* @__PURE__ */ new Map();
985
+ for (const r of [...t.convergence(), ...t.renderConvergence()]) {
986
+ if (r.differences.length === 0) continue;
987
+ byClient.set(r.clientId, [...byClient.get(r.clientId) ?? [], ...r.differences]);
988
+ }
989
+ const behind = [...byClient].map(([clientId, differences]) => ({ clientId, differences }));
788
990
  return {
789
991
  pass: behind.length === 0,
790
992
  message: () => behind.length === 0 ? withTrace(t, "expected at least one client to differ from the authority, none did") : withTrace(
@@ -829,11 +1031,33 @@ function toStayUnderBandwidth(received, bytesPerTick) {
829
1031
  )
830
1032
  };
831
1033
  }
1034
+ function toStayWithinPrediction(received, opts) {
1035
+ const t = harnessOf(received);
1036
+ const maxMagnitude = opts.maxMagnitude ?? Number.POSITIVE_INFINITY;
1037
+ const maxSnaps = opts.maxSnaps ?? Number.POSITIVE_INFINITY;
1038
+ const stats = [...t.predictionStats()];
1039
+ const offenders = stats.filter(([, s]) => s.maxMagnitude > maxMagnitude || s.snaps > maxSnaps);
1040
+ const describe = ([clientId, s]) => ` ${clientId}: ${s.corrections} correction(s), magnitude ${s.magnitude.toFixed(1)} (worst ${s.maxMagnitude.toFixed(1)}), ${s.snaps} snap(s), ${s.replayed} replayed`;
1041
+ const bounds = `maxMagnitude ${maxMagnitude}, maxSnaps ${maxSnaps}`;
1042
+ return {
1043
+ pass: offenders.length === 0,
1044
+ message: () => offenders.length === 0 ? withTrace(
1045
+ t,
1046
+ `expected some client to exceed the prediction bounds (${bounds}); none did:
1047
+ ${stats.map(describe).join("\n") || " no clients joined"}`
1048
+ ) : withTrace(
1049
+ t,
1050
+ `expected every client to stay within the prediction bounds (${bounds}), ${offenders.length} did not:
1051
+ ${offenders.map(describe).join("\n")}`
1052
+ )
1053
+ };
1054
+ }
832
1055
  var matchers = {
833
1056
  toHaveNoVisibilityLeaks,
834
1057
  toHaveConverged,
835
1058
  toHaveRejected,
836
- toStayUnderBandwidth
1059
+ toStayUnderBandwidth,
1060
+ toStayWithinPrediction
837
1061
  };
838
1062
 
839
1063
  export {
@@ -847,5 +1071,6 @@ export {
847
1071
  toHaveConverged,
848
1072
  toHaveRejected,
849
1073
  toStayUnderBandwidth,
1074
+ toStayWithinPrediction,
850
1075
  matchers
851
1076
  };
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ import { RoomDefinition } from '@irtio/server';
3
3
  import { Room, ClientState, RelayRoom, Scheduler } from '@irtio/client';
4
4
  import { RoomCore } from '@irtio/runtime';
5
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';
6
+ export { TraceEntry, VisibilityLeak, initPhysics } from '@irtio/runtime/test';
7
+ 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';
8
8
 
9
9
  /**
10
10
  * `@irtio/testing`'s public contract: what `testRoom` hands back, and the two option bags.
@@ -58,12 +58,25 @@ interface TestRoomOptions {
58
58
  readonly roomId?: string;
59
59
  /** The clients' owned-write flush window, in ms of fake time. Default 50 (the client's own). */
60
60
  readonly writeIntervalMs?: number;
61
+ /**
62
+ * Throw out of `t.tick` / `t.run` / `t.until` / `t.join` when a room handler throws. Default
63
+ * `true`.
64
+ *
65
+ * The runtime guards handlers on purpose: one bad `onJoin` must not take a live room down. In
66
+ * a test that same guarding hides the failure — the room limps on and the test fails ten
67
+ * seconds later on an unrelated `t.until` timeout, pointing at the wrong thing (bug 2). Set
68
+ * `false` for a test whose *subject* is a throwing handler; `t.handlerErrors` records them
69
+ * either way.
70
+ */
71
+ readonly failOnHandlerError?: boolean;
61
72
  }
62
73
  interface TestJoinSpec<Role extends string = string> {
63
74
  readonly role?: Role;
64
75
  readonly name?: string;
65
76
  /** Link simulation for this client only. */
66
77
  readonly latency?: LatencySpec;
78
+ /** Render delay for this client's `room.render` (D20), handed to `joinRoom`. */
79
+ readonly interpDelayMs?: number;
67
80
  /**
68
81
  * Implementations of the schema's `client(...)` RPCs, handed straight to `joinRoom({ rpc })`.
69
82
  * Without this there is no way to test a server-to-client call succeeding — the client replies
@@ -109,6 +122,26 @@ type TestClient<S, Role extends string = RoleOf<S> & string> = Omit<Room<S, Role
109
122
  /** How many times this client has completed a reconnect (a resumed `WELCOME` after a drop). */
110
123
  readonly reconnects: number;
111
124
  };
125
+ /** Per-client prediction accounting, accumulated from the room's `correct` events (week 8). */
126
+ interface PredictionStats {
127
+ /** Correction ops this client received (one `CORRECT` frame may carry several). */
128
+ readonly corrections: number;
129
+ /** Summed numeric distance between the local prediction and the server's values. */
130
+ readonly magnitude: number;
131
+ /** The single worst correction's numeric distance. */
132
+ readonly maxMagnitude: number;
133
+ /** Corrections that outran the client's resim window and snapped instead of replaying. */
134
+ readonly snaps: number;
135
+ /** Total pending local writes re-applied over corrections. */
136
+ readonly replayed: number;
137
+ }
138
+ /** One handler throw the runtime caught and the room survived. See `failOnHandlerError`. */
139
+ interface HandlerError {
140
+ /** What was running: `tick`, `physics`, `onJoin`, `room timer`, `alarms.<name>`, … */
141
+ readonly handler: string;
142
+ readonly message: string;
143
+ readonly tick: number;
144
+ }
112
145
  /** One `REPLY` that came back with `ok: false`, keyed by the rpc the client had called. */
113
146
  interface RejectedCall {
114
147
  readonly clientId: string;
@@ -126,6 +159,12 @@ interface TestRoom<S extends AnySchema> {
126
159
  readonly trace: readonly TraceEntry[];
127
160
  /** Error replies seen so far, for `toHaveRejected`. */
128
161
  readonly rejections: readonly RejectedCall[];
162
+ /**
163
+ * Handler throws the runtime caught, in order. Empty in a healthy room; with the default
164
+ * `failOnHandlerError` the first one has already failed the test by the time you could read
165
+ * this, so it is for tests that opted out.
166
+ */
167
+ readonly handlerErrors: readonly HandlerError[];
129
168
  /** Frames the `latency.loss` die dropped. */
130
169
  readonly dropped: number;
131
170
  /** Escape hatch: the real `RoomCore`. */
@@ -165,6 +204,12 @@ interface TestRoom<S extends AnySchema> {
165
204
  clientId: string;
166
205
  differences: string[];
167
206
  }[];
207
+ /**
208
+ * Per-client prediction accounting, from each room's `correct` events: corrections seen,
209
+ * numeric misprediction magnitude (total and worst), snaps and replayed writes. A client that
210
+ * was never corrected has an all-zero entry.
211
+ */
212
+ predictionStats(): Map<string, PredictionStats>;
168
213
  /** Outbound bytes per client and the per-tick average, as `toStayUnderBandwidth` reads them. */
169
214
  bandwidth(): {
170
215
  clientId: string;
@@ -273,9 +318,18 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
273
318
  private readonly roleOverrides;
274
319
  private readonly traceLog;
275
320
  private readonly frameLeaks;
321
+ private readonly spatialSeen;
276
322
  private readonly rejectionLog;
323
+ /** Every handler throw the runtime caught, in order (bug 2). */
324
+ private readonly handlerErrorLog;
325
+ /** How many of them `reportHandlerErrors` has already raised. */
326
+ private reportedErrors;
277
327
  /** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
278
328
  private readonly reconnectCounts;
329
+ /** Prediction accounting per client id, fed by each room's `correct` events. */
330
+ private readonly predictions;
331
+ /** What the render clock needs per client: its interp delay, plus the link's worst delay. */
332
+ private readonly renderMeta;
279
333
  private nextClientId;
280
334
  private droppedFrames;
281
335
  /** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
@@ -285,6 +339,7 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
285
339
  private readonly roomId;
286
340
  private readonly writeIntervalMs;
287
341
  private readonly latency;
342
+ private readonly failOnHandlerError;
288
343
  private stopped;
289
344
  constructor(definition: RoomDefinition<S>, options: TestRoomOptions);
290
345
  get core(): RoomCore<S>;
@@ -292,6 +347,7 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
292
347
  get plain(): PlainState;
293
348
  get trace(): readonly TraceEntry[];
294
349
  get rejections(): readonly RejectedCall[];
350
+ get handlerErrors(): readonly HandlerError[];
295
351
  get dropped(): number;
296
352
  get now(): number;
297
353
  get tickCount(): number;
@@ -343,6 +399,13 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
343
399
  private record;
344
400
  /** Fires everything due, then lets the clients' promises run, until nothing new is in flight. */
345
401
  private settle;
402
+ /**
403
+ * Bug 2: the runtime catches a throwing handler so the room stays up, which in a test means
404
+ * the failure arrives later, somewhere else, wearing a disguise (the one that found this was a
405
+ * frozen tick counter reported ten seconds on as a `t.until` timeout). Every clock method ends
406
+ * here, so the throw surfaces at the line that ran the tick that broke.
407
+ */
408
+ private reportHandlerErrors;
346
409
  tick(n?: number): Promise<void>;
347
410
  run(ms: number): Promise<void>;
348
411
  /**
@@ -373,6 +436,22 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
373
436
  pretendRole(clientId: string, role: string): void;
374
437
  private roleFor;
375
438
  checkVisibility(): VisibilityLeak[];
439
+ private recordCorrection;
440
+ /** Prediction accounting per client id. Every joined client has an entry, zeroed if untouched. */
441
+ predictionStats(): Map<string, PredictionStats>;
442
+ /**
443
+ * Render-path divergence (D20): `room.render` diffed against the authority, but **only** for
444
+ * clients whose inbound state stream has been idle for at least `interpDelayMs` of fake time
445
+ * (plus the link's worst-case delay). The render clock draws at `now − interpDelayMs` and holds
446
+ * past the newest delta, so an idle client's render must equal its settled state — while a
447
+ * client mid-interpolation is legitimately behind and is skipped, never flaked on.
448
+ */
449
+ renderConvergence(): {
450
+ clientId: string;
451
+ differences: string[];
452
+ }[];
453
+ /** When the room last sent this client a state-bearing frame, per the trace (send time). */
454
+ private lastStateFrameAt;
376
455
  /** Per-client divergence from the authority, restricted to what that client's role may see. */
377
456
  convergence(): {
378
457
  clientId: string;
@@ -477,7 +556,7 @@ declare function materialize(ext: AnySchema, view: AnyRecord): PlainState;
477
556
  * Where a client's view differs from the authority, restricted to the collections its role may
478
557
  * see. Empty ⇒ converged.
479
558
  */
480
- declare function divergence(ext: AnySchema, authority: PlainState, client: PlainState, visible: ReadonlySet<string>): string[];
559
+ declare function divergence(ext: AnySchema, authority: PlainState, client: PlainState, visible: ReadonlySet<string>, memberships?: ReadonlyMap<string, ReadonlySet<string>>): string[];
481
560
 
482
561
  /**
483
562
  * `@irtio/testing` — the blessed test API.
@@ -521,7 +600,7 @@ declare function divergence(ext: AnySchema, authority: PlainState, client: Plain
521
600
  * ```
522
601
  *
523
602
  * Importing this module does **not** pull Vitest in. `@irtio/testing/matchers` does, and
524
- * registers the four matchers as a side effect.
603
+ * registers the five matchers as a side effect.
525
604
  *
526
605
  * Every method that touches the clock — `join`, `tick`, `run`, `until` on both `testRoom` and
527
606
  * `testRelay` — is `async` and **must be awaited**; see `harness.ts`'s docblock for why.
@@ -539,4 +618,4 @@ declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>, op
539
618
  /** Starts a schema-less relay room in-process and returns the test handle. See the module docblock. */
540
619
  declare function testRelay(options?: TestRelayOptions): Promise<TestRelay>;
541
620
 
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 };
621
+ 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 };
package/dist/index.js CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  toHaveConverged,
10
10
  toHaveNoVisibilityLeaks,
11
11
  toHaveRejected,
12
- toStayUnderBandwidth
13
- } from "./chunk-O3OZKC3D.js";
12
+ toStayUnderBandwidth,
13
+ toStayWithinPrediction
14
+ } from "./chunk-Q44LJTMZ.js";
14
15
 
15
16
  // src/relay-harness.ts
16
17
  import { joinRelay } from "@irtio/client";
@@ -446,6 +447,7 @@ var TestRelayHarness = class {
446
447
  };
447
448
 
448
449
  // src/index.ts
450
+ import { initPhysics } from "@irtio/runtime/test";
449
451
  async function testRoom(definition, options = {}) {
450
452
  return new TestHarness(definition, options);
451
453
  }
@@ -458,6 +460,7 @@ export {
458
460
  TestRelayHarness,
459
461
  clockScheduler,
460
462
  divergence,
463
+ initPhysics,
461
464
  matchers,
462
465
  materialize,
463
466
  testRelay,
@@ -465,5 +468,6 @@ export {
465
468
  toHaveConverged,
466
469
  toHaveNoVisibilityLeaks,
467
470
  toHaveRejected,
468
- toStayUnderBandwidth
471
+ toStayUnderBandwidth,
472
+ toStayWithinPrediction
469
473
  };
@@ -1,9 +1,10 @@
1
- export { m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth } from './assertions-BP4as6ol.js';
1
+ import { P as PredictionBounds } from './assertions-Cj1Sejhj.js';
2
+ export { m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth, d as toStayWithinPrediction } from './assertions-Cj1Sejhj.js';
2
3
 
3
4
  /**
4
5
  * `@irtio/testing/matchers` — the Vitest entry.
5
6
  *
6
- * Importing this module registers the four matchers and augments `expect`'s types. It is a
7
+ * Importing this module registers the five matchers and augments `expect`'s types. It is a
7
8
  * separate entry point on purpose: Vitest is an **optional** peer of `@irtio/testing`, so the main
8
9
  * entry (and anything that only wants `testRoom`) never touches it.
9
10
  *
@@ -12,6 +13,7 @@ export { m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as
12
13
  * expect(t).toHaveNoVisibilityLeaks();
13
14
  * ```
14
15
  */
16
+
15
17
  /** The matchers this package adds to `expect`. */
16
18
  interface IrtioMatchers<R = unknown> {
17
19
  /** No client's view or received frame named a collection its role must not see. */
@@ -22,10 +24,12 @@ interface IrtioMatchers<R = unknown> {
22
24
  toHaveRejected(rpcName: string): R;
23
25
  /** No client averaged more than `bytesPerTick` outbound bytes per room tick. */
24
26
  toStayUnderBandwidth(bytesPerTick: number): R;
27
+ /** No client's mispredictions exceeded the given magnitude/snap bounds. */
28
+ toStayWithinPrediction(opts: PredictionBounds): R;
25
29
  }
26
30
  declare module 'vitest' {
27
31
  interface Matchers<T = any> extends IrtioMatchers<T> {
28
32
  }
29
33
  }
30
34
 
31
- export type { IrtioMatchers };
35
+ export { type IrtioMatchers, PredictionBounds };
package/dist/matchers.js CHANGED
@@ -3,8 +3,9 @@ import {
3
3
  toHaveConverged,
4
4
  toHaveNoVisibilityLeaks,
5
5
  toHaveRejected,
6
- toStayUnderBandwidth
7
- } from "./chunk-O3OZKC3D.js";
6
+ toStayUnderBandwidth,
7
+ toStayWithinPrediction
8
+ } from "./chunk-Q44LJTMZ.js";
8
9
 
9
10
  // src/matchers.ts
10
11
  import { expect } from "vitest";
@@ -14,5 +15,6 @@ export {
14
15
  toHaveConverged,
15
16
  toHaveNoVisibilityLeaks,
16
17
  toHaveRejected,
17
- toStayUnderBandwidth
18
+ toStayUnderBandwidth,
19
+ toStayWithinPrediction
18
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/testing",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
6
  "publishConfig": {
@@ -23,11 +23,11 @@
23
23
  "dist"
24
24
  ],
25
25
  "dependencies": {
26
- "@irtio/client": "0.1.0",
27
- "@irtio/protocol": "0.1.0",
28
- "@irtio/runtime": "0.1.0",
29
- "@irtio/server": "0.1.0",
30
- "@irtio/schema": "0.1.0"
26
+ "@irtio/protocol": "0.3.0",
27
+ "@irtio/runtime": "0.3.0",
28
+ "@irtio/client": "0.3.0",
29
+ "@irtio/server": "0.3.0",
30
+ "@irtio/schema": "0.3.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "vitest": ">=3"