@irtio/testing 0.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/LICENSE +21 -0
- package/dist/assertions-BP4as6ol.d.ts +38 -0
- package/dist/chunk-O3OZKC3D.js +851 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +469 -0
- package/dist/matchers.d.ts +31 -0
- package/dist/matchers.js +18 -0
- package/package.json +44 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
// src/compare.ts
|
|
2
|
+
import {
|
|
3
|
+
cloneValue,
|
|
4
|
+
computeDirty,
|
|
5
|
+
createState,
|
|
6
|
+
defaultRecord
|
|
7
|
+
} from "@irtio/schema";
|
|
8
|
+
function deepEqual(a, b) {
|
|
9
|
+
if (Object.is(a, b)) return true;
|
|
10
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
11
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
12
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
13
|
+
}
|
|
14
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
15
|
+
const ra = a;
|
|
16
|
+
const rb = b;
|
|
17
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(ra), ...Object.keys(rb)]);
|
|
18
|
+
for (const k of keys) if (!deepEqual(ra[k], rb[k])) return false;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
function materialize(ext, view) {
|
|
22
|
+
const out = createState(ext);
|
|
23
|
+
for (const c of ext.collections) {
|
|
24
|
+
const value = view[c.name];
|
|
25
|
+
if (value === void 0 || value === null) continue;
|
|
26
|
+
if (c.kind === "entity") {
|
|
27
|
+
const src = value;
|
|
28
|
+
const dst = out[c.name];
|
|
29
|
+
for (const id of [...src.ids()]) {
|
|
30
|
+
const record = src.get(id);
|
|
31
|
+
if (!record) continue;
|
|
32
|
+
const values = {};
|
|
33
|
+
for (const f of c.fields) values[f.name] = cloneValue(record[f.name]);
|
|
34
|
+
dst.add(id, values, { owner: src.ownerOf(id) ?? "" });
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
const record = value;
|
|
38
|
+
const dst = out[c.name];
|
|
39
|
+
for (const f of c.fields) dst[f.name] = cloneValue(record[f.name]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function divergence(ext, authority, client, visible) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const [name, cd] of computeDirty(ext, authority, client)) {
|
|
47
|
+
if (!visible.has(name)) continue;
|
|
48
|
+
for (const id of cd.added) out.push(`${name}[${id}]: only in the client's view`);
|
|
49
|
+
for (const id of cd.removed) out.push(`${name}[${id}]: missing from the client's view`);
|
|
50
|
+
for (const [id, rd] of cd.updated) {
|
|
51
|
+
const fields = [...rd.mask.fields].map((i) => fieldNameAt(ext, name, i)).filter((n) => n !== void 0);
|
|
52
|
+
const what = rd.owner ? [...fields, "owner"] : fields;
|
|
53
|
+
out.push(`${name}[${id}]: ${what.join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function fieldNameAt(ext, collection, index) {
|
|
59
|
+
const c = ext.collections.find((x) => x.name === collection);
|
|
60
|
+
return c?.fields[index]?.name;
|
|
61
|
+
}
|
|
62
|
+
function singletonIsDefault(desc, value) {
|
|
63
|
+
return deepEqual(value, defaultRecord(desc));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/scheduler.ts
|
|
67
|
+
function clockScheduler(clock) {
|
|
68
|
+
return {
|
|
69
|
+
now: () => clock.now(),
|
|
70
|
+
setTimeout(fn, ms) {
|
|
71
|
+
const handle = clock.setTimeout(fn, ms);
|
|
72
|
+
return () => clock.clearTimeout(handle);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/harness.ts
|
|
78
|
+
import { joinRoom } from "@irtio/client";
|
|
79
|
+
import {
|
|
80
|
+
ErrorCode,
|
|
81
|
+
FrameType,
|
|
82
|
+
decodeCall,
|
|
83
|
+
decodeFrame,
|
|
84
|
+
decodeHello,
|
|
85
|
+
decodePing,
|
|
86
|
+
decodeReply,
|
|
87
|
+
encodeErrorPayload,
|
|
88
|
+
encodeFrame,
|
|
89
|
+
encodePong,
|
|
90
|
+
encodeWelcome,
|
|
91
|
+
formatError,
|
|
92
|
+
rpcTable
|
|
93
|
+
} from "@irtio/protocol";
|
|
94
|
+
import { Mulberry32, RoomCore, RoomFullError, visibleNames } from "@irtio/runtime";
|
|
95
|
+
import { FakeClock, HarnessHost, frameTypeName } from "@irtio/runtime/test";
|
|
96
|
+
import { decodeDelta } from "@irtio/schema";
|
|
97
|
+
|
|
98
|
+
// src/link.ts
|
|
99
|
+
var InProcessSocket = class {
|
|
100
|
+
constructor(outbound, onLocalClose) {
|
|
101
|
+
this.outbound = outbound;
|
|
102
|
+
this.onLocalClose = onLocalClose;
|
|
103
|
+
}
|
|
104
|
+
outbound;
|
|
105
|
+
onLocalClose;
|
|
106
|
+
onopen = null;
|
|
107
|
+
onmessage = null;
|
|
108
|
+
onclose = null;
|
|
109
|
+
onerror = null;
|
|
110
|
+
/** `true` once either end has closed the link. */
|
|
111
|
+
closed = false;
|
|
112
|
+
send(bytes) {
|
|
113
|
+
if (this.closed) return;
|
|
114
|
+
this.outbound(bytes);
|
|
115
|
+
}
|
|
116
|
+
/** `room.leave()` and `t.stop()` come through here. */
|
|
117
|
+
close() {
|
|
118
|
+
if (this.closed) return;
|
|
119
|
+
this.closed = true;
|
|
120
|
+
this.onLocalClose();
|
|
121
|
+
this.onclose?.();
|
|
122
|
+
}
|
|
123
|
+
/** Harness → client: the socket is up, send `HELLO`. */
|
|
124
|
+
open() {
|
|
125
|
+
if (!this.closed) this.onopen?.();
|
|
126
|
+
}
|
|
127
|
+
/** Harness → client: one frame arrived. */
|
|
128
|
+
deliver(bytes) {
|
|
129
|
+
if (!this.closed) this.onmessage?.(bytes);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Harness → client: the server hung up — `t.stop()` after the room closed, or `client.drop()`
|
|
133
|
+
* simulating a network drop. Unlike `close()` this never calls `onLocalClose`: the harness
|
|
134
|
+
* decides for itself whether that meant a leave (`stop()`) or a disconnect (`drop()`).
|
|
135
|
+
*/
|
|
136
|
+
hangUp(info) {
|
|
137
|
+
if (this.closed) return;
|
|
138
|
+
this.closed = true;
|
|
139
|
+
this.onclose?.(info);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// src/harness.ts
|
|
144
|
+
var TEST_URL = "ws://localhost:7070";
|
|
145
|
+
var TEST_KEY = "test";
|
|
146
|
+
var DROPPABLE = /* @__PURE__ */ new Set([
|
|
147
|
+
FrameType.WRITE,
|
|
148
|
+
FrameType.DELTA,
|
|
149
|
+
FrameType.CORRECT,
|
|
150
|
+
FrameType.MSG
|
|
151
|
+
]);
|
|
152
|
+
var MICROTASK_TURNS = 8;
|
|
153
|
+
var SETTLE_ROUNDS = 64;
|
|
154
|
+
var JOIN_TIMEOUT_MS = 3e4;
|
|
155
|
+
async function microtasks(turns = MICROTASK_TURNS) {
|
|
156
|
+
for (let i = 0; i < turns; i++) await Promise.resolve();
|
|
157
|
+
}
|
|
158
|
+
var TestHarness = class {
|
|
159
|
+
clock = new FakeClock();
|
|
160
|
+
host;
|
|
161
|
+
coreRef;
|
|
162
|
+
scheduler;
|
|
163
|
+
rng;
|
|
164
|
+
definition;
|
|
165
|
+
links = [];
|
|
166
|
+
byId = /* @__PURE__ */ new Map();
|
|
167
|
+
resumeTokens = /* @__PURE__ */ new Map();
|
|
168
|
+
clientList = [];
|
|
169
|
+
roleById = /* @__PURE__ */ new Map();
|
|
170
|
+
roleOverrides = /* @__PURE__ */ new Map();
|
|
171
|
+
traceLog = [];
|
|
172
|
+
frameLeaks = [];
|
|
173
|
+
rejectionLog = [];
|
|
174
|
+
/** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
|
|
175
|
+
reconnectCounts = /* @__PURE__ */ new Map();
|
|
176
|
+
nextClientId = 1;
|
|
177
|
+
droppedFrames = 0;
|
|
178
|
+
/** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
|
|
179
|
+
activity = 0;
|
|
180
|
+
inCore = false;
|
|
181
|
+
deferred = [];
|
|
182
|
+
roomId;
|
|
183
|
+
writeIntervalMs;
|
|
184
|
+
latency;
|
|
185
|
+
stopped = false;
|
|
186
|
+
constructor(definition, options) {
|
|
187
|
+
this.definition = configure(definition, options);
|
|
188
|
+
this.roomId = options.roomId ?? "test-room";
|
|
189
|
+
this.latency = options.latency;
|
|
190
|
+
this.writeIntervalMs = options.writeIntervalMs;
|
|
191
|
+
this.rng = new Mulberry32(options.seed ?? 1);
|
|
192
|
+
this.scheduler = clockScheduler(this.clock);
|
|
193
|
+
this.host = new HarnessHost(this.clock);
|
|
194
|
+
this.host.onSend = (clientId, frame) => {
|
|
195
|
+
const link = this.byId.get(clientId);
|
|
196
|
+
if (link?.connected) this.toClient(link, frame);
|
|
197
|
+
};
|
|
198
|
+
this.coreRef = new RoomCore(this.definition, this.host, {
|
|
199
|
+
roomId: this.roomId,
|
|
200
|
+
seed: options.seed ?? 1
|
|
201
|
+
});
|
|
202
|
+
this.coreRef.start();
|
|
203
|
+
}
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
// Accessors
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
get core() {
|
|
208
|
+
return this.coreRef;
|
|
209
|
+
}
|
|
210
|
+
get state() {
|
|
211
|
+
return this.coreRef.state;
|
|
212
|
+
}
|
|
213
|
+
get plain() {
|
|
214
|
+
return this.coreRef.plain;
|
|
215
|
+
}
|
|
216
|
+
get trace() {
|
|
217
|
+
return this.traceLog;
|
|
218
|
+
}
|
|
219
|
+
get rejections() {
|
|
220
|
+
return this.rejectionLog;
|
|
221
|
+
}
|
|
222
|
+
get dropped() {
|
|
223
|
+
return this.droppedFrames;
|
|
224
|
+
}
|
|
225
|
+
get now() {
|
|
226
|
+
return this.clock.time;
|
|
227
|
+
}
|
|
228
|
+
get tickCount() {
|
|
229
|
+
return this.coreRef.tick;
|
|
230
|
+
}
|
|
231
|
+
get mode() {
|
|
232
|
+
return this.coreRef.mode;
|
|
233
|
+
}
|
|
234
|
+
get clients() {
|
|
235
|
+
return this.clientList;
|
|
236
|
+
}
|
|
237
|
+
get intervalMs() {
|
|
238
|
+
return 1e3 / this.definition.config.tickRate;
|
|
239
|
+
}
|
|
240
|
+
/** The extended schema every frame is encoded against. */
|
|
241
|
+
get ext() {
|
|
242
|
+
return this.coreRef.ext;
|
|
243
|
+
}
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
// The core is never re-entered
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
enterCore(fn) {
|
|
248
|
+
if (this.inCore) {
|
|
249
|
+
this.deferred.push(fn);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.inCore = true;
|
|
253
|
+
try {
|
|
254
|
+
fn();
|
|
255
|
+
} finally {
|
|
256
|
+
this.inCore = false;
|
|
257
|
+
}
|
|
258
|
+
while (this.deferred.length > 0) {
|
|
259
|
+
const next = this.deferred.shift();
|
|
260
|
+
if (next) this.enterCore(next);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
// The link
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
/** A `Transport` bound to one `t.join()`; reconnects call `connect` again with the same spec. */
|
|
267
|
+
transportFor(spec) {
|
|
268
|
+
return {
|
|
269
|
+
connect: () => {
|
|
270
|
+
const link = {
|
|
271
|
+
socket: void 0,
|
|
272
|
+
clientId: `c${this.nextClientId++}`,
|
|
273
|
+
role: "",
|
|
274
|
+
joined: false,
|
|
275
|
+
connected: true,
|
|
276
|
+
latency: spec.latency,
|
|
277
|
+
nextIn: this.clock.time,
|
|
278
|
+
nextOut: this.clock.time,
|
|
279
|
+
calls: /* @__PURE__ */ new Map()
|
|
280
|
+
};
|
|
281
|
+
link.socket = new InProcessSocket(
|
|
282
|
+
(bytes) => this.fromClient(link, bytes),
|
|
283
|
+
() => this.linkClosed(link)
|
|
284
|
+
);
|
|
285
|
+
this.links.push(link);
|
|
286
|
+
this.clock.setTimeout(() => link.socket.open(), 0);
|
|
287
|
+
this.activity++;
|
|
288
|
+
return link.socket;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** The client end hung up: a deliberate `leave()`, or `t.stop()`. Treated as a graceful leave. */
|
|
293
|
+
linkClosed(link) {
|
|
294
|
+
if (!link.connected) return;
|
|
295
|
+
link.connected = false;
|
|
296
|
+
this.byId.delete(link.clientId);
|
|
297
|
+
if (!link.joined || this.stopped) return;
|
|
298
|
+
link.joined = false;
|
|
299
|
+
this.enterCore(() => this.coreRef.leave(link.clientId, "left"));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* `TestClient.drop()`: severs the transport out from under a client, the way a network drop
|
|
303
|
+
* would — the room only learns the client is *disconnected* (`RoomCore.markDisconnected`, the
|
|
304
|
+
* same call the real supervisor makes when a socket closes mid-session), not that it left. The
|
|
305
|
+
* client's own `onclose` handler then arms its reconnect backoff on the fake clock, exactly as
|
|
306
|
+
* it would over a real killed socket.
|
|
307
|
+
*/
|
|
308
|
+
dropClient(clientId, options) {
|
|
309
|
+
const link = this.byId.get(clientId);
|
|
310
|
+
if (!link || !link.connected || link.socket.closed) return;
|
|
311
|
+
link.connected = false;
|
|
312
|
+
this.byId.delete(clientId);
|
|
313
|
+
this.enterCore(() => this.coreRef.markDisconnected(clientId));
|
|
314
|
+
link.socket.hangUp(options);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Schedules one frame's arrival on the fake clock. Returns `false` when the loss die dropped it.
|
|
318
|
+
* The `next*` clamp keeps a jittered stream in order: a WebSocket never reorders.
|
|
319
|
+
*/
|
|
320
|
+
schedule(link, dir, type, run) {
|
|
321
|
+
const spec = link.latency ?? this.latency;
|
|
322
|
+
const loss = spec?.loss ?? 0;
|
|
323
|
+
if (loss > 0 && DROPPABLE.has(type) && this.rng.next() < loss) {
|
|
324
|
+
this.droppedFrames++;
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
const jitterMs = spec?.jitterMs ?? 0;
|
|
328
|
+
const oneWay = (spec?.rttMs ?? 0) / 2 + (jitterMs > 0 ? this.rng.next() * jitterMs : 0);
|
|
329
|
+
const floor = dir === "in" ? link.nextIn : link.nextOut;
|
|
330
|
+
const at = Math.max(this.clock.time + oneWay, floor);
|
|
331
|
+
if (dir === "in") link.nextIn = at;
|
|
332
|
+
else link.nextOut = at;
|
|
333
|
+
this.activity++;
|
|
334
|
+
this.clock.setTimeout(() => {
|
|
335
|
+
this.activity++;
|
|
336
|
+
run();
|
|
337
|
+
}, at - this.clock.time);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
// Client to room
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
fromClient(link, frame) {
|
|
344
|
+
let type;
|
|
345
|
+
let payload;
|
|
346
|
+
try {
|
|
347
|
+
const decoded = decodeFrame(frame);
|
|
348
|
+
type = decoded.type;
|
|
349
|
+
payload = decoded.payload;
|
|
350
|
+
} catch {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (type === FrameType.HELLO) this.preresolve(link, payload);
|
|
354
|
+
if (type === FrameType.CALL) this.noteCall(link, payload);
|
|
355
|
+
this.record("in", link.clientId, type, frame.length);
|
|
356
|
+
this.schedule(link, "in", type, () => this.toRoom(link, type, payload, frame));
|
|
357
|
+
}
|
|
358
|
+
preresolve(link, payload) {
|
|
359
|
+
try {
|
|
360
|
+
const hello = decodeHello(payload);
|
|
361
|
+
const resumed = hello.resumeToken ? this.resumeTokens.get(hello.resumeToken) : void 0;
|
|
362
|
+
if (resumed) link.clientId = resumed;
|
|
363
|
+
} catch {
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
noteCall(link, payload) {
|
|
367
|
+
try {
|
|
368
|
+
const call = decodeCall(payload);
|
|
369
|
+
const desc = rpcTable(this.definition.schema).find((r) => r.index === call.rpcId);
|
|
370
|
+
if (desc) link.calls.set(call.reqId, desc.name);
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
toRoom(link, type, payload, frame) {
|
|
375
|
+
if (!link.connected || this.stopped) return;
|
|
376
|
+
if (type === FrameType.HELLO) {
|
|
377
|
+
this.handleHello(link, payload);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (type === FrameType.PING) {
|
|
381
|
+
let t = 0;
|
|
382
|
+
try {
|
|
383
|
+
t = decodePing(payload).t;
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
this.toClient(
|
|
387
|
+
link,
|
|
388
|
+
encodeFrame(FrameType.PONG, encodePong({ t, serverTick: this.coreRef.tick }))
|
|
389
|
+
);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (type === FrameType.LEAVE) {
|
|
393
|
+
this.handleLeaveFrame(link);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!link.joined) return;
|
|
397
|
+
this.enterCore(() => this.coreRef.receive(link.clientId, frame));
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* `LEAVE`, the wire counterpart of `linkClosed`'s deliberate-leave branch: call
|
|
401
|
+
* `RoomCore.leave` with `'left'` right away instead of routing the frame into
|
|
402
|
+
* `RoomCore.receive` (which has no case for it — that would hit its `badFrame` fallback) or
|
|
403
|
+
* waiting for the socket's close event. Marks the link gone first so the close that follows
|
|
404
|
+
* moments later (`leave()` always closes its socket) finds `linkClosed` a no-op.
|
|
405
|
+
*/
|
|
406
|
+
handleLeaveFrame(link) {
|
|
407
|
+
if (!link.connected || !link.joined) return;
|
|
408
|
+
link.connected = false;
|
|
409
|
+
link.joined = false;
|
|
410
|
+
this.byId.delete(link.clientId);
|
|
411
|
+
this.enterCore(() => this.coreRef.leave(link.clientId, "left"));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The whole supervisor: allocate an id, `RoomCore.join`, answer with a real `WELCOME`. The
|
|
415
|
+
* resume token is the client id — there is nothing to sign in-process.
|
|
416
|
+
*/
|
|
417
|
+
handleHello(link, payload) {
|
|
418
|
+
let role;
|
|
419
|
+
let name;
|
|
420
|
+
let reconnecting = false;
|
|
421
|
+
try {
|
|
422
|
+
const hello = decodeHello(payload);
|
|
423
|
+
role = hello.role;
|
|
424
|
+
name = hello.name;
|
|
425
|
+
reconnecting = hello.resumeToken !== void 0 && this.resumeTokens.has(hello.resumeToken);
|
|
426
|
+
} catch {
|
|
427
|
+
this.fail(link, "E_BAD_FRAME", "malformed HELLO");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
let result;
|
|
431
|
+
let failure;
|
|
432
|
+
this.enterCore(() => {
|
|
433
|
+
try {
|
|
434
|
+
result = this.coreRef.join(link.clientId, {
|
|
435
|
+
...role !== void 0 ? { role } : {},
|
|
436
|
+
...name !== void 0 ? { name } : {},
|
|
437
|
+
...reconnecting ? { reconnecting: true } : {}
|
|
438
|
+
});
|
|
439
|
+
} catch (err) {
|
|
440
|
+
failure = err;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
if (!result) {
|
|
444
|
+
const full = failure instanceof RoomFullError;
|
|
445
|
+
this.fail(
|
|
446
|
+
link,
|
|
447
|
+
full ? "E_ROOM_FULL" : "E_INTERNAL",
|
|
448
|
+
full ? formatError("E_ROOM_FULL", { roomId: this.roomId }) : failure instanceof Error ? failure.message : String(failure)
|
|
449
|
+
);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const joined = result;
|
|
453
|
+
link.joined = true;
|
|
454
|
+
link.connected = true;
|
|
455
|
+
link.role = joined.role;
|
|
456
|
+
this.byId.set(link.clientId, link);
|
|
457
|
+
this.roleById.set(link.clientId, joined.role);
|
|
458
|
+
if (reconnecting) {
|
|
459
|
+
this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
|
|
460
|
+
}
|
|
461
|
+
const resumeToken = `resume:${link.clientId}`;
|
|
462
|
+
this.resumeTokens.set(resumeToken, link.clientId);
|
|
463
|
+
this.toClient(
|
|
464
|
+
link,
|
|
465
|
+
encodeFrame(
|
|
466
|
+
FrameType.WELCOME,
|
|
467
|
+
encodeWelcome({
|
|
468
|
+
clientId: link.clientId,
|
|
469
|
+
role: joined.role,
|
|
470
|
+
tick: joined.tick,
|
|
471
|
+
snapshot: joined.snapshot,
|
|
472
|
+
resumeToken,
|
|
473
|
+
roomId: this.roomId,
|
|
474
|
+
tickIntervalMs: Math.round(this.intervalMs)
|
|
475
|
+
})
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
fail(link, code, message) {
|
|
480
|
+
this.toClient(
|
|
481
|
+
link,
|
|
482
|
+
encodeFrame(
|
|
483
|
+
FrameType.ERROR,
|
|
484
|
+
encodeErrorPayload({ code: ErrorCode[code].code, message, fatal: true })
|
|
485
|
+
)
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
// -------------------------------------------------------------------------
|
|
489
|
+
// Room to client
|
|
490
|
+
// -------------------------------------------------------------------------
|
|
491
|
+
toClient(link, frame) {
|
|
492
|
+
const type = frame[0] ?? -1;
|
|
493
|
+
if (type === FrameType.DELTA || type === FrameType.CORRECT) this.checkFrame(link, frame);
|
|
494
|
+
if (type === FrameType.REPLY) this.noteReply(link, frame);
|
|
495
|
+
this.record("out", link.clientId, type, frame.length);
|
|
496
|
+
this.schedule(link, "out", type, () => link.socket.deliver(frame));
|
|
497
|
+
}
|
|
498
|
+
/** A `DELTA`/`CORRECT` naming a collection this client's role may not see is a leak. */
|
|
499
|
+
checkFrame(link, frame) {
|
|
500
|
+
let delta;
|
|
501
|
+
try {
|
|
502
|
+
delta = decodeDelta(this.ext, decodeFrame(frame).payload);
|
|
503
|
+
} catch {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const keep = visibleNames(this.ext, link.role);
|
|
507
|
+
for (const dc of delta.collections) {
|
|
508
|
+
if (keep.has(dc.name) || dc.ops.length === 0) continue;
|
|
509
|
+
this.frameLeaks.push({
|
|
510
|
+
clientId: link.clientId,
|
|
511
|
+
role: link.role,
|
|
512
|
+
collection: dc.name,
|
|
513
|
+
kind: "frame",
|
|
514
|
+
ids: dc.ops.map((o) => o.id),
|
|
515
|
+
tick: delta.tick
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
noteReply(link, frame) {
|
|
520
|
+
try {
|
|
521
|
+
const reply = decodeReply(decodeFrame(frame).payload);
|
|
522
|
+
const rpc = link.calls.get(reply.reqId);
|
|
523
|
+
if (rpc === void 0) return;
|
|
524
|
+
link.calls.delete(reply.reqId);
|
|
525
|
+
if (reply.ok) return;
|
|
526
|
+
this.rejectionLog.push({
|
|
527
|
+
clientId: link.clientId,
|
|
528
|
+
rpc,
|
|
529
|
+
error: reply.error,
|
|
530
|
+
tick: this.coreRef.tick
|
|
531
|
+
});
|
|
532
|
+
} catch {
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
record(dir, clientId, type, bytes) {
|
|
536
|
+
let frame;
|
|
537
|
+
try {
|
|
538
|
+
frame = frameTypeName(type);
|
|
539
|
+
} catch {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.traceLog.push({
|
|
543
|
+
dir,
|
|
544
|
+
clientId,
|
|
545
|
+
frame,
|
|
546
|
+
tick: this.coreRef.tick,
|
|
547
|
+
bytes,
|
|
548
|
+
at: this.clock.time
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
// -------------------------------------------------------------------------
|
|
552
|
+
// Clock
|
|
553
|
+
// -------------------------------------------------------------------------
|
|
554
|
+
/** Fires everything due, then lets the clients' promises run, until nothing new is in flight. */
|
|
555
|
+
async settle() {
|
|
556
|
+
for (let i = 0; i < SETTLE_ROUNDS; i++) {
|
|
557
|
+
const before = this.activity;
|
|
558
|
+
await microtasks();
|
|
559
|
+
this.clock.advance(0);
|
|
560
|
+
if (this.activity === before) return;
|
|
561
|
+
}
|
|
562
|
+
throw new Error("testRoom: the frame pump did not settle");
|
|
563
|
+
}
|
|
564
|
+
async tick(n = 1) {
|
|
565
|
+
for (let i = 0; i < n; i++) {
|
|
566
|
+
this.clock.advance(this.mode === "tick" ? this.intervalMs : 0);
|
|
567
|
+
await this.settle();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async run(ms) {
|
|
571
|
+
this.clock.advance(ms);
|
|
572
|
+
await this.settle();
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Advances until `pred()` holds. In **event mode** a tick is a zero-length clock step, so this
|
|
576
|
+
* advances `stepMs` (1 ms by default) of fake time per attempt instead — otherwise a condition
|
|
577
|
+
* that depends on a timer, such as a dropped client's 250 ms reconnect backoff, could never
|
|
578
|
+
* become true no matter how large `maxTicks` was. Found when a test spent its whole budget
|
|
579
|
+
* on `t.until: predicate still false after 1000 ticks`.
|
|
580
|
+
*/
|
|
581
|
+
async until(pred, options = {}) {
|
|
582
|
+
const maxTicks = options.maxTicks ?? 1e3;
|
|
583
|
+
const stepMs = options.stepMs ?? (this.mode === "tick" ? this.intervalMs : 1);
|
|
584
|
+
await this.settle();
|
|
585
|
+
if (pred()) return;
|
|
586
|
+
for (let i = 0; i < maxTicks; i++) {
|
|
587
|
+
this.clock.advance(stepMs);
|
|
588
|
+
await this.settle();
|
|
589
|
+
if (pred()) return;
|
|
590
|
+
}
|
|
591
|
+
throw new Error(
|
|
592
|
+
`t.until: predicate still false after ${maxTicks} attempts (${maxTicks * stepMs} ms of fake time, mode=${this.mode})`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
async join(a, b) {
|
|
596
|
+
if (typeof a === "number") {
|
|
597
|
+
const out = [];
|
|
598
|
+
for (let i = 0; i < a; i++) out.push(await this.joinOne(b ?? {}));
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
return this.joinOne(a ?? {});
|
|
602
|
+
}
|
|
603
|
+
async joinOne(spec) {
|
|
604
|
+
const options = {
|
|
605
|
+
url: TEST_URL,
|
|
606
|
+
key: TEST_KEY,
|
|
607
|
+
room: this.roomId,
|
|
608
|
+
transport: this.transportFor(spec),
|
|
609
|
+
scheduler: this.scheduler,
|
|
610
|
+
...this.writeIntervalMs !== void 0 ? { writeIntervalMs: this.writeIntervalMs } : {},
|
|
611
|
+
...spec.role !== void 0 ? { role: spec.role } : {},
|
|
612
|
+
...spec.name !== void 0 ? { name: spec.name } : {},
|
|
613
|
+
...spec.rpc !== void 0 ? { rpc: spec.rpc } : {}
|
|
614
|
+
};
|
|
615
|
+
const room = await this.awaitJoin(joinRoom(this.definition.schema, options));
|
|
616
|
+
const client = Object.create(room);
|
|
617
|
+
Object.defineProperties(client, {
|
|
618
|
+
id: { get: () => room.me, enumerable: true },
|
|
619
|
+
roomId: { get: () => room.id, enumerable: true },
|
|
620
|
+
view: { get: () => room.state, enumerable: true },
|
|
621
|
+
reconnects: { get: () => this.reconnectCounts.get(room.me) ?? 0, enumerable: true },
|
|
622
|
+
drop: {
|
|
623
|
+
value: (options2) => this.dropClient(room.me, options2),
|
|
624
|
+
enumerable: true
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
this.clientList.push(client);
|
|
628
|
+
return client;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Drives the clock until `joinRoom` settles. A zero-latency join lands inside the first
|
|
632
|
+
* `advance(0)`; a slow link needs the 1 ms steps.
|
|
633
|
+
*/
|
|
634
|
+
async awaitJoin(promise) {
|
|
635
|
+
let settled = false;
|
|
636
|
+
let value;
|
|
637
|
+
let error;
|
|
638
|
+
promise.then(
|
|
639
|
+
(v) => {
|
|
640
|
+
settled = true;
|
|
641
|
+
value = v;
|
|
642
|
+
},
|
|
643
|
+
(e) => {
|
|
644
|
+
settled = true;
|
|
645
|
+
error = e instanceof Error ? e : new Error(String(e));
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
this.clock.advance(0);
|
|
649
|
+
await microtasks();
|
|
650
|
+
for (let ms = 0; ms < JOIN_TIMEOUT_MS && !settled; ms++) {
|
|
651
|
+
this.clock.advance(1);
|
|
652
|
+
await microtasks();
|
|
653
|
+
}
|
|
654
|
+
if (error !== void 0) throw error;
|
|
655
|
+
if (!settled) throw new Error("testRoom: join never resolved");
|
|
656
|
+
await this.settle();
|
|
657
|
+
return value;
|
|
658
|
+
}
|
|
659
|
+
// -------------------------------------------------------------------------
|
|
660
|
+
// Visibility, convergence, bandwidth
|
|
661
|
+
// -------------------------------------------------------------------------
|
|
662
|
+
/**
|
|
663
|
+
* Judges `clientId` as `role` in `checkVisibility` from now on, without telling the room. The
|
|
664
|
+
* only way to prove the leak detector fires: a correct room never produces a leak, so a test
|
|
665
|
+
* that asserts the checker works has to assert a client against a role it did not join as.
|
|
666
|
+
*/
|
|
667
|
+
pretendRole(clientId, role) {
|
|
668
|
+
this.roleOverrides.set(clientId, role);
|
|
669
|
+
}
|
|
670
|
+
roleFor(clientId) {
|
|
671
|
+
return this.roleOverrides.get(clientId) ?? this.roleById.get(clientId) ?? "";
|
|
672
|
+
}
|
|
673
|
+
checkVisibility() {
|
|
674
|
+
const leaks = [...this.frameLeaks];
|
|
675
|
+
for (const client of this.clientList) {
|
|
676
|
+
const role = this.roleFor(client.id);
|
|
677
|
+
const keep = visibleNames(this.ext, role);
|
|
678
|
+
const view = client.view;
|
|
679
|
+
for (const c of this.ext.collections) {
|
|
680
|
+
if (keep.has(c.name)) continue;
|
|
681
|
+
if (c.kind === "entity") {
|
|
682
|
+
const coll = view[c.name];
|
|
683
|
+
if (!coll || coll.size === 0) continue;
|
|
684
|
+
leaks.push({
|
|
685
|
+
clientId: client.id,
|
|
686
|
+
role,
|
|
687
|
+
collection: c.name,
|
|
688
|
+
kind: "view",
|
|
689
|
+
ids: [...coll.ids()],
|
|
690
|
+
tick: this.coreRef.tick
|
|
691
|
+
});
|
|
692
|
+
} else {
|
|
693
|
+
if (singletonIsDefault(c, view[c.name])) continue;
|
|
694
|
+
leaks.push({
|
|
695
|
+
clientId: client.id,
|
|
696
|
+
role,
|
|
697
|
+
collection: c.name,
|
|
698
|
+
kind: "view",
|
|
699
|
+
ids: [""],
|
|
700
|
+
tick: this.coreRef.tick
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return leaks;
|
|
706
|
+
}
|
|
707
|
+
/** Per-client divergence from the authority, restricted to what that client's role may see. */
|
|
708
|
+
convergence() {
|
|
709
|
+
const out = [];
|
|
710
|
+
for (const client of this.clientList) {
|
|
711
|
+
const role = this.roleFor(client.id);
|
|
712
|
+
const view = materialize(this.ext, client.view);
|
|
713
|
+
out.push({
|
|
714
|
+
clientId: client.id,
|
|
715
|
+
differences: divergence(this.ext, this.plain, view, visibleNames(this.ext, role))
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
/** Outbound bytes per client, and the ticks they were spread over. */
|
|
721
|
+
bandwidth() {
|
|
722
|
+
const ticks = Math.max(1, this.coreRef.tick);
|
|
723
|
+
const totals = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const entry of this.traceLog) {
|
|
725
|
+
if (entry.dir !== "out") continue;
|
|
726
|
+
totals.set(entry.clientId, (totals.get(entry.clientId) ?? 0) + entry.bytes);
|
|
727
|
+
}
|
|
728
|
+
return [...totals].map(([clientId, bytes]) => ({ clientId, bytes, perTick: bytes / ticks }));
|
|
729
|
+
}
|
|
730
|
+
stop() {
|
|
731
|
+
this.stopped = true;
|
|
732
|
+
for (const client of this.clientList) client.leave();
|
|
733
|
+
for (const link of this.links) link.socket.hangUp();
|
|
734
|
+
this.coreRef.stop();
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
function configure(definition, options) {
|
|
738
|
+
if (options.mode === void 0 && options.tickRate === void 0) return definition;
|
|
739
|
+
return {
|
|
740
|
+
...definition,
|
|
741
|
+
config: {
|
|
742
|
+
...definition.config,
|
|
743
|
+
...options.mode !== void 0 ? { mode: options.mode } : {},
|
|
744
|
+
...options.tickRate !== void 0 ? { tickRate: options.tickRate } : {}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/assertions.ts
|
|
750
|
+
var TRACE_TAIL = 10;
|
|
751
|
+
function harnessOf(received) {
|
|
752
|
+
if (!(received instanceof TestHarness)) {
|
|
753
|
+
throw new TypeError("irtio matchers expect the object returned by testRoom()");
|
|
754
|
+
}
|
|
755
|
+
return received;
|
|
756
|
+
}
|
|
757
|
+
function traceTail(t) {
|
|
758
|
+
const tail = t.trace.slice(-TRACE_TAIL);
|
|
759
|
+
if (tail.length === 0) return "trace: empty";
|
|
760
|
+
const lines = tail.map(
|
|
761
|
+
(e) => ` ${Math.round(e.at)}ms tick ${e.tick} ${e.dir === "in" ? "\u2192" : "\u2190"} ${e.clientId} ${e.frame} ${e.bytes}B`
|
|
762
|
+
);
|
|
763
|
+
return `trace (last ${tail.length}):
|
|
764
|
+
${lines.join("\n")}`;
|
|
765
|
+
}
|
|
766
|
+
function withTrace(t, body) {
|
|
767
|
+
return `${body}
|
|
768
|
+
|
|
769
|
+
${traceTail(t)}`;
|
|
770
|
+
}
|
|
771
|
+
function toHaveNoVisibilityLeaks(received) {
|
|
772
|
+
const t = harnessOf(received);
|
|
773
|
+
const leaks = t.checkVisibility();
|
|
774
|
+
return {
|
|
775
|
+
pass: leaks.length === 0,
|
|
776
|
+
message: () => leaks.length === 0 ? withTrace(t, "expected the room to leak something, but every view was role-clean") : withTrace(
|
|
777
|
+
t,
|
|
778
|
+
`expected no visibility leaks, found ${leaks.length}:
|
|
779
|
+
${leaks.map(
|
|
780
|
+
(l) => ` ${l.clientId} (${l.role}) saw ${l.collection} [${l.ids.join(", ")}] in its ${l.kind} at tick ${l.tick}`
|
|
781
|
+
).join("\n")}`
|
|
782
|
+
)
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function toHaveConverged(received) {
|
|
786
|
+
const t = harnessOf(received);
|
|
787
|
+
const behind = t.convergence().filter((r) => r.differences.length > 0);
|
|
788
|
+
return {
|
|
789
|
+
pass: behind.length === 0,
|
|
790
|
+
message: () => behind.length === 0 ? withTrace(t, "expected at least one client to differ from the authority, none did") : withTrace(
|
|
791
|
+
t,
|
|
792
|
+
`expected every client to have converged, ${behind.length} had not:
|
|
793
|
+
${behind.map((r) => ` ${r.clientId}:
|
|
794
|
+
${r.differences.map((d) => ` ${d}`).join("\n")}`).join("\n")}`
|
|
795
|
+
)
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function toHaveRejected(received, rpcName) {
|
|
799
|
+
const t = harnessOf(received);
|
|
800
|
+
const hits = t.rejections.filter((r) => r.rpc === rpcName);
|
|
801
|
+
const seen = [...new Set(t.rejections.map((r) => r.rpc))];
|
|
802
|
+
return {
|
|
803
|
+
pass: hits.length > 0,
|
|
804
|
+
message: () => hits.length > 0 ? withTrace(
|
|
805
|
+
t,
|
|
806
|
+
`expected no call to ${JSON.stringify(rpcName)} to be rejected, but ${hits.length} was: ${hits.map((h) => `${h.clientId}: ${h.error}`).join("; ")}`
|
|
807
|
+
) : withTrace(
|
|
808
|
+
t,
|
|
809
|
+
`expected some call to ${JSON.stringify(rpcName)} to be rejected; ` + (seen.length === 0 ? "no rpc was rejected at all" : `rejected rpcs so far: ${seen.join(", ")}`)
|
|
810
|
+
)
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function toStayUnderBandwidth(received, bytesPerTick) {
|
|
814
|
+
const t = harnessOf(received);
|
|
815
|
+
const usage = t.bandwidth();
|
|
816
|
+
const worst = usage.reduce(
|
|
817
|
+
(acc, u) => acc === void 0 || u.perTick > acc.perTick ? u : acc,
|
|
818
|
+
void 0
|
|
819
|
+
);
|
|
820
|
+
const perTick = worst?.perTick ?? 0;
|
|
821
|
+
return {
|
|
822
|
+
pass: perTick <= bytesPerTick,
|
|
823
|
+
message: () => perTick <= bytesPerTick ? withTrace(
|
|
824
|
+
t,
|
|
825
|
+
`expected some client to exceed ${bytesPerTick} B/tick, the worst was ${worst?.clientId ?? "nobody"} at ${perTick.toFixed(1)} B/tick`
|
|
826
|
+
) : withTrace(
|
|
827
|
+
t,
|
|
828
|
+
`expected every client to stay under ${bytesPerTick} B/tick, but ${worst?.clientId} received ${worst?.bytes} B over ${t.tickCount} ticks (${perTick.toFixed(1)} B/tick)`
|
|
829
|
+
)
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
var matchers = {
|
|
833
|
+
toHaveNoVisibilityLeaks,
|
|
834
|
+
toHaveConverged,
|
|
835
|
+
toHaveRejected,
|
|
836
|
+
toStayUnderBandwidth
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
export {
|
|
840
|
+
materialize,
|
|
841
|
+
divergence,
|
|
842
|
+
InProcessSocket,
|
|
843
|
+
clockScheduler,
|
|
844
|
+
TestHarness,
|
|
845
|
+
TRACE_TAIL,
|
|
846
|
+
toHaveNoVisibilityLeaks,
|
|
847
|
+
toHaveConverged,
|
|
848
|
+
toHaveRejected,
|
|
849
|
+
toStayUnderBandwidth,
|
|
850
|
+
matchers
|
|
851
|
+
};
|