// 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'); }); });