@irtio/testing 1.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-YMZ3FZYM.js → chunk-47O3HZBE.js} +366 -19
- package/dist/index.d.ts +170 -20
- package/dist/index.js +3 -1
- package/dist/matchers.js +1 -1
- package/package.json +12 -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,8 @@ function clockScheduler(clock) {
|
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
// src/harness.ts
|
|
86
|
-
import {
|
|
89
|
+
import { spawnBots } from "@irtio/bots";
|
|
90
|
+
import { INTERNAL_SESSION, joinRoom } from "@irtio/client";
|
|
87
91
|
import {
|
|
88
92
|
ErrorCode,
|
|
89
93
|
FrameType,
|
|
@@ -154,6 +158,84 @@ var InProcessSocket = class {
|
|
|
154
158
|
}
|
|
155
159
|
};
|
|
156
160
|
|
|
161
|
+
// src/physics.ts
|
|
162
|
+
function vec(v) {
|
|
163
|
+
return { x: v.x, y: v.y, z: v.z ?? 0 };
|
|
164
|
+
}
|
|
165
|
+
function createTestPhysics(core) {
|
|
166
|
+
const physicsOf = () => {
|
|
167
|
+
const p = core.physics;
|
|
168
|
+
if (!p) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
"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."
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return p;
|
|
174
|
+
};
|
|
175
|
+
const bodyOf = (collection, id) => {
|
|
176
|
+
const p = physicsOf();
|
|
177
|
+
const body = p.bodyFor(collection, id);
|
|
178
|
+
if (body === void 0 || body === null) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`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).`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return body;
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
get engine() {
|
|
187
|
+
return core.physics?.engineKind;
|
|
188
|
+
},
|
|
189
|
+
position(collection, id) {
|
|
190
|
+
const body = bodyOf(collection, id);
|
|
191
|
+
if (physicsOf().engineKind === "matter2d") return vec(body.position);
|
|
192
|
+
return vec(body.translation());
|
|
193
|
+
},
|
|
194
|
+
velocity(collection, id) {
|
|
195
|
+
const body = bodyOf(collection, id);
|
|
196
|
+
if (physicsOf().engineKind === "matter2d") return vec(body.velocity);
|
|
197
|
+
return vec(body.linvel());
|
|
198
|
+
},
|
|
199
|
+
teleport(collection, id, to) {
|
|
200
|
+
const p = physicsOf();
|
|
201
|
+
const body = bodyOf(collection, id);
|
|
202
|
+
if (p.engineKind === "matter2d") {
|
|
203
|
+
matterOf(p).Body.setPosition(body, { x: to.x, y: to.y });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
body.setTranslation(
|
|
207
|
+
p.engineKind === "rapier3d" ? { x: to.x, y: to.y, z: to.z ?? 0 } : { x: to.x, y: to.y },
|
|
208
|
+
true
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
impulse(collection, id, by) {
|
|
212
|
+
const p = physicsOf();
|
|
213
|
+
const body = bodyOf(collection, id);
|
|
214
|
+
if (p.engineKind === "matter2d") {
|
|
215
|
+
const m = body;
|
|
216
|
+
matterOf(p).Body.setVelocity(body, {
|
|
217
|
+
x: m.velocity.x + by.x / m.mass,
|
|
218
|
+
y: m.velocity.y + by.y / m.mass
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
body.applyImpulse(
|
|
223
|
+
p.engineKind === "rapier3d" ? { x: by.x, y: by.y, z: by.z ?? 0 } : { x: by.x, y: by.y },
|
|
224
|
+
true
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function matterOf(p) {
|
|
230
|
+
const matter = p.matter;
|
|
231
|
+
if (!matter) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"t.physics: this room runs matter2d but the matter-js namespace is not loaded. Await `initMatter()` in a `beforeAll` before building the room."
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return matter;
|
|
237
|
+
}
|
|
238
|
+
|
|
157
239
|
// src/harness.ts
|
|
158
240
|
var TEST_URL = "ws://localhost:7070";
|
|
159
241
|
var TEST_KEY = "test";
|
|
@@ -178,6 +260,7 @@ function frameNameOf(frame) {
|
|
|
178
260
|
async function microtasks(turns = MICROTASK_TURNS) {
|
|
179
261
|
for (let i = 0; i < turns; i++) await Promise.resolve();
|
|
180
262
|
}
|
|
263
|
+
var NPC_TRACE_LIMIT = 16;
|
|
181
264
|
function detailOf(detail) {
|
|
182
265
|
return detail ? { detail } : {};
|
|
183
266
|
}
|
|
@@ -201,11 +284,20 @@ var TestHarness = class {
|
|
|
201
284
|
/** Client ids a frame was dropped for because nobody knows them, each warned about once. */
|
|
202
285
|
unknownSendWarned = /* @__PURE__ */ new Set();
|
|
203
286
|
clientList = [];
|
|
287
|
+
/** D44: live NPC sessions by client id. */
|
|
288
|
+
npcSessions = /* @__PURE__ */ new Map();
|
|
204
289
|
roleById = /* @__PURE__ */ new Map();
|
|
205
290
|
roleOverrides = /* @__PURE__ */ new Map();
|
|
206
291
|
traceLog = [];
|
|
207
292
|
frameLeaks = [];
|
|
208
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Record ids this harness has actually delivered to a client, per collection. Started as the
|
|
295
|
+
* join snapshot's ids and kept current by `checkFrame`. It was a spatial-grid-only ledger; every
|
|
296
|
+
* collection is tracked now, because a `room.setRole` demotion catches the client up with
|
|
297
|
+
* `remove` ops for a collection its new role cannot see, and those ids are exactly the ones it
|
|
298
|
+
* was legitimately sent before.
|
|
299
|
+
*/
|
|
300
|
+
deliveredSeen = /* @__PURE__ */ new Map();
|
|
209
301
|
rejectionLog = [];
|
|
210
302
|
/** Every handler throw the runtime caught, in order (bug 2). */
|
|
211
303
|
handlerErrorLog = [];
|
|
@@ -227,6 +319,11 @@ var TestHarness = class {
|
|
|
227
319
|
writeIntervalMs;
|
|
228
320
|
latency;
|
|
229
321
|
failOnHandlerError;
|
|
322
|
+
/** M7 pf4 (f): auto-pump settings, resolved once. */
|
|
323
|
+
autoPump;
|
|
324
|
+
autoPumpTicks;
|
|
325
|
+
/** M7 pf4 (g): built once, lazily reading `core.physics` on every call. */
|
|
326
|
+
physicsRef;
|
|
230
327
|
stopped = false;
|
|
231
328
|
constructor(definition, options) {
|
|
232
329
|
this.definition = configure(definition, options);
|
|
@@ -234,6 +331,8 @@ var TestHarness = class {
|
|
|
234
331
|
this.latency = options.latency;
|
|
235
332
|
this.writeIntervalMs = options.writeIntervalMs;
|
|
236
333
|
this.failOnHandlerError = options.failOnHandlerError ?? true;
|
|
334
|
+
this.autoPump = options.autoPump ?? true;
|
|
335
|
+
this.autoPumpTicks = options.autoPumpTicks ?? DEFAULT_AUTO_PUMP_TICKS;
|
|
237
336
|
this.rng = new Mulberry32(options.seed ?? 1);
|
|
238
337
|
this.scheduler = clockScheduler(this.clock);
|
|
239
338
|
this.host = new HarnessHost(this.clock);
|
|
@@ -245,11 +344,17 @@ var TestHarness = class {
|
|
|
245
344
|
}
|
|
246
345
|
this.holdOrDrop(clientId, frame);
|
|
247
346
|
};
|
|
347
|
+
this.host.onSpawnNpc = (clientId, config) => this.spawnNpc(clientId, config);
|
|
348
|
+
this.host.onDespawnNpc = (clientId) => this.despawnNpc(clientId);
|
|
248
349
|
this.coreRef = new RoomCore(this.definition, this.host, {
|
|
249
350
|
roomId: this.roomId,
|
|
250
351
|
...options.seed !== void 0 ? { seed: options.seed } : {},
|
|
251
|
-
...options.profile === true ? { profile: true } : {}
|
|
352
|
+
...options.profile === true ? { profile: true } : {},
|
|
353
|
+
...options.traceRpc === true ? { traceRpc: true } : {}
|
|
252
354
|
});
|
|
355
|
+
this.physicsRef = createTestPhysics(
|
|
356
|
+
this.coreRef
|
|
357
|
+
);
|
|
253
358
|
this.host.core = this.coreRef;
|
|
254
359
|
this.host.serializeForSave = () => this.coreRef.snapshot();
|
|
255
360
|
this.coreRef.onHandlerError = (handler, err) => {
|
|
@@ -308,6 +413,14 @@ var TestHarness = class {
|
|
|
308
413
|
get profile() {
|
|
309
414
|
return this.coreRef.profile();
|
|
310
415
|
}
|
|
416
|
+
/** M7 pf4 (g): the narrow physics handle. Always present; its methods refuse with a reason. */
|
|
417
|
+
get physics() {
|
|
418
|
+
return this.physicsRef;
|
|
419
|
+
}
|
|
420
|
+
/** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
|
|
421
|
+
get rpcTrace() {
|
|
422
|
+
return this.coreRef.rpcTrace();
|
|
423
|
+
}
|
|
311
424
|
// -------------------------------------------------------------------------
|
|
312
425
|
// The core is never re-entered
|
|
313
426
|
// -------------------------------------------------------------------------
|
|
@@ -330,13 +443,20 @@ var TestHarness = class {
|
|
|
330
443
|
// -------------------------------------------------------------------------
|
|
331
444
|
// The link
|
|
332
445
|
// -------------------------------------------------------------------------
|
|
333
|
-
/**
|
|
334
|
-
|
|
446
|
+
/**
|
|
447
|
+
* A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec.
|
|
448
|
+
*
|
|
449
|
+
* `identity` is how an NPC gets in: the runtime mints its client id (`npc-<room>-<n>`) before the
|
|
450
|
+
* host is asked to open the session, and every reconnect this transport makes has to come back as
|
|
451
|
+
* that same id with the same `npc` flag, exactly as the supervisor's loopback does.
|
|
452
|
+
*/
|
|
453
|
+
transportFor(spec, identity) {
|
|
335
454
|
return {
|
|
336
455
|
connect: () => {
|
|
337
456
|
const link = {
|
|
338
457
|
socket: void 0,
|
|
339
|
-
clientId: `c${this.nextClientId++}`,
|
|
458
|
+
clientId: identity?.clientId ?? `c${this.nextClientId++}`,
|
|
459
|
+
npc: identity?.npc ?? false,
|
|
340
460
|
role: "",
|
|
341
461
|
joined: false,
|
|
342
462
|
connected: true,
|
|
@@ -356,6 +476,105 @@ var TestHarness = class {
|
|
|
356
476
|
}
|
|
357
477
|
};
|
|
358
478
|
}
|
|
479
|
+
// -------------------------------------------------------------------------
|
|
480
|
+
// D44: NPCs
|
|
481
|
+
// -------------------------------------------------------------------------
|
|
482
|
+
/**
|
|
483
|
+
* `room.spawnNPC`, in process. The runtime has already validated the config and minted the id;
|
|
484
|
+
* what is left is the supervisor's half — find the script in the definition's `npcs` map and put
|
|
485
|
+
* a real client behind it.
|
|
486
|
+
*
|
|
487
|
+
* Everything here rides `this.clock`: the bot joins through `transportFor`, so its frames are the
|
|
488
|
+
* same fake-clock timers a player's are, and `scheduler` makes `npc.wait` / `npc.until` fake time
|
|
489
|
+
* too. So an NPC spawned inside a tick becomes visible on the next `t.tick()` / `t.run()` /
|
|
490
|
+
* `t.settle`, and never makes progress behind the test's back.
|
|
491
|
+
*
|
|
492
|
+
* Throwing is deliberate: the caller is `room.spawnNPC` inside a handler, which the runtime runs
|
|
493
|
+
* under `tryRun`, so the sentence lands in `t.handlerErrors` and (by default) fails the test at
|
|
494
|
+
* the clock call that ran the handler. Recording it and carrying on is what this used to do, and
|
|
495
|
+
* it is how "my NPC never showed up" became a silent test.
|
|
496
|
+
*/
|
|
497
|
+
spawnNpc(clientId, config) {
|
|
498
|
+
if (this.stopped) return;
|
|
499
|
+
if (this.npcSessions.has(clientId)) {
|
|
500
|
+
this.host.log("warn", [`irtio: npc ${clientId} is already running; spawn ignored`]);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const scripts = this.definition.config.npcs;
|
|
504
|
+
const script = scripts?.[config.brain.script];
|
|
505
|
+
if (!script) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`testRoom: no npc script named ${JSON.stringify(config.brain.script)} in this room's npcs map`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
const name = config.name ?? config.brain.script;
|
|
511
|
+
const entry = {
|
|
512
|
+
clientId,
|
|
513
|
+
script: config.brain.script,
|
|
514
|
+
runner: void 0,
|
|
515
|
+
stopped: false
|
|
516
|
+
};
|
|
517
|
+
this.npcSessions.set(clientId, entry);
|
|
518
|
+
this.activity++;
|
|
519
|
+
void spawnBots(1, {
|
|
520
|
+
schema: this.definition.schema,
|
|
521
|
+
room: this.roomId,
|
|
522
|
+
url: TEST_URL,
|
|
523
|
+
key: TEST_KEY,
|
|
524
|
+
name,
|
|
525
|
+
...config.role !== void 0 ? { role: config.role } : {},
|
|
526
|
+
...config.seed !== void 0 ? { seed: config.seed } : {},
|
|
527
|
+
traceLimit: NPC_TRACE_LIMIT,
|
|
528
|
+
// Both of these are real timers in `@irtio/bots`, and a fake-clock harness must not own one:
|
|
529
|
+
// the join is bounded by the test's own clock calls, and "every bot disconnected" is what
|
|
530
|
+
// `t.stop()` deliberately does.
|
|
531
|
+
joinTimeoutMs: 0,
|
|
532
|
+
roomGoneGraceMs: 0,
|
|
533
|
+
transport: this.transportFor({}, { clientId, npc: true }),
|
|
534
|
+
scheduler: this.scheduler,
|
|
535
|
+
script: (bot) => script(bot)
|
|
536
|
+
}).then(
|
|
537
|
+
(runner) => {
|
|
538
|
+
this.activity++;
|
|
539
|
+
entry.runner = runner;
|
|
540
|
+
if (entry.stopped) void this.stopNpc(entry);
|
|
541
|
+
else void runner.done().catch((err) => this.noteNpcError(entry, err));
|
|
542
|
+
},
|
|
543
|
+
(err) => {
|
|
544
|
+
this.npcSessions.delete(clientId);
|
|
545
|
+
this.noteNpcError(entry, err);
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
despawnNpc(clientId) {
|
|
550
|
+
const entry = this.npcSessions.get(clientId);
|
|
551
|
+
if (!entry) return;
|
|
552
|
+
this.npcSessions.delete(clientId);
|
|
553
|
+
entry.stopped = true;
|
|
554
|
+
if (entry.runner) void this.stopNpc(entry);
|
|
555
|
+
}
|
|
556
|
+
/** Stops one NPC's runner: its script winds down, then it leaves the room like any client. */
|
|
557
|
+
async stopNpc(entry) {
|
|
558
|
+
const runner = entry.runner;
|
|
559
|
+
entry.runner = void 0;
|
|
560
|
+
if (!runner) return;
|
|
561
|
+
this.activity++;
|
|
562
|
+
try {
|
|
563
|
+
await runner.stop();
|
|
564
|
+
} catch (err) {
|
|
565
|
+
this.noteNpcError(entry, err);
|
|
566
|
+
}
|
|
567
|
+
this.activity++;
|
|
568
|
+
}
|
|
569
|
+
/** An NPC failure, filed where a handler throw is filed — see `reportHandlerErrors`. */
|
|
570
|
+
noteNpcError(entry, err) {
|
|
571
|
+
this.handlerErrorLog.push({
|
|
572
|
+
handler: `npcs.${entry.script} (${entry.clientId})`,
|
|
573
|
+
message: err instanceof Error ? err.message : String(err),
|
|
574
|
+
tick: this.coreRef.tick
|
|
575
|
+
});
|
|
576
|
+
this.activity++;
|
|
577
|
+
}
|
|
359
578
|
/**
|
|
360
579
|
* Bug #49: a frame for a client with no link. Held when the core has the client and counts it
|
|
361
580
|
* connected, which is exactly the window between `RoomCore.join` running `onJoin` and
|
|
@@ -528,7 +747,8 @@ var TestHarness = class {
|
|
|
528
747
|
result = this.coreRef.join(link.clientId, {
|
|
529
748
|
...role !== void 0 ? { role } : {},
|
|
530
749
|
...name !== void 0 ? { name } : {},
|
|
531
|
-
...reconnecting ? { reconnecting: true } : {}
|
|
750
|
+
...reconnecting ? { reconnecting: true } : {},
|
|
751
|
+
...link.npc ? { npc: true } : {}
|
|
532
752
|
});
|
|
533
753
|
} catch (err) {
|
|
534
754
|
failure = err;
|
|
@@ -555,11 +775,11 @@ var TestHarness = class {
|
|
|
555
775
|
const decodedJoin = decodeSnapshot(this.ext, joined.snapshot).state;
|
|
556
776
|
const seen = /* @__PURE__ */ new Map();
|
|
557
777
|
for (const desc of this.ext.collections) {
|
|
558
|
-
if (desc.
|
|
778
|
+
if (desc.kind !== "entity") continue;
|
|
559
779
|
const collection = decodedJoin[desc.name];
|
|
560
|
-
seen.set(desc.name, new Set(collection.ids()));
|
|
780
|
+
seen.set(desc.name, new Set(collection ? collection.ids() : []));
|
|
561
781
|
}
|
|
562
|
-
this.
|
|
782
|
+
this.deliveredSeen.set(link.clientId, seen);
|
|
563
783
|
this.roleById.set(link.clientId, joined.role);
|
|
564
784
|
if (reconnecting) {
|
|
565
785
|
this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
|
|
@@ -607,6 +827,36 @@ var TestHarness = class {
|
|
|
607
827
|
this.record("out", link.clientId, type, frame.length);
|
|
608
828
|
this.schedule(link, "out", type, () => link.socket.deliver(frame));
|
|
609
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* The room's own role for this client **right now**, not the one its WELCOME carried.
|
|
832
|
+
*
|
|
833
|
+
* `room.setRole` commits `entry.role` before it hands the catch-up `DELTA` to `core.send`, so by
|
|
834
|
+
* the time that frame reaches `onSend` the authoritative role is already the new one. Judging it
|
|
835
|
+
* against the WELCOME-cached role called every legitimate promotion catch-up a leak. Reading the
|
|
836
|
+
* core here keeps `link.role`/`roleById` truthful too, so `roleFor` and the view half of
|
|
837
|
+
* `checkVisibility` follow a mid-session promotion without a separate hook.
|
|
838
|
+
*/
|
|
839
|
+
currentRole(link) {
|
|
840
|
+
const role = this.coreRef.clients.get(link.clientId)?.role;
|
|
841
|
+
if (role === void 0 || role === link.role) return link.role;
|
|
842
|
+
link.role = role;
|
|
843
|
+
this.roleById.set(link.clientId, role);
|
|
844
|
+
return role;
|
|
845
|
+
}
|
|
846
|
+
/** The live "already delivered" id set for one client and collection, created on demand. */
|
|
847
|
+
deliveredIds(clientId, collection) {
|
|
848
|
+
let byCollection = this.deliveredSeen.get(clientId);
|
|
849
|
+
if (!byCollection) {
|
|
850
|
+
byCollection = /* @__PURE__ */ new Map();
|
|
851
|
+
this.deliveredSeen.set(clientId, byCollection);
|
|
852
|
+
}
|
|
853
|
+
let ids = byCollection.get(collection);
|
|
854
|
+
if (!ids) {
|
|
855
|
+
ids = /* @__PURE__ */ new Set();
|
|
856
|
+
byCollection.set(collection, ids);
|
|
857
|
+
}
|
|
858
|
+
return ids;
|
|
859
|
+
}
|
|
610
860
|
/** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
|
|
611
861
|
checkFrame(link, frame) {
|
|
612
862
|
let delta;
|
|
@@ -615,27 +865,31 @@ var TestHarness = class {
|
|
|
615
865
|
} catch {
|
|
616
866
|
return;
|
|
617
867
|
}
|
|
618
|
-
const
|
|
619
|
-
const
|
|
868
|
+
const role = this.currentRole(link);
|
|
869
|
+
const keep = visibleNames(this.ext, role);
|
|
870
|
+
const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, role);
|
|
620
871
|
for (const dc of delta.collections) {
|
|
621
872
|
const desc = this.ext.collection(dc.name);
|
|
622
|
-
const seen = this.
|
|
873
|
+
const seen = this.deliveredIds(link.clientId, dc.name);
|
|
623
874
|
const leaked = dc.ops.filter((op) => {
|
|
875
|
+
if (op.op === "remove") {
|
|
876
|
+
if (keep.has(dc.name)) return desc.visibility === "spatial-grid" && !seen.has(op.id);
|
|
877
|
+
return !seen.has(op.id);
|
|
878
|
+
}
|
|
624
879
|
if (!keep.has(dc.name)) return true;
|
|
625
880
|
if (desc.visibility !== "spatial-grid") return false;
|
|
626
|
-
if (op.op === "remove") return !seen.has(op.id);
|
|
627
881
|
return !policy.maySeeEntity(desc, op.id);
|
|
628
882
|
});
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
883
|
+
for (const op of dc.ops) {
|
|
884
|
+
if (op.op === "remove") seen.delete(op.id);
|
|
885
|
+
else if (desc.visibility !== "spatial-grid" || policy.maySeeEntity(desc, op.id)) {
|
|
886
|
+
seen.add(op.id);
|
|
633
887
|
}
|
|
634
888
|
}
|
|
635
889
|
if (leaked.length === 0) continue;
|
|
636
890
|
this.frameLeaks.push({
|
|
637
891
|
clientId: link.clientId,
|
|
638
|
-
role
|
|
892
|
+
role,
|
|
639
893
|
collection: dc.name,
|
|
640
894
|
kind: "frame",
|
|
641
895
|
ids: leaked.map((o) => o.id),
|
|
@@ -755,6 +1009,70 @@ var TestHarness = class {
|
|
|
755
1009
|
}
|
|
756
1010
|
return this.joinOne(a ?? {});
|
|
757
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* M7 pf4 (f): a `Promise`-shaped thing that advances the fake clock while it is being awaited.
|
|
1014
|
+
*
|
|
1015
|
+
* Lazy on purpose. The pump starts on the first `then` — that is, when somebody awaits the call
|
|
1016
|
+
* — and not when the call is made, so a test can still hold the promise, assert on the state
|
|
1017
|
+
* before the room has answered, and drive the clock itself. Eager pumping would have taken that
|
|
1018
|
+
* away, and it is the one thing the manual boilerplate was good for.
|
|
1019
|
+
*
|
|
1020
|
+
* The cap is named in the rejection because a room that never replies is a bug in the room, and
|
|
1021
|
+
* a harness that spun on it forever would report the wrong thing in the wrong place.
|
|
1022
|
+
*
|
|
1023
|
+
* `label` names the operation being awaited (`client.call.start()`, `client.requestOwnership()`,
|
|
1024
|
+
* `client.leave()`). Every awaitable the `TestClient` exposes comes through here, so a hang has
|
|
1025
|
+
* to say which one hung; the first report of this was a bare vitest timeout on a
|
|
1026
|
+
* `requestOwnership` that the harness had never pumped at all.
|
|
1027
|
+
*/
|
|
1028
|
+
pumped(inner, label) {
|
|
1029
|
+
if (!this.autoPump) return inner;
|
|
1030
|
+
let settled;
|
|
1031
|
+
inner.then(
|
|
1032
|
+
(value) => {
|
|
1033
|
+
settled = { ok: true, value };
|
|
1034
|
+
},
|
|
1035
|
+
(error) => {
|
|
1036
|
+
settled = { ok: false, error };
|
|
1037
|
+
}
|
|
1038
|
+
);
|
|
1039
|
+
const cap = this.autoPumpTicks;
|
|
1040
|
+
const drive = async () => {
|
|
1041
|
+
const step = this.mode === "tick" ? this.intervalMs : 1;
|
|
1042
|
+
await this.settle();
|
|
1043
|
+
for (let i = 0; i < cap && settled === void 0; i++) {
|
|
1044
|
+
this.clock.advance(step);
|
|
1045
|
+
await this.settle();
|
|
1046
|
+
}
|
|
1047
|
+
if (settled === void 0) {
|
|
1048
|
+
throw new Error(
|
|
1049
|
+
`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.`
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (settled.ok) return settled.value;
|
|
1053
|
+
throw settled.error;
|
|
1054
|
+
};
|
|
1055
|
+
let started;
|
|
1056
|
+
const run = () => started ??= drive();
|
|
1057
|
+
const thenable = {
|
|
1058
|
+
// 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.
|
|
1059
|
+
then: (onOk, onErr) => run().then(onOk, onErr),
|
|
1060
|
+
catch: (onErr) => run().catch(onErr),
|
|
1061
|
+
finally: (onDone) => run().finally(onDone)
|
|
1062
|
+
};
|
|
1063
|
+
return thenable;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* M7 pf4 (e): fire the ping now. See `TestRoom.pingNow` for why a test would want to.
|
|
1067
|
+
*/
|
|
1068
|
+
async pingNow(clientId) {
|
|
1069
|
+
for (const client of this.clientList) {
|
|
1070
|
+
if (clientId !== void 0 && client.id !== clientId) continue;
|
|
1071
|
+
const session = client[INTERNAL_SESSION];
|
|
1072
|
+
session?.pingNow();
|
|
1073
|
+
}
|
|
1074
|
+
await this.settle();
|
|
1075
|
+
}
|
|
758
1076
|
async joinOne(spec) {
|
|
759
1077
|
const options = {
|
|
760
1078
|
url: TEST_URL,
|
|
@@ -777,7 +1095,30 @@ var TestHarness = class {
|
|
|
777
1095
|
marginMs: (latency?.rttMs ?? 0) + (latency?.jitterMs ?? 0)
|
|
778
1096
|
});
|
|
779
1097
|
const client = Object.create(room);
|
|
1098
|
+
const call = new Proxy(
|
|
1099
|
+
{},
|
|
1100
|
+
{
|
|
1101
|
+
get: (_target, prop) => {
|
|
1102
|
+
if (typeof prop !== "string") return void 0;
|
|
1103
|
+
const method = room.call[prop];
|
|
1104
|
+
if (typeof method !== "function") return method;
|
|
1105
|
+
return (params) => this.pumped(
|
|
1106
|
+
method(params),
|
|
1107
|
+
`client.call.${prop}()`
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
);
|
|
780
1112
|
Object.defineProperties(client, {
|
|
1113
|
+
call: { get: () => call, enumerable: true },
|
|
1114
|
+
requestOwnership: {
|
|
1115
|
+
value: (entity, id) => this.pumped(room.requestOwnership(entity, id), "client.requestOwnership()"),
|
|
1116
|
+
enumerable: true
|
|
1117
|
+
},
|
|
1118
|
+
leave: {
|
|
1119
|
+
value: () => this.pumped(room.leave(), "client.leave()"),
|
|
1120
|
+
enumerable: true
|
|
1121
|
+
},
|
|
781
1122
|
id: { get: () => room.me, enumerable: true },
|
|
782
1123
|
roomId: { get: () => room.id, enumerable: true },
|
|
783
1124
|
view: { get: () => room.state, enumerable: true },
|
|
@@ -990,6 +1331,11 @@ var TestHarness = class {
|
|
|
990
1331
|
}
|
|
991
1332
|
stop() {
|
|
992
1333
|
this.stopped = true;
|
|
1334
|
+
for (const entry of [...this.npcSessions.values()]) {
|
|
1335
|
+
this.npcSessions.delete(entry.clientId);
|
|
1336
|
+
entry.stopped = true;
|
|
1337
|
+
void this.stopNpc(entry);
|
|
1338
|
+
}
|
|
993
1339
|
for (const client of this.clientList) client.leave();
|
|
994
1340
|
for (const link of this.links) link.socket.hangUp();
|
|
995
1341
|
this.coreRef.stop();
|
|
@@ -1127,6 +1473,7 @@ var matchers = {
|
|
|
1127
1473
|
export {
|
|
1128
1474
|
materialize,
|
|
1129
1475
|
divergence,
|
|
1476
|
+
DEFAULT_AUTO_PUMP_TICKS,
|
|
1130
1477
|
InProcessSocket,
|
|
1131
1478
|
clockScheduler,
|
|
1132
1479
|
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
|
}
|
|
@@ -335,11 +407,20 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
335
407
|
/** Client ids a frame was dropped for because nobody knows them, each warned about once. */
|
|
336
408
|
private readonly unknownSendWarned;
|
|
337
409
|
private readonly clientList;
|
|
410
|
+
/** D44: live NPC sessions by client id. */
|
|
411
|
+
private readonly npcSessions;
|
|
338
412
|
private readonly roleById;
|
|
339
413
|
private readonly roleOverrides;
|
|
340
414
|
private readonly traceLog;
|
|
341
415
|
private readonly frameLeaks;
|
|
342
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Record ids this harness has actually delivered to a client, per collection. Started as the
|
|
418
|
+
* join snapshot's ids and kept current by `checkFrame`. It was a spatial-grid-only ledger; every
|
|
419
|
+
* collection is tracked now, because a `room.setRole` demotion catches the client up with
|
|
420
|
+
* `remove` ops for a collection its new role cannot see, and those ids are exactly the ones it
|
|
421
|
+
* was legitimately sent before.
|
|
422
|
+
*/
|
|
423
|
+
private readonly deliveredSeen;
|
|
343
424
|
private readonly rejectionLog;
|
|
344
425
|
/** Every handler throw the runtime caught, in order (bug 2). */
|
|
345
426
|
private readonly handlerErrorLog;
|
|
@@ -361,6 +442,11 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
361
442
|
private readonly writeIntervalMs;
|
|
362
443
|
private readonly latency;
|
|
363
444
|
private readonly failOnHandlerError;
|
|
445
|
+
/** M7 pf4 (f): auto-pump settings, resolved once. */
|
|
446
|
+
private readonly autoPump;
|
|
447
|
+
private readonly autoPumpTicks;
|
|
448
|
+
/** M7 pf4 (g): built once, lazily reading `core.physics` on every call. */
|
|
449
|
+
private readonly physicsRef;
|
|
364
450
|
private stopped;
|
|
365
451
|
constructor(definition: RoomDefinition<S>, options: TestRoomOptions);
|
|
366
452
|
get core(): RoomCore<S>;
|
|
@@ -379,9 +465,40 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
379
465
|
get ext(): AnySchema;
|
|
380
466
|
/** D65: the room's bandwidth ledger, or `undefined` unless `profile: true` asked for one. */
|
|
381
467
|
get profile(): ProfileSnapshot | undefined;
|
|
468
|
+
/** M7 pf4 (g): the narrow physics handle. Always present; its methods refuse with a reason. */
|
|
469
|
+
get physics(): TestPhysics;
|
|
470
|
+
/** M7 pf4: the RPC trace, or `undefined` unless `traceRpc: true` armed one. */
|
|
471
|
+
get rpcTrace(): RpcTraceDump | undefined;
|
|
382
472
|
private enterCore;
|
|
383
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec.
|
|
475
|
+
*
|
|
476
|
+
* `identity` is how an NPC gets in: the runtime mints its client id (`npc-<room>-<n>`) before the
|
|
477
|
+
* host is asked to open the session, and every reconnect this transport makes has to come back as
|
|
478
|
+
* that same id with the same `npc` flag, exactly as the supervisor's loopback does.
|
|
479
|
+
*/
|
|
384
480
|
private transportFor;
|
|
481
|
+
/**
|
|
482
|
+
* `room.spawnNPC`, in process. The runtime has already validated the config and minted the id;
|
|
483
|
+
* what is left is the supervisor's half — find the script in the definition's `npcs` map and put
|
|
484
|
+
* a real client behind it.
|
|
485
|
+
*
|
|
486
|
+
* Everything here rides `this.clock`: the bot joins through `transportFor`, so its frames are the
|
|
487
|
+
* same fake-clock timers a player's are, and `scheduler` makes `npc.wait` / `npc.until` fake time
|
|
488
|
+
* too. So an NPC spawned inside a tick becomes visible on the next `t.tick()` / `t.run()` /
|
|
489
|
+
* `t.settle`, and never makes progress behind the test's back.
|
|
490
|
+
*
|
|
491
|
+
* Throwing is deliberate: the caller is `room.spawnNPC` inside a handler, which the runtime runs
|
|
492
|
+
* under `tryRun`, so the sentence lands in `t.handlerErrors` and (by default) fails the test at
|
|
493
|
+
* the clock call that ran the handler. Recording it and carrying on is what this used to do, and
|
|
494
|
+
* it is how "my NPC never showed up" became a silent test.
|
|
495
|
+
*/
|
|
496
|
+
private spawnNpc;
|
|
497
|
+
private despawnNpc;
|
|
498
|
+
/** Stops one NPC's runner: its script winds down, then it leaves the room like any client. */
|
|
499
|
+
private stopNpc;
|
|
500
|
+
/** An NPC failure, filed where a handler throw is filed — see `reportHandlerErrors`. */
|
|
501
|
+
private noteNpcError;
|
|
385
502
|
/**
|
|
386
503
|
* Bug #49: a frame for a client with no link. Held when the core has the client and counts it
|
|
387
504
|
* connected, which is exactly the window between `RoomCore.join` running `onJoin` and
|
|
@@ -423,6 +540,18 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
423
540
|
private handleHello;
|
|
424
541
|
private fail;
|
|
425
542
|
private toClient;
|
|
543
|
+
/**
|
|
544
|
+
* The room's own role for this client **right now**, not the one its WELCOME carried.
|
|
545
|
+
*
|
|
546
|
+
* `room.setRole` commits `entry.role` before it hands the catch-up `DELTA` to `core.send`, so by
|
|
547
|
+
* the time that frame reaches `onSend` the authoritative role is already the new one. Judging it
|
|
548
|
+
* against the WELCOME-cached role called every legitimate promotion catch-up a leak. Reading the
|
|
549
|
+
* core here keeps `link.role`/`roleById` truthful too, so `roleFor` and the view half of
|
|
550
|
+
* `checkVisibility` follow a mid-session promotion without a separate hook.
|
|
551
|
+
*/
|
|
552
|
+
private currentRole;
|
|
553
|
+
/** The live "already delivered" id set for one client and collection, created on demand. */
|
|
554
|
+
private deliveredIds;
|
|
426
555
|
/** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
|
|
427
556
|
private checkFrame;
|
|
428
557
|
private noteReply;
|
|
@@ -452,6 +581,27 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
452
581
|
join<Role extends string = RoleOf<S> & string>(spec?: TestJoinSpec<Role> & {
|
|
453
582
|
role?: RoleOf<S> & string;
|
|
454
583
|
}): Promise<TestClient<S, Role>>;
|
|
584
|
+
/**
|
|
585
|
+
* M7 pf4 (f): a `Promise`-shaped thing that advances the fake clock while it is being awaited.
|
|
586
|
+
*
|
|
587
|
+
* Lazy on purpose. The pump starts on the first `then` — that is, when somebody awaits the call
|
|
588
|
+
* — and not when the call is made, so a test can still hold the promise, assert on the state
|
|
589
|
+
* before the room has answered, and drive the clock itself. Eager pumping would have taken that
|
|
590
|
+
* away, and it is the one thing the manual boilerplate was good for.
|
|
591
|
+
*
|
|
592
|
+
* The cap is named in the rejection because a room that never replies is a bug in the room, and
|
|
593
|
+
* a harness that spun on it forever would report the wrong thing in the wrong place.
|
|
594
|
+
*
|
|
595
|
+
* `label` names the operation being awaited (`client.call.start()`, `client.requestOwnership()`,
|
|
596
|
+
* `client.leave()`). Every awaitable the `TestClient` exposes comes through here, so a hang has
|
|
597
|
+
* to say which one hung; the first report of this was a bare vitest timeout on a
|
|
598
|
+
* `requestOwnership` that the harness had never pumped at all.
|
|
599
|
+
*/
|
|
600
|
+
private pumped;
|
|
601
|
+
/**
|
|
602
|
+
* M7 pf4 (e): fire the ping now. See `TestRoom.pingNow` for why a test would want to.
|
|
603
|
+
*/
|
|
604
|
+
pingNow(clientId?: string): Promise<void>;
|
|
455
605
|
private joinOne;
|
|
456
606
|
/**
|
|
457
607
|
* Drives the clock until `joinRoom` settles. A zero-latency join lands inside the first
|
|
@@ -648,4 +798,4 @@ declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>, op
|
|
|
648
798
|
/** Starts a schema-less relay room in-process and returns the test handle. See the module docblock. */
|
|
649
799
|
declare function testRelay(options?: TestRelayOptions): Promise<TestRelay>;
|
|
650
800
|
|
|
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 };
|
|
801
|
+
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-47O3HZBE.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": "1.0
|
|
3
|
+
"version": "3.1.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,12 @@
|
|
|
23
28
|
"dist"
|
|
24
29
|
],
|
|
25
30
|
"dependencies": {
|
|
26
|
-
"@irtio/
|
|
27
|
-
"@irtio/
|
|
28
|
-
"@irtio/
|
|
29
|
-
"@irtio/
|
|
30
|
-
"@irtio/
|
|
31
|
+
"@irtio/bots": "3.1.0",
|
|
32
|
+
"@irtio/client": "3.1.0",
|
|
33
|
+
"@irtio/protocol": "3.1.0",
|
|
34
|
+
"@irtio/runtime": "3.1.0",
|
|
35
|
+
"@irtio/schema": "3.1.0",
|
|
36
|
+
"@irtio/server": "3.1.0"
|
|
31
37
|
},
|
|
32
38
|
"peerDependencies": {
|
|
33
39
|
"vitest": ">=3"
|