?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>
167 lines
6.4 KiB
TypeScript
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');
|
|
});
|
|
});
|