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>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user