@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.
- package/dist/{chunk-YMZ3FZYM.js → chunk-3W5KPX3I.js} +244 -15
- package/dist/index.d.ts +140 -19
- package/dist/index.js +3 -1
- package/dist/matchers.js +1 -1
- package/package.json +11 -6
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
619
|
-
const
|
|
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.
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
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
|
-
*
|
|
12
|
+
* M7 pf4 (g): `t.physics` — a narrow, engine-neutral handle on the room's live world.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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-
|
|
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
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/testing",
|
|
3
|
-
"version": "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/
|
|
27
|
-
"@irtio/
|
|
28
|
-
"@irtio/runtime": "0.
|
|
29
|
-
"@irtio/schema": "0.
|
|
30
|
-
"@irtio/server": "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"
|