@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
|
|
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
|
-
/**
|
|
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
|
-
|
|
49
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/dist/matchers.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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/
|
|
27
|
-
"@irtio/
|
|
28
|
-
"@irtio/
|
|
29
|
-
"@irtio/server": "0.
|
|
30
|
-
"@irtio/schema": "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"
|