feat: F5.3a the simulation rooms on server F
One room per project for everyone in it, held by a few worker_threads, each room with a replica of the document that Hocuspocus' direct connection keeps open: changes go in, what the room writes comes back. /sim?project=... signs in as /sync does and says refusals and partings as the socket's closing; only a write grant changes the room. The state names who started the simulation. After the last person the room stays 60 s, then stops, writes back and ends. Freezing stops a running simulation and refuses to start one, deleting ends the room without writing back, revoking lets the person go from the room too; a failed thread ends its rooms, stopping the service stops them all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
// 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);
|
||||
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'].includes(line.event as string)).map(({ event, user }) => [event, user])).toEqual([
|
||||
['sim.start', 'mia'],
|
||||
['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.sims.stats().rooms === 0, 5_000);
|
||||
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' }]);
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user