Files
mdg-filius/packages/editor/test/remoteSimHost.test.ts
T
julian.kiedaischandClaude Opus 5.5 f52852493a feat: F5.3b the editor on a project of server F
?project=<uuid> opens the shared document over /sync and the room over
/sim (RemoteSimHost: messages wait for the socket, a lost socket comes
back with what it watched, F's refusal or parting ends it); instead of
the editor, a word why when F refuses or lets go. F tells each
connection its access at once. Who only reads sees everything and
changes nothing: no palette or undo, a locked surface, the sheet's
fields disabled (tabs stay), no starting or stopping; thawed, the
document comes anew. The toolbar names who started the simulation.
Refusals carry no message, so Hocuspocus writes nothing outside F's
JSON log. A browser test runs three people against a real F.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:00:18 +00:00

167 lines
6.4 KiB
TypeScript

// The room on F as the editor reaches it (RemoteSimHost), on sockets made
// by the test and a clock it moves: what is sent before the socket opens
// goes once it does; the room's messages are read as LocalSimHost reads
// them; a lost socket comes back after a wait that doubles (to 10 s at
// most) and watches again what it watched; F's refusal and parting end it
// for good and are told; closing it ends it without a word.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, type HostMessage } from '@mdg-filius/protocol';
import { RECONNECT_FIRST_MILLISECONDS, RECONNECT_MAXIMUM_MILLISECONDS, RemoteSimHost, simUrl, type RoomEnd, type SocketLike } from '../src/remote/remoteSimHost.js';
class FakeSocket implements SocketLike {
readyState = 0;
readonly sent: unknown[] = [];
closedWith: [number | undefined, string | undefined] | undefined;
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
constructor(readonly url: string) {}
send(data: string): void {
this.sent.push(JSON.parse(data));
}
close(code?: number, reason?: string): void {
this.closedWith = [code, reason];
this.readyState = 3;
}
opened(): void {
this.readyState = 1;
this.onopen?.();
}
hear(data: unknown): void {
this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) });
}
lost(code: number, reason = ''): void {
this.readyState = 3;
this.onclose?.({ code, reason });
}
}
let sockets: FakeSocket[];
const host = () =>
new RemoteSimHost('ws://f.test/sim?project=p', (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket;
});
beforeEach(() => {
sockets = [];
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
describe('the room on F', () => {
it('sends what came before the socket opened once it does, and then at once', () => {
const room = host();
room.send({ v: 1, kind: 'sub', topics: ['activity'] });
room.send({ v: 1, kind: 'start' });
expect(sockets[0]!.sent).toEqual([]);
sockets[0]!.opened();
room.send({ v: 1, kind: 'stop' });
expect(sockets[0]!.sent).toEqual([
{ v: 1, kind: 'sub', topics: ['activity'] },
{ v: 1, kind: 'start' },
{ v: 1, kind: 'stop' },
]);
expect(sockets[0]!.url).toBe('ws://f.test/sim?project=p');
});
it("reads the room's messages, and names what it cannot read", () => {
const room = host();
const got: HostMessage[] = [];
const stop = room.onMessage((message) => got.push(message));
sockets[0]!.opened();
sockets[0]!.hear({ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 });
sockets[0]!.hear('not json');
sockets[0]!.hear({ v: 1, kind: 'nonsense' });
stop();
sockets[0]!.hear({ v: 1, kind: 'activity', links: [] });
expect(got).toEqual([
{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 },
{ v: 1, kind: 'error', code: 'badMessage', detail: 'not an object' },
{ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense' },
]);
});
it('comes back after a loss, waiting longer each time, and watches again what it watched', () => {
const room = host();
room.send({ v: 1, kind: 'sub', topics: ['activity', 'node:a'] });
sockets[0]!.opened();
room.send({ v: 1, kind: 'unsub', topics: ['node:a'] });
sockets[0]!.lost(1006);
// Sent while it is away: kept for the room that comes.
room.send({ v: 1, kind: 'sub', topics: ['trace:p1'] });
room.send({ v: 1, kind: 'speed', value: 50 });
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS - 1);
expect(sockets).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(2);
sockets[1]!.opened();
expect(sockets[1]!.sent).toEqual([
{ v: 1, kind: 'sub', topics: ['activity', 'trace:p1'] },
{ v: 1, kind: 'speed', value: 50 },
]);
// Lost again before it opened, again and again: 1 s, 2 s, … up to 10 s; an open socket begins anew.
const waits: number[] = [];
for (let index = 1; index <= 6; index++) {
const before = sockets.length;
sockets.at(-1)!.lost(index % 2 === 0 ? SIM_ROOM_FAILED_CODE : 1001);
let waited = 0;
while (sockets.length === before) {
vi.advanceTimersByTime(100);
waited += 100;
}
waits.push(waited);
}
expect(waits).toEqual([RECONNECT_FIRST_MILLISECONDS, 1_000, 2_000, 4_000, 8_000, RECONNECT_MAXIMUM_MILLISECONDS]);
sockets.at(-1)!.opened();
sockets.at(-1)!.lost(1006);
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS);
expect(sockets.at(-1)!.readyState).toBe(0);
});
it('ends for good when F refuses or lets go, and says why', () => {
for (const [code, end] of [
[SIM_REFUSED_CODE, { kind: 'refused', reason: 'noAccess' }],
[SIM_CLOSED_CODE, { kind: 'closed', reason: 'revoked' }],
] as const) {
sockets = [];
const room = host();
const ends: RoomEnd[] = [];
room.onEnd((each) => ends.push(each));
sockets[0]!.opened();
sockets[0]!.lost(code, end.reason);
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect(sockets).toHaveLength(1);
expect(ends).toEqual([end]);
room.send({ v: 1, kind: 'start' });
expect(sockets[0]!.sent).toEqual([]);
}
});
it('ends without a word when it is closed, and does not come back', () => {
const room = host();
const ends: RoomEnd[] = [];
const stop = room.onEnd((each) => ends.push(each));
sockets[0]!.opened();
room.close();
expect(sockets[0]!.closedWith).toEqual([undefined, undefined]);
sockets[0]!.lost(1000);
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect([sockets.length, ends]).toEqual([1, []]);
stop();
// Closed while it waits to come back: it does not.
const other = host();
sockets.at(-1)!.lost(1006);
other.close();
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect(sockets).toHaveLength(2);
});
it('makes the address of a room from the address of F', () => {
expect(simUrl('https://filius.hub.example/', 'abc')).toBe('wss://filius.hub.example/sim?project=abc');
expect(simUrl('http://127.0.0.1:8080', 'abc')).toBe('ws://127.0.0.1:8080/sim?project=abc');
});
});