// 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 | undefined; } function enterRoom(server: TestServer, project: string, { cookie, origin }: { readonly cookie?: string; readonly origin?: string } = {}): Promise { 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 | 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 { 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 => 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 { 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 => 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] }); // What the dashboard's monitoring reads (F9): the same, and the process — only with the service key. const status = await fetch(`${server.base}/svc/status`, { headers: { 'x-filius-service-key': SERVICE_KEY } }); expect(status.status).toBe(200); const said = (await status.json()) as Record & { memory: { rssMb: number; heapUsedMb: number }; uptimeSeconds: number }; expect(said).toMatchObject({ rooms: 1, people: 2, threads: [1], node: process.version }); expect(said.documents).toBeGreaterThanOrEqual(1); expect(said.connections).toBeGreaterThanOrEqual(0); expect(said.memory.rssMb).toBeGreaterThan(0); expect(said.memory.heapUsedMb).toBeGreaterThan(0); expect(said.uptimeSeconds).toBeGreaterThanOrEqual(0); expect((await fetch(`${server.base}/svc/status`)).status).toBe(403); 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(); }); });