Moving a device, moving or resizing a documentation item went into the shared document at every pointer move: 59 updates a second, each leaving about 18 bytes of history (M4 on test-filius). Now the editor keeps the drag while it lasts, shows it itself and tells the others through the awareness (moving, at most every 50 ms, the end at once), and writes it once when it is let go. A drag the browser cancels or a second finger turns into a pinch is written where it is; WebKit sends a second pointerdown for the first finger when another comes, which had lost the drag. 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: [], moving: null });
|
|
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: [], moving: null });
|
|
// 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();
|
|
});
|
|
});
|