Files
mdg-filius/packages/server/test/sim.test.ts
T
julian.kiedaischandClaude Opus 5.5 12b26f4abe feat(editor): the simulation's properties in a sheet of their own, with a restart for everyone
Who started the simulation, "Pakete verwerfen" and the dropped packets move
from the toolbar into a properties sheet beside the surface. New in it: restart
the simulation, after asking. The room learns a restart message: stopped as by
stop, every capture, dropped frame and program window emptied, started anew
and named after who restarted; refused for readers and frozen projects, logged,
and waiting for asked blobs like a start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:01:41 +00:00

404 lines
22 KiB
TypeScript

// The simulation rooms on F (F5.3a) with real sockets, the real room thread
// and a real Postgres, on FILIUS' example internetwork_komplett: one room
// for everyone in a project — started by one, running for all, named after
// who started it; a ping in one person's command line seen by the other;
// what the simulation leaves going into the shared document; a reader who
// watches and changes nothing; the refusals, with their reasons; the room
// staying a while after the last person left, then stopping and ending;
// freezing, deleting and revoking reaching the room; a failed thread
// ending its rooms; the service stopping with rooms running.
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import WebSocket from 'ws';
import * as Y from 'yjs';
import { changeFiles, nodeFiles, readProject, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, WRITING_KINDS, type HostMessage } from '@mdg-filius/protocol';
import { LINGER_MILLISECONDS, PostgresSessions, PostgresStore } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js';
import { EDITOR_ORIGIN, join, SERVICE_KEY, signIn, startTestServer, until, type TestServer } from './harness.js';
let database: TestDatabase;
let store: PostgresStore;
let sessions: PostgresSessions;
beforeAll(async () => {
database = await testDatabase();
store = PostgresStore.connect(database.url);
sessions = new PostgresSessions(store.pool);
await store.migrate();
await sessions.migrate();
}, 60_000);
afterAll(async () => {
await store?.close();
await database?.stop();
});
const EXAMPLE = new URL('../../../corpus/java-examples/internetwork_komplett.fls', import.meta.url);
/** FILIUS' example as a project of F; the id of a device by its name. */
async function exampleProject(): Promise<{ readonly project: string; readonly node: (name: string) => string }> {
const project = randomUUID();
const imported = openProjectArchive(new Uint8Array(readFileSync(EXAMPLE)), () => randomUUID());
const doc = new Y.Doc();
writeProject(doc, imported);
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
return { project, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
}
const start = (options: { readonly linger?: number } = {}) => startTestServer(store, sessions, options.linger === undefined ? {} : { lingerMilliseconds: options.linger });
interface SimClient {
readonly socket: WebSocket;
readonly got: HostMessage[];
/** The socket's closing: its code and reason. */
readonly closed: Promise<{ readonly code: number; readonly reason: string }>;
send(message: object): void;
/** The last state the room sent. */
state(): Extract<HostMessage, { kind: 'state' }> | undefined;
}
function enterRoom(server: TestServer, project: string, { cookie, origin }: { readonly cookie?: string; readonly origin?: string } = {}): Promise<SimClient> {
const socket = new WebSocket(`ws://127.0.0.1:${server.port}/sim?project=${project}`, { headers: { ...(cookie === undefined ? {} : { cookie }), ...(origin === undefined ? {} : { origin }) } });
const got: HostMessage[] = [];
socket.on('message', (data) => got.push(JSON.parse(data.toString()) as HostMessage));
const closed = new Promise<{ code: number; reason: string }>((resolve) => socket.on('close', (code, reason) => resolve({ code, reason: reason.toString() })));
return new Promise((resolve, reject) => {
socket.on('open', () =>
resolve({
socket,
got,
closed,
send: (message) => socket.send(JSON.stringify({ v: 1, ...message })),
state: () => got.filter((message) => message.kind === 'state').at(-1) as Extract<HostMessage, { kind: 'state' }> | undefined,
}),
);
socket.on('unexpected-response', (_request, response) => reject(new Error(`refused: ${response.statusCode}`)));
socket.on('error', reject);
});
}
/** Signed in with a fresh ticket (by name), then in the room. */
async function person(
server: TestServer,
project: string,
name: string,
level: 'read' | 'write' = 'write',
user = randomUUID(),
): Promise<SimClient & { readonly cookie: string; readonly user: string }> {
const cookie = await signIn(server, project, { user, login: name.toLowerCase(), level, name });
return Object.assign(await enterRoom(server, project, { cookie }), { cookie, user });
}
const appLines = (client: SimClient) =>
client.got.filter((message): message is Extract<HostMessage, { kind: 'app' }> => message.kind === 'app').flatMap((message) => message.lines as { text?: string }[]);
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function service(server: TestServer, method: string, path: string, body?: unknown): Promise<Response> {
return fetch(`${server.base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
}
describe('a simulation room on F', () => {
it('runs for everyone in the project once one starts it, names who did, and shows a ping in one command line to the other', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
const ben = await person(server, project, 'Ben');
await until(() => mia.state() !== undefined && ben.state() !== undefined);
expect(ben.state()).toMatchObject({ mode: 'design' });
const notebook = node('Notebook 1.10');
ben.send({ kind: 'sub', topics: [`app:${notebook}/terminal`] });
mia.send({ kind: 'start' });
await until(() => ben.state()?.mode === 'simulation');
expect(ben.state()!.startedBy).toBe('Mia');
mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
await until(() => mia.got.some((message) => message.kind === 'ack' && message.reqId === 'r1'));
await until(() => appLines(ben).some((line) => /4 Paket\(e\) gesendet, 4 Paket\(e\) empfangen/.test(line.text ?? '')), 20_000);
// Started anew by Ben: named him, the command line as at the start.
const sent = ben.got.length;
ben.send({ kind: 'restart' });
await until(() => mia.state()?.startedBy === 'Ben');
await until(() => ben.got.slice(sent).some((message) => message.kind === 'app' && message.from === 0));
const anew = ben.got.slice(sent).filter((message): message is Extract<HostMessage, { kind: 'app' }> => message.kind === 'app' && message.from === 0).at(-1)!;
expect((anew.lines as { text?: string }[]).some((line) => /Paket/.test(line.text ?? ''))).toBe(false);
ben.send({ kind: 'stop' });
await until(() => mia.state()?.mode === 'design');
expect(mia.state()).not.toHaveProperty('startedBy');
expect(server.log.lines.filter((line) => ['sim.start', 'sim.stop', 'sim.restart'].includes(line.event as string)).map(({ event, user }) => [event, user])).toEqual([
['sim.start', 'mia'],
['sim.restart', 'ben'],
['sim.stop', 'ben'],
]);
expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1] });
mia.socket.close();
ben.socket.close();
await server.close();
}, 30_000);
it('writes what the simulation leaves into the shared document, for whoever has it open', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
const editor = await join(server, project, { cookie: mia.cookie });
const notebook = node('Notebook 1.10');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'touch probe.txt' }, reqId: 'r1' });
await until(() => (nodeFiles(editor.doc, notebook)?.allEntries() ?? []).some((entry) => entry.path.endsWith('probe.txt')));
// And back: a file a person writes into the document is there for the command line.
mia.send({ kind: 'sub', topics: [`app:${notebook}/terminal`] });
changeFiles(editor.doc, notebook, 'person', (files) => files.writeTextFile('/from-doc.txt', 'x'));
await until(() => editor.provider.unsyncedChanges === 0);
mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'r2' });
await until(() => appLines(mia).some((line) => (line.text ?? '').includes('from-doc.txt')));
mia.socket.close();
editor.close();
await server.close();
});
it('lets a reader watch and change nothing', async () => {
const server = await start();
const { project, node } = await exampleProject();
const reader = await person(server, project, 'Rita', 'read');
await until(() => reader.state() !== undefined);
for (const kind of WRITING_KINDS) reader.send({ kind, ...(kind === 'input' ? { node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'r' } : {}) });
await until(() => reader.got.filter((message) => message.kind === 'error').length === WRITING_KINDS.length);
expect(reader.got.filter((message) => message.kind === 'error')).toEqual([
...WRITING_KINDS.slice(0, -1).map(() => ({ v: 1, kind: 'error', code: 'readOnly' })),
{ v: 1, kind: 'error', code: 'readOnly', reqId: 'r' },
]);
expect(reader.state()).toMatchObject({ mode: 'design' });
reader.send({ kind: 'sub', topics: ['activity'] });
reader.socket.send('not json');
await until(() => reader.got.some((message) => message.kind === 'error' && message.code === 'badMessage'));
expect(server.log.lines.find((line) => line.event === 'sim.joined')).toMatchObject({ project, user: 'rita', write: false });
reader.socket.close();
await server.close();
});
it('refuses without a session, without a grant, a name that is no project, and a page of another origin', async () => {
const server = await start();
const { project } = await exampleProject();
const other = (await exampleProject()).project;
const cookie = await signIn(server, other, { login: 'ab12' });
expect(await (await enterRoom(server, project)).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'notSignedIn' });
expect(await (await enterRoom(server, project, { cookie })).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'noAccess' });
expect(await (await enterRoom(server, 'nothing', { cookie })).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'notAProject' });
await expect(enterRoom(server, project, { cookie, origin: 'https://elsewhere.test' })).rejects.toThrow('refused: 403');
expect(server.log.lines.filter((line) => line.event === 'sim.refused').map(({ reason, user }) => [reason, user])).toEqual([
['notSignedIn', undefined],
['noAccess', 'ab12'],
['notAProject', undefined],
]);
await server.close();
});
it('refuses a project its store no longer has when the room is made', async () => {
const server = await start();
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
await until(() => mia.state() !== undefined);
mia.socket.close();
await server.close();
const forgetful = Object.assign(Object.create(store) as PostgresStore, { load: async () => undefined });
const again = await startTestServer(forgetful, sessions);
expect(await (await enterRoom(again, project, { cookie: mia.cookie })).closed).toEqual({ code: SIM_REFUSED_CODE, reason: 'unknownProject' });
expect(again.log.lines.find((line) => line.event === 'sim.openFailed')).toMatchObject({ project });
await again.close();
});
it(`stays ${LINGER_MILLISECONDS / 1000} s after the last person left, running, then stops, writes back and ends`, async () => {
expect(LINGER_MILLISECONDS).toBe(60_000);
const server = await start({ linger: 600 });
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.socket.close();
await sleep(200);
// Back within the while: the same room, still running.
const again = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => again.state() !== undefined);
expect(again.state()).toMatchObject({ mode: 'simulation', startedBy: 'Mia' });
// Staying past the while it had: coming back called it off.
await sleep(700);
expect(server.sims.stats().rooms).toBe(1);
again.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch left.txt' }, reqId: 'r' });
await until(() => again.got.some((message) => message.kind === 'ack'));
again.socket.close();
await until(() => server.log.lines.some((line) => line.event === 'sim.roomClosed'), 5_000);
expect(server.sims.stats().rooms).toBe(0);
expect(server.log.lines.filter((line) => line.event === 'sim.roomClosed')).toEqual([{ level: 'info', event: 'sim.roomClosed', project, writeBack: true }]);
// The document let go once the room had written back.
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
const stored = await store.load(project);
const doc = new Y.Doc();
Y.applyUpdate(doc, stored!.state);
for (const update of stored!.updates) Y.applyUpdate(doc, update);
expect(
nodeFiles(doc, node('Notebook 1.10'))!
.allEntries()
.some((entry) => entry.path.endsWith('left.txt')),
).toBe(true);
// Whoever comes later finds a new room, in design mode.
const later = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => later.state() !== undefined);
expect(later.state()).toMatchObject({ mode: 'design' });
later.socket.close();
await server.close();
});
it('stops a running simulation when the project is frozen, refuses to start it then, and starts it once thawed', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch before.txt' }, reqId: 'r' });
await until(() => mia.got.some((message) => message.kind === 'ack'));
expect((await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true })).status).toBe(200);
expect(mia.state()).toMatchObject({ mode: 'design' });
mia.send({ kind: 'start', reqId: 'x' });
await until(() => mia.got.some((message) => message.kind === 'error'));
expect(mia.got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'frozen', reqId: 'x' }]);
// Nor anew.
mia.send({ kind: 'restart', reqId: 'y' });
await until(() => mia.got.filter((message) => message.kind === 'error').length === 2);
expect(mia.got.filter((message) => message.kind === 'error').at(-1)).toEqual({ v: 1, kind: 'error', code: 'frozen', reqId: 'y' });
expect(mia.state()).toMatchObject({ mode: 'design' });
// A reader is told it reads, not that it is frozen.
const late = await person(server, project, 'Ben');
late.send({ kind: 'start' });
await until(() => late.got.some((message) => message.kind === 'error'));
expect(late.got.find((message) => message.kind === 'error')).toMatchObject({ code: 'frozen' });
expect((await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false })).status).toBe(200);
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.socket.close();
late.socket.close();
await server.close();
});
it('freezes a project whose room nobody made, and makes its room frozen', async () => {
const server = await start();
const { project } = await exampleProject();
expect((await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true })).status).toBe(200);
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.got.some((message) => message.kind === 'error'));
expect(mia.got.find((message) => message.kind === 'error')).toMatchObject({ code: 'frozen' });
mia.socket.close();
await server.close();
});
it('lets everyone in the room go when the project is deleted, and writes nothing back', async () => {
const server = await start();
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
const response = await service(server, 'DELETE', `/svc/projects/${project}`);
expect(await response.json()).toEqual({ deleted: true, disconnected: 1 });
expect(await mia.closed).toEqual({ code: SIM_CLOSED_CODE, reason: 'deleted' });
expect(server.log.lines.find((line) => line.event === 'sim.roomClosed')).toMatchObject({ project, writeBack: false });
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
expect(server.log.lines.filter((line) => line.event === 'store.failed')).toEqual([]);
expect(await store.load(project)).toBeUndefined();
await server.close();
});
it('lets a person go whose grant ends, to one project or to all, and no one else', async () => {
const server = await start();
const { project } = await exampleProject();
const other = (await exampleProject()).project;
const user = randomUUID();
const mia = await person(server, project, 'Mia', 'write', user);
const miaThere = await person(server, other, 'Mia', 'write', user);
const ben = await person(server, project, 'Ben');
expect(await (await service(server, 'POST', '/svc/revoke', { user, project })).json()).toEqual({ revoked: 1, disconnected: 1 });
expect(await mia.closed).toEqual({ code: SIM_CLOSED_CODE, reason: 'revoked' });
expect(miaThere.socket.readyState).toBe(WebSocket.OPEN);
// Two sessions: one ticket each.
expect(await (await service(server, 'POST', '/svc/revoke', { user })).json()).toEqual({ revoked: 2, disconnected: 1 });
expect(await miaThere.closed).toEqual({ code: SIM_CLOSED_CODE, reason: 'revoked' });
expect(ben.socket.readyState).toBe(WebSocket.OPEN);
ben.socket.close();
await server.close();
});
it('ends the rooms of a thread that fails, and gives whoever comes back a new room', async () => {
const server = await start();
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
await server.sims.workers[0]!.terminate();
expect(await mia.closed).toEqual({ code: SIM_ROOM_FAILED_CODE, reason: 'roomFailed' });
expect(server.log.lines.find((line) => line.event === 'sim.threadFailed')).toMatchObject({ rooms: 1 });
const again = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => again.state() !== undefined);
expect(again.state()).toMatchObject({ mode: 'design' });
expect(server.sims.stats()).toEqual({ rooms: 1, people: 1, threads: [1] });
again.socket.close();
await server.close();
});
it('stops every room when the service stops, writing back what they left, and lets everyone go', async () => {
const server = await start();
const { project, node } = await exampleProject();
const mia = await person(server, project, 'Mia');
mia.send({ kind: 'start' });
await until(() => mia.state()?.mode === 'simulation');
mia.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch last.txt' }, reqId: 'r' });
await until(() => mia.got.some((message) => message.kind === 'ack'));
await server.close();
expect(await mia.closed).toEqual({ code: 1001, reason: 'service stopping' });
const stored = await store.load(project);
const doc = new Y.Doc();
Y.applyUpdate(doc, stored!.state);
for (const update of stored!.updates) Y.applyUpdate(doc, update);
expect(
nodeFiles(doc, node('Notebook 1.10'))!
.allEntries()
.some((entry) => entry.path.endsWith('last.txt')),
).toBe(true);
expect(readProject(doc).graph.nodes.length).toBeGreaterThan(0);
});
it('lets go a socket that answers no ping', async () => {
const server = await startTestServer(store, sessions, { pingMilliseconds: 100 });
const { project } = await exampleProject();
const mia = await person(server, project, 'Mia');
await until(() => mia.state() !== undefined);
// A socket that went away says nothing: ws answers pings by itself, so its answering is stopped.
(mia.socket as unknown as { _receiver: { removeAllListeners(name: string): void } })._receiver.removeAllListeners('ping');
expect((await mia.closed).code).toBe(1006);
await server.close();
});
it('gives a new room to the thread with the fewest', async () => {
const server = await startTestServer(store, sessions, { simThreads: 2 });
const [first, second, third] = [await exampleProject(), await exampleProject(), await exampleProject()];
const people = [await person(server, first.project, 'Mia'), await person(server, second.project, 'Ben')];
await until(() => people.every((each) => each.state() !== undefined));
expect(server.sims.stats().threads).toEqual([1, 1]);
people.push(await person(server, third.project, 'Rita'));
await until(() => people[2]!.state() !== undefined);
expect([...server.sims.stats().threads].sort()).toEqual([1, 2]);
for (const each of people) each.socket.close();
await server.close();
});
it('opens a room for the editor with its origin', async () => {
const server = await start();
const { project } = await exampleProject();
const cookie = await signIn(server, project);
const client = await enterRoom(server, project, { cookie, origin: EDITOR_ORIGIN });
await until(() => client.state() !== undefined);
client.socket.close();
await server.close();
});
});