Over Yjs awareness each editor tells who it is (F names the person in the access message), the device it has chosen and the windows it has open, and nothing else: no pointer, nothing that changes without a cause, sent only when it changed. Others' states are read with care. A frame in their colour round a device they chose, a dot on a window they have open too, a circle with initials each beside the mode buttons; the bridge tells the dashboard who is there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
9.8 KiB
TypeScript
183 lines
9.8 KiB
TypeScript
// The editor's way to its shared document (packages/editor's
|
|
// connectSharedDoc, F5.3b) against a real server F: ready with the
|
|
// document, and whether the person only reads, and whether the project is
|
|
// frozen; frozen and thawed while it is open — thawed, the document comes
|
|
// anew (what was changed while reading must not stay in the page's copy);
|
|
// refused without a session; let go when the grant ends or the project is
|
|
// deleted; and let go by the editor.
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import WebSocket from 'ws';
|
|
import { addDevice } from '@mdg-filius/doc';
|
|
import { peersOf } from '../../editor/src/remote/presence.js';
|
|
import { connectSharedDoc, type SharedDocState } from '../../editor/src/remote/sharedDoc.js';
|
|
import { PostgresSessions, PostgresStore } from '../src/index.js';
|
|
import { testDatabase, type TestDatabase } from './database.js';
|
|
import { newProject, 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();
|
|
});
|
|
|
|
/** The editor's connection as a browser with this cookie makes it; the states it went through. */
|
|
function connectEditor(server: TestServer, project: string, cookie?: string) {
|
|
class Browser extends WebSocket {
|
|
constructor(url: string, protocols?: string | string[]) {
|
|
super(url, protocols, { headers: cookie === undefined ? {} : { cookie } });
|
|
this.on('error', () => {});
|
|
}
|
|
}
|
|
const states: SharedDocState[] = [];
|
|
const close = connectSharedDoc({ server: server.base, project, onState: (state) => states.push(state), WebSocketPolyfill: Browser });
|
|
const last = () => states.at(-1)!;
|
|
return { states, last, close, ready: () => until(() => last().kind === 'ready') };
|
|
}
|
|
|
|
const service = (server: TestServer, path: string, body: unknown, method = 'POST') =>
|
|
fetch(`${server.base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, body: JSON.stringify(body) });
|
|
|
|
describe("the editor's shared document", () => {
|
|
it('is ready with the document, writable with a write grant and read-only with a read grant', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
const writer = connectEditor(server, project, await signIn(server, project));
|
|
await writer.ready();
|
|
expect(writer.states[0]).toEqual({ kind: 'connecting' });
|
|
expect(writer.last()).toMatchObject({ kind: 'ready', readOnly: false, frozen: false });
|
|
const doc = (writer.last() as Extract<SharedDocState, { kind: 'ready' }>).doc;
|
|
addDevice(doc, { id: 'a', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
|
|
const reader = connectEditor(server, project, await signIn(server, project, { level: 'read' }));
|
|
await reader.ready();
|
|
expect(reader.last()).toMatchObject({ kind: 'ready', readOnly: true, frozen: false });
|
|
await until(() => (reader.last() as Extract<SharedDocState, { kind: 'ready' }>).doc.getMap('nodes').size === 1);
|
|
writer.close();
|
|
reader.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('knows who it is, from F, and sees the others through the awareness', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
const [miaId, benId] = [randomUUID(), randomUUID()];
|
|
const mia = connectEditor(server, project, await signIn(server, project, { user: miaId, name: 'Mia' }));
|
|
const ben = connectEditor(server, project, await signIn(server, project, { user: benId, name: 'Ben', level: 'read' }));
|
|
await mia.ready();
|
|
await ben.ready();
|
|
const miaReady = mia.last() as Extract<SharedDocState, { kind: 'ready' }>;
|
|
const benReady = ben.last() as Extract<SharedDocState, { kind: 'ready' }>;
|
|
expect(miaReady.you).toEqual({ id: miaId, name: 'Mia', color: '#3366cc' });
|
|
expect(benReady.you).toMatchObject({ id: benId, name: 'Ben' });
|
|
// A reader is seen too: the awareness is not the document.
|
|
benReady.awareness.setLocalState({ v: 1, user: benReady.you, selection: 'router', windows: [] });
|
|
await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 1);
|
|
expect(peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID)[0]!.state).toEqual({ v: 1, user: benReady.you, selection: 'router', windows: [] });
|
|
// Gone, he is gone for her too.
|
|
ben.close();
|
|
await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 0);
|
|
mia.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('reads while the project is frozen and gets the document anew when it is thawed', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
const editor = connectEditor(server, project, await signIn(server, project));
|
|
await editor.ready();
|
|
const first = (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).doc;
|
|
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
|
|
await until(() => editor.last().kind === 'ready' && (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).frozen);
|
|
expect(editor.last()).toMatchObject({ kind: 'ready', readOnly: true, frozen: true, doc: first });
|
|
// A change that got there anyway while it read: F did not take it.
|
|
addDevice(first, { id: 'stray', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
|
|
await service(server, `/svc/projects/${project}/freeze`, { frozen: false });
|
|
await until(() => editor.last().kind === 'ready' && !(editor.last() as Extract<SharedDocState, { kind: 'ready' }>).readOnly);
|
|
const fresh = editor.last() as Extract<SharedDocState, { kind: 'ready' }>;
|
|
expect(fresh.doc).not.toBe(first);
|
|
expect(fresh.doc.getMap('nodes').size).toBe(0);
|
|
expect(editor.states.map((state) => state.kind).slice(-2)).toEqual(['connecting', 'ready']);
|
|
// Writing works again: another person sees it.
|
|
addDevice(fresh.doc, { id: 'b', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
|
|
const other = connectEditor(server, project, await signIn(server, project));
|
|
await other.ready();
|
|
await until(() => (other.last() as Extract<SharedDocState, { kind: 'ready' }>).doc.getMap('nodes').size === 1);
|
|
editor.close();
|
|
other.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('starts frozen and says so', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
|
|
const editor = connectEditor(server, project, await signIn(server, project));
|
|
await until(() => editor.last().kind === 'ready' && (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).frozen);
|
|
expect(editor.last()).toMatchObject({ readOnly: true, frozen: true });
|
|
editor.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('is refused without a session, and let go when the grant ends or the project is deleted', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
const stranger = connectEditor(server, project);
|
|
await until(() => stranger.last().kind === 'refused');
|
|
expect(stranger.last()).toEqual({ kind: 'refused', reason: 'notSignedIn' });
|
|
const user = randomUUID();
|
|
const editor = connectEditor(server, project, await signIn(server, project, { user }));
|
|
await editor.ready();
|
|
await service(server, '/svc/revoke', { user, project });
|
|
await until(() => editor.last().kind === 'closed');
|
|
expect(editor.last()).toEqual({ kind: 'closed', reason: 'revoked' });
|
|
const other = connectEditor(server, project, await signIn(server, project));
|
|
await other.ready();
|
|
await service(server, `/svc/projects/${project}`, undefined, 'DELETE');
|
|
await until(() => other.last().kind === 'closed');
|
|
expect(other.last()).toEqual({ kind: 'closed', reason: 'deleted' });
|
|
expect(server.sync.hocuspocus.getConnectionsCount()).toBe(0);
|
|
await server.close();
|
|
});
|
|
|
|
it('stays ready when only the network drops, and comes back by itself', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
const editor = connectEditor(server, project, await signIn(server, project));
|
|
await editor.ready();
|
|
const doc = (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).doc;
|
|
for (const connection of server.sync.connections(project)) connection.webSocket.terminate();
|
|
await until(() => server.sync.connections(project).length === 1, 10_000);
|
|
expect(editor.last()).toMatchObject({ kind: 'ready', doc });
|
|
addDevice(doc, { id: 'after', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
|
|
await until(async () => (await store.pending(project)) === 1);
|
|
editor.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('lets go when the editor does, and tells nothing after', async () => {
|
|
const server = await startTestServer(store, sessions);
|
|
const project = await newProject(store);
|
|
const editor = connectEditor(server, project, await signIn(server, project));
|
|
await editor.ready();
|
|
const count = editor.states.length;
|
|
editor.close();
|
|
await until(() => server.sync.hocuspocus.getConnectionsCount() === 0);
|
|
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
expect(editor.states).toHaveLength(count);
|
|
await server.close();
|
|
});
|
|
});
|