A modem that offers a connection to another project is found by six characters derived from its project and itself (modemCode): a copy of the project has another code, so no two modems offer the same one. The room names its modems of other projects to F's relay at a start and none at a stop; the relay pairs each offer with the first modem that joins its code, one to one as Java's server modem takes one client, and passes the frames between the rooms' threads. Every client hears how each modem stands (`modems`: connected, waiting, taken, unpaired, ambiguous, detached, or unavailable without F). The document keeps the connection and the code. Measured on F with two threads: a ping from one project's notebook to the other's goes through; a third project that wants the same code reads taken, and takes it when the second stops; a deleted project's modem leaves with its room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
27 KiB
TypeScript
475 lines
27 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; two projects'
|
|
// networks joined by their modems across two threads.
|
|
|
|
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 { modemCode } from '@mdg-filius/core';
|
|
import { addDevice, changeFiles, connect, emptyProject, nodeFiles, readProject, setHostAddress, setModemCode, setModemConnection, setSoftware, 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 };
|
|
}
|
|
|
|
/**
|
|
* A project of a computer at `address` cabled to a modem of another project:
|
|
* offering, or joining `code`; the ids of both.
|
|
*/
|
|
async function modemProject(address: string, relay: { readonly role: 'offer' } | { readonly role: 'join'; readonly code: string }) {
|
|
const project = randomUUID();
|
|
const doc = new Y.Doc();
|
|
writeProject(doc, emptyProject());
|
|
const [computer, modem] = [randomUUID(), randomUUID()];
|
|
addDevice(doc, { kind: 'notebook', id: computer, name: 'Notebook', x: 0, y: 0, newId: randomUUID }, 'setup');
|
|
addDevice(doc, { kind: 'remoteLink', id: modem, name: 'Modem', x: 200, y: 0, newId: randomUUID }, 'setup');
|
|
connect(doc, { node: computer }, { node: modem }, randomUUID(), 'setup');
|
|
setHostAddress(doc, computer, 'ipAddress', address, 'setup');
|
|
setSoftware(doc, computer, { install: ['commandPrompt'], remove: [] }, 'de', 'setup');
|
|
setModemConnection(doc, modem, relay.role, 'setup');
|
|
if (relay.role === 'join') setModemCode(doc, modem, relay.code, 'setup');
|
|
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
|
|
return { project, computer, modem };
|
|
}
|
|
|
|
const modemsOf = (client: SimClient) => client.got.filter((message): message is Extract<HostMessage, { kind: 'modems' }> => message.kind === 'modems').at(-1)?.modems;
|
|
|
|
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], modems: { modems: 0, pairs: 0 } });
|
|
// 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<string, unknown> & { 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], modems: { modems: 0, pairs: 0 } });
|
|
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("joins two projects' networks through their modems, across two threads; a third that wants the same code finds it taken", async () => {
|
|
const server = await startTestServer(store, sessions, { simThreads: 2 });
|
|
const teacher = await modemProject('10.0.0.1', { role: 'offer' });
|
|
const group = await modemProject('10.0.0.2', { role: 'join', code: modemCode(teacher.project, teacher.modem) });
|
|
const late = await modemProject('10.0.0.3', { role: 'join', code: modemCode(teacher.project, teacher.modem) });
|
|
const mia = await person(server, teacher.project, 'Mia');
|
|
const ben = await person(server, group.project, 'Ben');
|
|
await until(() => mia.state() !== undefined && ben.state() !== undefined);
|
|
expect(server.sims.stats().threads).toEqual([1, 1]);
|
|
mia.send({ kind: 'start' });
|
|
await until(() => modemsOf(mia)?.[teacher.modem] === 'waiting');
|
|
ben.send({ kind: 'start' });
|
|
await until(() => modemsOf(ben)?.[group.modem] === 'connected' && modemsOf(mia)?.[teacher.modem] === 'connected');
|
|
expect(server.log.lines.find((line) => line.event === 'sim.modemsPaired')).toMatchObject({ offer: teacher.project, join: group.project });
|
|
// A ping from Ben's project to the computer in Mia's.
|
|
ben.send({ kind: 'sub', topics: [`app:${group.computer}/terminal`] });
|
|
ben.send({ kind: 'input', node: group.computer, app: 'terminal', action: 'enter', args: { line: 'ping 10.0.0.1' }, reqId: 'p' });
|
|
await until(() => appLines(ben).some((line) => /4 Paket\(e\) gesendet, 4 Paket\(e\) empfangen/.test(line.text ?? '')), 20_000);
|
|
// Rita's project wants the same code: taken while Ben's has it.
|
|
const rita = await person(server, late.project, 'Rita');
|
|
await until(() => rita.state() !== undefined);
|
|
rita.send({ kind: 'start' });
|
|
await until(() => modemsOf(rita)?.[late.modem] === 'taken');
|
|
expect(server.sims.stats().modems).toEqual({ modems: 3, pairs: 1 });
|
|
// Ben stops: the pair parts, Rita's modem takes the code.
|
|
ben.send({ kind: 'stop' });
|
|
await until(() => modemsOf(rita)?.[late.modem] === 'connected');
|
|
expect(modemsOf(ben)).toEqual({});
|
|
// Mia's project is deleted, its running room ends without stopping: its modem goes with it.
|
|
expect((await service(server, 'DELETE', `/svc/projects/${teacher.project}`)).status).toBe(200);
|
|
await until(() => modemsOf(rita)?.[late.modem] === 'waiting');
|
|
expect(server.sims.stats().modems).toEqual({ modems: 1, pairs: 0 });
|
|
for (const each of [mia, ben, rita]) each.socket.close();
|
|
await server.close();
|
|
}, 40_000);
|
|
|
|
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();
|
|
});
|
|
});
|