feat: F5.3b the editor on a project of server F
?project=<uuid> opens the shared document over /sync and the room over /sim (RemoteSimHost: messages wait for the socket, a lost socket comes back with what it watched, F's refusal or parting ends it); instead of the editor, a word why when F refuses or lets go. F tells each connection its access at once. Who only reads sees everything and changes nothing: no palette or undo, a locked surface, the sheet's fields disabled (tabs stay), no starting or stopping; thawed, the document comes anew. The toolbar names who started the simulation. Refusals carry no message, so Hocuspocus writes nothing outside F's JSON log. A browser test runs three people against a real F. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// A real server F for the editor's browser tests: bundled as the build
|
||||
// bundles it (dist/main.mjs and its room thread), started as a process with
|
||||
// a throwaway Postgres (packages/server/test/database.ts), on the host the
|
||||
// editor's page comes from, so that F's cookie goes with the page's
|
||||
// requests. A project is made in F's store straight from a FILIUS file; a
|
||||
// person signs in as the dashboard would let them, with a ticket at /__auth.
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { BrowserContext } from '@playwright/test';
|
||||
import { build } from 'esbuild';
|
||||
import pg from 'pg';
|
||||
import * as Y from 'yjs';
|
||||
import { writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { testDatabase, type TestDatabase } from '../../server/test/database.js';
|
||||
import { signTicket } from '../../server/test/tickets.js';
|
||||
|
||||
const TICKET_SECRET = 'browser-ticket-secret';
|
||||
export const SERVICE_KEY = 'browser-service-key';
|
||||
|
||||
export interface ServerF {
|
||||
readonly base: string;
|
||||
/** A project made from a FILIUS file of the corpus; its devices' ids by name. */
|
||||
project(file: string): Promise<{ readonly id: string; readonly node: (name: string) => string }>;
|
||||
/** Signs a person in, in this browser context. */
|
||||
signIn(context: BrowserContext, project: string, person: { readonly name: string; readonly level?: 'read' | 'write'; readonly user?: string }): Promise<void>;
|
||||
service(method: string, path: string, body?: unknown): Promise<Response>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Server F on the editor page's host (`editorBase`), for pages from there. */
|
||||
export async function startServerF(editorBase: string): Promise<ServerF> {
|
||||
const editor = new URL(editorBase);
|
||||
const database: TestDatabase = await testDatabase();
|
||||
const out = mkdtempSync(join(tmpdir(), 'filius-server-f-'));
|
||||
const source = new URL('../../server/src/', import.meta.url).pathname;
|
||||
await build({
|
||||
entryPoints: [join(source, 'main.ts'), join(source, 'simWorker.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
outdir: out,
|
||||
outExtension: { '.js': '.mjs' },
|
||||
external: ['pg-native'],
|
||||
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
|
||||
logLevel: 'error',
|
||||
});
|
||||
const port = 20_000 + Math.floor(Math.random() * 20_000);
|
||||
const child: ChildProcess = spawn(process.execPath, [join(out, 'main.mjs')], {
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: database.url,
|
||||
FILIUS_TICKET_SECRET: TICKET_SECRET,
|
||||
FILIUS_SERVICE_KEY: SERVICE_KEY,
|
||||
FILIUS_ORIGINS: editor.origin,
|
||||
FILIUS_PORT: String(port),
|
||||
FILIUS_INSECURE_COOKIE: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.stdout!.on('data', (data: Buffer) => {
|
||||
if (data.toString().includes('"server.listening"')) resolve();
|
||||
});
|
||||
child.once('exit', (code) => reject(new Error(`server F ended (${code})`)));
|
||||
});
|
||||
const base = `${editor.protocol}//${editor.hostname}:${port}`;
|
||||
const sql = new pg.Pool({ connectionString: database.url });
|
||||
return {
|
||||
base,
|
||||
async project(file) {
|
||||
const id = randomUUID();
|
||||
const imported = openProjectArchive(new Uint8Array(readFileSync(new URL(`../../../corpus/${file}`, import.meta.url))), () => randomUUID());
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, imported);
|
||||
await sql.query('INSERT INTO filius_doc (project_uuid, state) VALUES ($1, $2)', [id, Buffer.from(Y.encodeStateAsUpdate(doc))]);
|
||||
return { id, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
|
||||
},
|
||||
async signIn(context, project, { name, level = 'write', user = randomUUID() }) {
|
||||
const ticket = signTicket({ u: user, n: name.toLowerCase(), d: name, p: project, lvl: level }, TICKET_SECRET);
|
||||
const page = await context.newPage();
|
||||
const response = await page.goto(`${base}/__auth?ticket=${encodeURIComponent(ticket)}&next=/healthz`);
|
||||
if (response?.status() !== 200) throw new Error(`not signed in: ${response?.status()}`);
|
||||
await page.close();
|
||||
},
|
||||
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
|
||||
async close() {
|
||||
const ended = new Promise((resolve) => child.once('exit', resolve));
|
||||
child.kill('SIGTERM');
|
||||
await ended;
|
||||
await sql.end();
|
||||
await database.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// The editor on a project of a real server F (F5.3b), three people in three
|
||||
// browsers: Mia and Ben write, Rita only reads. What one moves the others
|
||||
// see; Rita gets everything shown and nothing to change; Mia starts the
|
||||
// simulation and it runs for all, named after her; Ben's ping in a command
|
||||
// line is in Mia's too (one window per computer, as in Java); Rita's typing
|
||||
// there is refused, and she is told; the project frozen, Ben reads and is
|
||||
// told, thawed he writes again; his grant ended, he is told and the editor
|
||||
// goes.
|
||||
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { drag, onDevice, serveEditor } from './editorPage.js';
|
||||
import { startServerF, type ServerF } from './serverF.js';
|
||||
|
||||
let editor: { readonly base: string; readonly close: () => Promise<void> };
|
||||
let f: ServerF;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
editor = await serveEditor();
|
||||
f = await startServerF(editor.base);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await f?.close();
|
||||
await editor?.close();
|
||||
});
|
||||
|
||||
/** A person in their own browser context, signed in, the project open. */
|
||||
async function person(browser: Browser, project: string, name: string, level: 'read' | 'write' = 'write', user?: string): Promise<Page> {
|
||||
const context = await browser.newContext();
|
||||
await f.signIn(context, project, { name, level, ...(user === undefined ? {} : { user }) });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${editor.base}?project=${project}&server=${encodeURIComponent(f.base)}`);
|
||||
await page.waitForSelector('svg.filius-canvas');
|
||||
return page;
|
||||
}
|
||||
|
||||
const box = (page: Page, id: string) => page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box');
|
||||
const canvas = (page: Page) => page.locator('svg.filius-canvas');
|
||||
const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`);
|
||||
const note = (page: Page, kind: string) => page.locator(`[data-note="${kind}"]`);
|
||||
|
||||
async function commandLine(page: Page, id: string) {
|
||||
const at = await onDevice(page, id);
|
||||
await page.mouse.click(at.x, at.y);
|
||||
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
|
||||
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
|
||||
return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') };
|
||||
}
|
||||
|
||||
test('three people in one project of F: moving, reading only, one simulation for all, a frozen project, an ended grant', async ({ browser }) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await f.project('java-examples/internetwork_komplett.fls');
|
||||
const benUser = crypto.randomUUID();
|
||||
const mia = await person(browser, project.id, 'Mia');
|
||||
const ben = await person(browser, project.id, 'Ben', 'write', benUser);
|
||||
const rita = await person(browser, project.id, 'Rita', 'read');
|
||||
const devices = await mia.locator('g.device').count();
|
||||
expect(devices).toBeGreaterThan(5);
|
||||
for (const page of [ben, rita]) await expect(page.locator('g.device')).toHaveCount(devices);
|
||||
|
||||
// Mia moves a device; Ben and Rita see it where she put it.
|
||||
const notebook = project.node('Notebook 1.10');
|
||||
const before = await box(mia, notebook);
|
||||
const from = await onDevice(mia, notebook);
|
||||
await drag(mia, from, { x: from.x + 120, y: from.y + 40 });
|
||||
const moved = await box(mia, notebook);
|
||||
expect(moved).not.toBe(before);
|
||||
for (const page of [ben, rita]) await expect(page.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', moved!);
|
||||
|
||||
// Rita reads: no palette, no undo, the note; her drag moves nothing; her sheet's fields are disabled; she cannot start.
|
||||
await expect(note(rita, 'readOnly')).toHaveText('Nur ansehen');
|
||||
await expect(rita.locator('[data-palette]')).toHaveCount(0);
|
||||
await expect(rita.getByRole('button', { name: 'Rückgängig' })).toHaveCount(0);
|
||||
await expect(rita.locator('button[name="simulationMode"]')).toBeDisabled();
|
||||
const ritaFrom = await onDevice(rita, notebook);
|
||||
await drag(rita, ritaFrom, { x: ritaFrom.x + 80, y: ritaFrom.y });
|
||||
await expect(rita.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', moved!);
|
||||
await rita.mouse.click(ritaFrom.x, ritaFrom.y);
|
||||
const sheet = rita.getByRole('complementary', { name: 'Einstellungen' });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.locator('input[name="ipAddress"]')).toBeDisabled();
|
||||
await sheet.getByRole('button', { name: 'Schließen' }).click();
|
||||
await expect(note(mia, 'readOnly')).toHaveCount(0);
|
||||
|
||||
// Mia starts: the simulation runs for all, named after her.
|
||||
await mia.locator('button[name="simulationMode"]').click();
|
||||
for (const page of [mia, ben, rita]) {
|
||||
await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation');
|
||||
await expect(note(page, 'startedBy')).toHaveText('Simulation gestartet von Mia');
|
||||
}
|
||||
|
||||
// Ben pings from the notebook's command line; Mia's command line of it shows the same.
|
||||
const bens = await commandLine(ben, notebook);
|
||||
await bens.input.fill('ping 141.99.5.10');
|
||||
await bens.input.press('Enter');
|
||||
const mias = await commandLine(mia, notebook);
|
||||
for (const each of [bens, mias]) await expect(each.text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 });
|
||||
// Rita may look, not type.
|
||||
const ritas = await commandLine(rita, notebook);
|
||||
await expect(ritas.text).toContainText('ping 141.99.5.10');
|
||||
await ritas.input.fill('ipconfig');
|
||||
await ritas.input.press('Enter');
|
||||
await expect(rita.getByText('Hier können Sie nur zuschauen.')).toBeVisible();
|
||||
|
||||
// Ben stops it, for everyone.
|
||||
await ben.locator('button[name="designMode"]').click();
|
||||
for (const page of [mia, ben, rita]) await expect(canvas(page)).toHaveAttribute('data-mode', 'design');
|
||||
|
||||
// Frozen: Ben reads and is told; thawed, he writes again.
|
||||
expect((await f.service('POST', `/svc/projects/${project.id}/freeze`, { frozen: true })).status).toBe(200);
|
||||
await expect(note(ben, 'readOnly')).toHaveText('Eingefroren – nur ansehen');
|
||||
await expect(ben.locator('[data-palette]')).toHaveCount(0);
|
||||
expect((await f.service('POST', `/svc/projects/${project.id}/freeze`, { frozen: false })).status).toBe(200);
|
||||
await expect(note(ben, 'readOnly')).toHaveCount(0);
|
||||
await expect(ben.locator('[data-palette]').first()).toBeVisible();
|
||||
const benFrom = await onDevice(ben, notebook);
|
||||
await drag(ben, benFrom, { x: benFrom.x - 60, y: benFrom.y });
|
||||
const movedByBen = await box(ben, notebook);
|
||||
expect(movedByBen).not.toBe(moved);
|
||||
await expect(mia.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', movedByBen!);
|
||||
|
||||
// Ben's grant ends: he is told, and the editor goes.
|
||||
expect((await f.service('POST', '/svc/revoke', { user: benUser, project: project.id })).status).toBe(200);
|
||||
await expect(ben.getByRole('alert')).toHaveText('Ihr Zugang zu diesem Projekt wurde beendet.');
|
||||
await expect(canvas(ben)).toHaveCount(0);
|
||||
await expect(canvas(mia)).toBeVisible();
|
||||
});
|
||||
|
||||
test('says why when F ends only the room: the editor goes as when the document is let go', async ({ browser }) => {
|
||||
const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls');
|
||||
const context = await browser.newContext();
|
||||
await f.signIn(context, project.id, { name: 'Mia' });
|
||||
const page = await context.newPage();
|
||||
await page.routeWebSocket(/\/sim/, (socket) => socket.close({ code: 4403, reason: 'noAccess' }));
|
||||
await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`);
|
||||
await expect(page.getByRole('alert')).toHaveText('Sie haben keinen Zugang zu diesem Projekt.');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('says why when F refuses: no session, a project it does not know', async ({ page }) => {
|
||||
const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls');
|
||||
await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`);
|
||||
await expect(page.getByRole('alert')).toHaveText('Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.');
|
||||
await page.goto(`${editor.base}?project=not-a-project&server=${encodeURIComponent(f.base)}`);
|
||||
await expect(page.getByRole('alert')).toHaveText('Diese Adresse nennt kein Projekt.');
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@mdg-filius/core": "0.0.0",
|
||||
"@mdg-filius/doc": "0.0.0",
|
||||
"@mdg-filius/fls": "0.0.0",
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
// Every change goes through packages/doc's commands with this editor's own
|
||||
// origin, so that its undo takes back what this person did and nothing of
|
||||
// the others in the room.
|
||||
//
|
||||
// Who only reads (on F: a read grant, or a frozen project) sees everything
|
||||
// and changes nothing: no palette, no undo, nothing moves or has a menu, the
|
||||
// sheet's fields are disabled, the simulation is neither started nor
|
||||
// stopped by them; desktops and the data exchange window open to watch. The
|
||||
// room's simulation names who started it.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
@@ -50,7 +56,7 @@ import { Palette, type PaletteKind } from './design/Palette.js';
|
||||
import { RouterDialog } from './design/RouterDialog.js';
|
||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
|
||||
import { useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { translate, useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
||||
import { captureWords, reportTraffic } from './simulation/captureCells.js';
|
||||
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
|
||||
@@ -143,9 +149,13 @@ export interface EditorProps {
|
||||
readonly createSimHost?: SimHostFactory;
|
||||
/** The project's name (its file's), for what is exported. */
|
||||
readonly projectName?: string;
|
||||
/** This person only reads (on F). */
|
||||
readonly readOnly?: boolean;
|
||||
/** …because the project is frozen (its deadline passed). */
|
||||
readonly frozen?: boolean;
|
||||
}
|
||||
|
||||
export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false }: EditorProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useTopologyView(doc);
|
||||
@@ -167,6 +177,11 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
|
||||
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
|
||||
|
||||
// The room said no: told as a message, once each time.
|
||||
useEffect(() => {
|
||||
if (simulation.refusal !== null) setMessage(translate(language, `access.${simulation.refusal.code}Refused`));
|
||||
}, [simulation.refusal, language]);
|
||||
|
||||
// A message goes after a while.
|
||||
useEffect(() => {
|
||||
if (message === null) return undefined;
|
||||
@@ -352,7 +367,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
// The keyboard: undo and redo, delete what is selected, Escape lets go.
|
||||
useEffect(() => {
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (typing(event.target) || simulating) return;
|
||||
if (typing(event.target) || simulating || readOnly) return;
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
if (command && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
@@ -371,13 +386,13 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
};
|
||||
window.addEventListener('keydown', key);
|
||||
return () => window.removeEventListener('keydown', key);
|
||||
}, [manager, selection, menu, routerAt, remove, simulating]);
|
||||
}, [manager, selection, menu, routerAt, remove, simulating, readOnly]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
{simulating ? null : documenting ? (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} />
|
||||
) : (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} />
|
||||
) : readOnly ? null : (
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
onCableTool={(on) => {
|
||||
@@ -402,6 +417,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
onCable={onCable}
|
||||
highlightedLink={highlightedLink}
|
||||
mode={editorMode}
|
||||
locked={readOnly}
|
||||
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
|
||||
renderDocText={(item, frame) => {
|
||||
const text = documentationText(doc, item.id);
|
||||
@@ -426,6 +442,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
else if (kind === 'gateway') openDesktop(id, 'nat');
|
||||
}}
|
||||
onMenu={(target, at) => {
|
||||
if (readOnly && !simulating) return;
|
||||
if (editorMode === 'documentation') {
|
||||
if (target.kind !== 'doc') return;
|
||||
setSelection(target);
|
||||
@@ -441,8 +458,18 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
}}
|
||||
/>
|
||||
<div role="toolbar" className="editor-toolbar">
|
||||
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} />
|
||||
{simulating ? <DropControl simulation={simulation} /> : (
|
||||
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} canRun={!readOnly} />
|
||||
{simulating && simulation.startedBy !== undefined ? (
|
||||
<span className="editor-note" data-note="startedBy">
|
||||
{t('access.startedBy', { name: simulation.startedBy })}
|
||||
</span>
|
||||
) : null}
|
||||
{readOnly ? (
|
||||
<span className="editor-note" data-note="readOnly">
|
||||
{t(frozen ? 'access.frozen' : 'access.readOnly')}
|
||||
</span>
|
||||
) : null}
|
||||
{simulating ? (readOnly ? null : <DropControl simulation={simulation} />) : readOnly ? null : (
|
||||
<>
|
||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
||||
↶
|
||||
@@ -578,6 +605,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
onMessage={setMessage}
|
||||
onHighlight={setHighlightedLink}
|
||||
onClose={() => setSelection(null)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,12 @@ export interface CanvasProps {
|
||||
* Java's simulation mode. The documentation mode: see above.
|
||||
*/
|
||||
readonly mode?: 'design' | 'simulation' | 'documentation';
|
||||
/**
|
||||
* Who only reads (on F): a tap still chooses a device (its sheet shows
|
||||
* what it holds), but nothing moves, no cable is begun or chosen, no
|
||||
* documentation item is chosen or changed, and nothing has a menu.
|
||||
*/
|
||||
readonly locked?: boolean;
|
||||
/** A documentation item moved or resized: its new frame. */
|
||||
readonly onDocFrame?: (id: string, frame: Frame) => void;
|
||||
/** The field a focused text is typed into, over the surface at its frame. */
|
||||
@@ -228,6 +234,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
onMenu,
|
||||
highlightedLink = null,
|
||||
mode = 'design',
|
||||
locked = false,
|
||||
activeLinks = NO_LINKS,
|
||||
onActivate,
|
||||
onDocFrame,
|
||||
@@ -364,6 +371,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
if (pointers.current.size > 2) return;
|
||||
const target = event.target as Element;
|
||||
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
|
||||
if (documenting && locked) {
|
||||
gesture.current = { kind: 'background', start: point, moved: false, last: point };
|
||||
return;
|
||||
}
|
||||
if (documenting) {
|
||||
const found = docAt(point);
|
||||
if (found === null) {
|
||||
@@ -391,11 +402,11 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
|
||||
longPress.current = setTimeout(() => {
|
||||
const now = gesture.current;
|
||||
if (now?.kind !== 'device' || now.moved) return;
|
||||
if (now?.kind !== 'device' || now.moved || (locked && !simulating)) return;
|
||||
now.longPressed = true;
|
||||
onMenu?.({ kind: 'device', id: now.id }, now.start);
|
||||
}, LONG_PRESS_MILLISECONDS);
|
||||
} else if (linkId !== undefined && linkId !== null && !simulating) {
|
||||
} else if (linkId !== undefined && linkId !== null && !simulating && !locked) {
|
||||
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
|
||||
longPress.current = setTimeout(() => {
|
||||
const now = gesture.current;
|
||||
@@ -454,7 +465,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
|
||||
now.last = point;
|
||||
setViewport((before) => shifted(before ?? currentRef.current, step));
|
||||
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
|
||||
} else if (now.kind === 'device' && !now.longPressed && !simulating && !locked) {
|
||||
if (tool === 'cable') {
|
||||
const from = boxes.get(now.id)!.center;
|
||||
const target = deviceAt(point);
|
||||
@@ -501,6 +512,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
if (!now.moved) onActivate?.(now.id);
|
||||
return;
|
||||
}
|
||||
if (locked) {
|
||||
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
|
||||
return;
|
||||
}
|
||||
if (tool === 'cable') {
|
||||
setBand(null);
|
||||
const from = { id: now.id, side: now.side };
|
||||
@@ -645,7 +660,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
const corner = toScreen(current, { x: item.x, y: item.y });
|
||||
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
|
||||
})()}
|
||||
{menuButton === null || selection === null || simulating ? null : (
|
||||
{menuButton === null || selection === null || simulating || locked ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="device-menu-button"
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface DocumentationSidebarProps {
|
||||
readonly onExport: () => Promise<void>;
|
||||
/** „Report“: the report saved as a PDF; rejected, it failed. */
|
||||
readonly onReport: () => Promise<void>;
|
||||
/** Who only reads makes no texts or rectangles; the picture and the report stay. */
|
||||
readonly readOnly?: boolean;
|
||||
}
|
||||
|
||||
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
||||
@@ -48,7 +50,7 @@ const item: CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport }: DocumentationSidebarProps) {
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false }: DocumentationSidebarProps) {
|
||||
const t = useTranslate();
|
||||
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
||||
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||
@@ -106,7 +108,7 @@ export function DocumentationSidebar({ onItem, onExport, onReport }: Documentati
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{ITEMS.map((each) => (
|
||||
{(readOnly ? [] : ITEMS).map((each) => (
|
||||
<button
|
||||
key={each.kind}
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"access.frozen": "Eingefroren – nur ansehen",
|
||||
"access.frozenRefused": "Das Projekt ist eingefroren; die Simulation startet nicht.",
|
||||
"access.readOnly": "Nur ansehen",
|
||||
"access.readOnlyRefused": "Hier können Sie nur zuschauen.",
|
||||
"access.startedBy": "Simulation gestartet von {name}",
|
||||
"browser.address": "Adresse",
|
||||
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
|
||||
"browser.errorTitle": "Fehler {message} ({code})",
|
||||
@@ -352,6 +357,14 @@
|
||||
"program.textEditor": "Text-Editor",
|
||||
"program.webBrowser": "Webbrowser",
|
||||
"program.webServer": "Webserver",
|
||||
"remote.connecting": "Verbindung zum Projekt …",
|
||||
"remote.deleted": "Das Projekt wurde gelöscht.",
|
||||
"remote.noAccess": "Sie haben keinen Zugang zu diesem Projekt.",
|
||||
"remote.notAProject": "Diese Adresse nennt kein Projekt.",
|
||||
"remote.notSignedIn": "Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.",
|
||||
"remote.refused": "Der Filius-Dienst hat die Verbindung abgelehnt ({reason}).",
|
||||
"remote.revoked": "Ihr Zugang zu diesem Projekt wurde beendet.",
|
||||
"remote.unknownProject": "Dieses Projekt gibt es nicht mehr.",
|
||||
"report.apps": "Anwendungen",
|
||||
"report.baseConfig": "Basiskonfiguration",
|
||||
"report.components": "Komponenten",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"access.frozen": "Frozen – view only",
|
||||
"access.frozenRefused": "The project is frozen; the simulation does not start.",
|
||||
"access.readOnly": "View only",
|
||||
"access.readOnlyRefused": "You can only watch here.",
|
||||
"access.startedBy": "Simulation started by {name}",
|
||||
"browser.address": "Address",
|
||||
"browser.errorText": "An error occurred (HTTP-Status: {code})",
|
||||
"browser.errorTitle": "Error {message} ({code})",
|
||||
@@ -352,6 +357,14 @@
|
||||
"program.textEditor": "Text editor",
|
||||
"program.webBrowser": "Webbrowser",
|
||||
"program.webServer": "Webserver",
|
||||
"remote.connecting": "Connecting to the project …",
|
||||
"remote.deleted": "The project was deleted.",
|
||||
"remote.noAccess": "You have no access to this project.",
|
||||
"remote.notAProject": "This address names no project.",
|
||||
"remote.notSignedIn": "Your sign-in has expired. Open the project again from the dashboard.",
|
||||
"remote.refused": "The Filius service refused the connection ({reason}).",
|
||||
"remote.revoked": "Your access to this project has ended.",
|
||||
"remote.unknownProject": "This project no longer exists.",
|
||||
"report.apps": "Applications",
|
||||
"report.baseConfig": "Base Configuration",
|
||||
"report.components": "Components",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// The editor's page. Embedded in the dashboard (F6) it gets its document and
|
||||
// language over the bridge; on its own — for development and the browser
|
||||
// tests — it opens one of FILIUS' examples, an empty project or a file
|
||||
// (`?example=<name>` opens that example at once, `?example=new` an empty
|
||||
// project, `?lang=en` and `?theme=dark` set the rest).
|
||||
// The editor's page. On a project of server F (`?project=<uuid>`, and
|
||||
// `&server=<address>` when F is not where the page comes from) it opens the
|
||||
// shared document and the room there (SharedProject); embedded in the
|
||||
// dashboard (F6) it gets its language and theme over the bridge. On its own
|
||||
// — for development and the browser tests — it opens one of FILIUS'
|
||||
// examples, an empty project or a file (`?example=<name>` opens that example
|
||||
// at once, `?example=new` an empty project, `?lang=en` and `?theme=dark` set
|
||||
// the rest), with its room in a worker of the page.
|
||||
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
@@ -11,6 +14,7 @@ import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topology
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { startBridge } from './bridge/dashboardBridge.js';
|
||||
import { Editor } from './Editor.js';
|
||||
import { SharedProject } from './remote/SharedProject.js';
|
||||
import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
|
||||
import './theme/tokens.css';
|
||||
|
||||
@@ -32,6 +36,7 @@ function App() {
|
||||
/** The open file's name, for what the editor exports. */
|
||||
const [fileName, setFileName] = useState<string | undefined>(undefined);
|
||||
const [example, setExample] = useState(params.get('example') ?? '');
|
||||
const project = params.get('project');
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
||||
@@ -81,6 +86,14 @@ function App() {
|
||||
}, [doc]);
|
||||
|
||||
const t = (key: string) => translate(language, key);
|
||||
if (project !== null)
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<main style={{ height: '100%' }}>
|
||||
<SharedProject server={params.get('server') ?? window.location.origin} project={project} />
|
||||
</main>
|
||||
</LanguageProvider>
|
||||
);
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// The editor on a project of server F (F5.3b): the shared document over
|
||||
// /sync and the room over /sim, both signed in by the cookie F set at
|
||||
// /__auth. Until the document is there, a word that it is coming; when F
|
||||
// refuses or lets go — of the document or the room — a word why, instead of
|
||||
// the editor. Who only reads gets the editor locked (Editor's readOnly).
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Editor } from '../Editor.js';
|
||||
import { CATALOGS, useLanguage, useTranslate } from '../i18n/i18n.js';
|
||||
import type { SimHostFactory } from '../simulation/useSimulation.js';
|
||||
import { RemoteSimHost, simUrl } from './remoteSimHost.js';
|
||||
import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
|
||||
|
||||
export interface SharedProjectProps {
|
||||
/** F's address. */
|
||||
readonly server: string;
|
||||
readonly project: string;
|
||||
}
|
||||
|
||||
export function SharedProject({ server, project }: SharedProjectProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const [state, setState] = useState<SharedDocState>({ kind: 'connecting' });
|
||||
/** F ended the room for this page: the editor goes as when the document is let go. */
|
||||
const [roomEnd, setRoomEnd] = useState<string | null>(null);
|
||||
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
|
||||
const createSimHost = useMemo<SimHostFactory>(
|
||||
() => () => {
|
||||
const host = new RemoteSimHost(simUrl(server, project));
|
||||
host.onEnd((end) => setRoomEnd(end.reason));
|
||||
return host;
|
||||
},
|
||||
[server, project],
|
||||
);
|
||||
const ended = roomEnd ?? (state.kind === 'refused' || state.kind === 'closed' ? state.reason : null);
|
||||
if (ended !== null) {
|
||||
const key = `remote.${ended}`;
|
||||
return (
|
||||
<p role="alert" className="remote-message">
|
||||
{key in CATALOGS[language] ? t(key) : t('remote.refused', { reason: ended })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.kind !== 'ready')
|
||||
return (
|
||||
<p role="status" className="remote-message">
|
||||
{t('remote.connecting')}
|
||||
</p>
|
||||
);
|
||||
return <Editor key={state.doc.guid} doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// The simulation room on server F (plan §6.1): a WebSocket to
|
||||
// /sim?project=…, the same messages as the room in a worker of the page
|
||||
// (LocalSimHost). The socket comes back after it is lost (the network, F
|
||||
// restarting, the room's thread failing), with the topics it watched; F's
|
||||
// own word ends it for good — refused (no session, no grant) or let go
|
||||
// (revoked, deleted) — and is told to whoever listens (`onEnd`).
|
||||
|
||||
import {
|
||||
parseHostMessage,
|
||||
SIM_CLOSED_CODE,
|
||||
SIM_PROTOCOL_VERSION,
|
||||
SIM_REFUSED_CODE,
|
||||
type ClientMessage,
|
||||
type HostMessage,
|
||||
type SimHost,
|
||||
type SyncClosing,
|
||||
type SyncRefusal,
|
||||
type Topic,
|
||||
} from '@mdg-filius/protocol';
|
||||
|
||||
/** The first wait before the socket comes back, doubled each time up to RECONNECT_MAXIMUM_MILLISECONDS. */
|
||||
export const RECONNECT_FIRST_MILLISECONDS = 500;
|
||||
export const RECONNECT_MAXIMUM_MILLISECONDS = 10_000;
|
||||
|
||||
/** Why F ended the room for this page. */
|
||||
export type RoomEnd = { readonly kind: 'refused'; readonly reason: SyncRefusal } | { readonly kind: 'closed'; readonly reason: SyncClosing };
|
||||
|
||||
/** What RemoteSimHost needs of a WebSocket (the browser's, or ws in tests). */
|
||||
export interface SocketLike {
|
||||
readonly readyState: number;
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((event: { readonly data: unknown }) => void) | null;
|
||||
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null;
|
||||
}
|
||||
|
||||
export type SocketFactory = (url: string) => SocketLike;
|
||||
|
||||
const OPEN = 1;
|
||||
|
||||
export class RemoteSimHost implements SimHost {
|
||||
private socket: SocketLike | undefined;
|
||||
private readonly listeners = new Set<(message: HostMessage) => void>();
|
||||
private readonly endListeners = new Set<(end: RoomEnd) => void>();
|
||||
/** What waits for the socket to open. */
|
||||
private queue: ClientMessage[] = [];
|
||||
/** What this page watches: sent again when the socket comes back. */
|
||||
private readonly topics = new Set<Topic>();
|
||||
private wait = RECONNECT_FIRST_MILLISECONDS;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private over = false;
|
||||
private openedBefore = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly open: SocketFactory = (address) => new WebSocket(address) as unknown as SocketLike,
|
||||
) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
const socket = this.open(this.url);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.wait = RECONNECT_FIRST_MILLISECONDS;
|
||||
let queued = this.queue;
|
||||
this.queue = [];
|
||||
// Back after a loss, in a room that knows nothing of this page: all it watches, before anything else.
|
||||
if (this.openedBefore) {
|
||||
if (this.topics.size > 0) socket.send(JSON.stringify({ v: 1, kind: 'sub', topics: [...this.topics] }));
|
||||
queued = queued.filter((message) => message.kind !== 'sub' && message.kind !== 'unsub');
|
||||
}
|
||||
this.openedBefore = true;
|
||||
for (const message of queued) socket.send(JSON.stringify(message));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
value = undefined;
|
||||
}
|
||||
const parsed = parseHostMessage(value);
|
||||
const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail };
|
||||
for (const listener of this.listeners) listener(message);
|
||||
};
|
||||
socket.onclose = ({ code, reason }) => {
|
||||
if (this.over) return;
|
||||
if (code === SIM_REFUSED_CODE || code === SIM_CLOSED_CODE) {
|
||||
this.over = true;
|
||||
const end = (code === SIM_REFUSED_CODE ? { kind: 'refused', reason } : { kind: 'closed', reason }) as RoomEnd;
|
||||
for (const listener of this.endListeners) listener(end);
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => this.connect(), this.wait);
|
||||
this.wait = Math.min(RECONNECT_MAXIMUM_MILLISECONDS, this.wait * 2);
|
||||
};
|
||||
}
|
||||
|
||||
send(message: ClientMessage): void {
|
||||
if (message.kind === 'sub') for (const topic of message.topics) this.topics.add(topic);
|
||||
if (message.kind === 'unsub') for (const topic of message.topics) this.topics.delete(topic);
|
||||
if (this.over) return;
|
||||
if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(message));
|
||||
// Sent once the socket is open (again).
|
||||
else this.queue.push(message);
|
||||
}
|
||||
|
||||
onMessage(listener: (message: HostMessage) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Hears F end the room for this page; returns how to stop hearing. */
|
||||
onEnd(listener: (end: RoomEnd) => void): () => void {
|
||||
this.endListeners.add(listener);
|
||||
return () => this.endListeners.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.over = true;
|
||||
if (this.timer !== undefined) clearTimeout(this.timer);
|
||||
this.listeners.clear();
|
||||
this.endListeners.clear();
|
||||
this.socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The address of a project's room on F, from F's address (http or https). */
|
||||
export function simUrl(server: string, project: string): string {
|
||||
const url = new URL('/sim', server);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.searchParams.set('project', project);
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// The project's shared document on server F (plan §5): Hocuspocus' client
|
||||
// on /sync, signed in by the cookie F set at /__auth. What it says is the
|
||||
// editor's state: still connecting; ready — with the document, whether this
|
||||
// person only reads, and whether because the project is frozen; refused
|
||||
// (no session, no grant, no such project); or let go (revoked, deleted).
|
||||
//
|
||||
// A person who read and may write again (a frozen project thawed) gets the
|
||||
// document anew: a change made while reading stayed in the page's copy and
|
||||
// F did not take it, and Yjs on F would hold back every later change that
|
||||
// builds on it (measured in F5.2). The editor locks editing while it reads,
|
||||
// so that nothing gets there; the fresh copy is for what got there anyway.
|
||||
|
||||
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { SYNC_CLOSINGS, type AccessMessage } from '@mdg-filius/protocol';
|
||||
|
||||
export type SharedDocState =
|
||||
| { readonly kind: 'connecting' }
|
||||
| { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean }
|
||||
/** F refused the connection; the reason is one of SYNC_REFUSALS (or Hocuspocus' own word). */
|
||||
| { readonly kind: 'refused'; readonly reason: string }
|
||||
/** F let the connection go; the reason is one of SYNC_CLOSINGS. */
|
||||
| { readonly kind: 'closed'; readonly reason: string };
|
||||
|
||||
export interface SharedDocOptions {
|
||||
/** F's address (http or https). */
|
||||
readonly server: string;
|
||||
readonly project: string;
|
||||
readonly onState: (state: SharedDocState) => void;
|
||||
/** A WebSocket other than the page's (tests in Node). */
|
||||
readonly WebSocketPolyfill?: unknown;
|
||||
}
|
||||
|
||||
/** The address of F's shared documents, from F's address. */
|
||||
export function syncUrl(server: string): string {
|
||||
const url = new URL('/sync', server);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** Connects to the project's document; returns how to let it go. */
|
||||
export function connectSharedDoc({ server, project, onState, WebSocketPolyfill }: SharedDocOptions): () => void {
|
||||
let current: { readonly provider: HocuspocusProvider; readonly socket: HocuspocusProviderWebsocket } | undefined;
|
||||
const letGo = () => {
|
||||
current?.provider.destroy();
|
||||
current?.socket.destroy();
|
||||
current = undefined;
|
||||
};
|
||||
const end = (state: SharedDocState) => {
|
||||
letGo();
|
||||
onState(state);
|
||||
};
|
||||
const open = () => {
|
||||
const doc = new Y.Doc();
|
||||
const socket = new HocuspocusProviderWebsocket({ url: syncUrl(server), ...(WebSocketPolyfill === undefined ? {} : { WebSocketPolyfill }) });
|
||||
let readOnly = false;
|
||||
let frozen = false;
|
||||
let synced = false;
|
||||
/** F tells the access at once (`access`): ready only with it, so that a reader never sees a writable editor. */
|
||||
let told = false;
|
||||
const tell = () => {
|
||||
if (synced && told) onState({ kind: 'ready', doc, readOnly, frozen });
|
||||
};
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: project,
|
||||
document: doc,
|
||||
// Who comes in is told by the cookie; the token is not looked at.
|
||||
token: 'cookie',
|
||||
onSynced: () => {
|
||||
if (synced) return;
|
||||
synced = true;
|
||||
tell();
|
||||
},
|
||||
onAuthenticationFailed: ({ reason }) => end({ kind: 'refused', reason }),
|
||||
onStateless: ({ payload }) => {
|
||||
let message: Partial<AccessMessage>;
|
||||
try {
|
||||
message = JSON.parse(payload) as Partial<AccessMessage>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.kind !== 'access' || typeof message.readOnly !== 'boolean') return;
|
||||
if (readOnly && !message.readOnly && synced) {
|
||||
letGo();
|
||||
onState({ kind: 'connecting' });
|
||||
open();
|
||||
return;
|
||||
}
|
||||
readOnly = message.readOnly;
|
||||
frozen = message.frozen === true;
|
||||
told = true;
|
||||
tell();
|
||||
},
|
||||
onClose: ({ event }) => {
|
||||
if ((SYNC_CLOSINGS as readonly string[]).includes(event.reason)) end({ kind: 'closed', reason: event.reason });
|
||||
},
|
||||
});
|
||||
provider.attach();
|
||||
current = { provider, socket };
|
||||
};
|
||||
onState({ kind: 'connecting' });
|
||||
open();
|
||||
return letGo;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { ConnectionsPanel } from './ConnectionsPanel.js';
|
||||
import { FirewallPanel } from './FirewallPanel.js';
|
||||
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetLock, SheetLocked } from './SheetLock.js';
|
||||
import './sheet.css';
|
||||
|
||||
/** The device's sheet, read anew after every change of the document. */
|
||||
@@ -60,6 +61,8 @@ export interface DeviceSheetProps {
|
||||
readonly onClose: () => void;
|
||||
/** The cable to draw active (a router's card whose tab is open), or none. */
|
||||
readonly onHighlight: (link: string | null) => void;
|
||||
/** Who only reads (on F): everything shown, nothing changed (SheetLock). */
|
||||
readonly readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** A page of the sheet that Java opens as a dialog. */
|
||||
@@ -67,7 +70,7 @@ export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections';
|
||||
|
||||
const PAGE_TITLES: Readonly<Record<SheetPage, string>> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' };
|
||||
|
||||
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
|
||||
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false }: DeviceSheetProps) {
|
||||
const t = useTranslate();
|
||||
const sheet = useDeviceSheet(doc, id);
|
||||
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields.
|
||||
@@ -86,6 +89,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
};
|
||||
const title = t(`device.${sheet.kind}`);
|
||||
return (
|
||||
<SheetLocked.Provider value={readOnly}>
|
||||
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
|
||||
<header>
|
||||
{open === null ? (
|
||||
@@ -103,17 +107,25 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
{/* A field per device: a text typed for one device is not offered to the next. Kept while a page is open, so that its tab stays as Java's panel does behind a dialog. */}
|
||||
<div key={id} hidden={open !== null}>
|
||||
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
|
||||
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
|
||||
<SheetLock>
|
||||
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
|
||||
</SheetLock>
|
||||
) : sheet.kind === 'networkSwitch' ? (
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
) : sheet.kind === 'remoteLink' ? (
|
||||
<ModemFields sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<ModemFields sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
) : sheet.kind === 'router' ? (
|
||||
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
|
||||
) : sheet.kind === 'gateway' ? (
|
||||
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
|
||||
) : (
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<SheetLock>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
</SheetLock>
|
||||
)}
|
||||
</div>
|
||||
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? (
|
||||
@@ -126,14 +138,19 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
</div>
|
||||
) : open === 'connections' && sheet.kind === 'router' ? (
|
||||
<div key={`${id}-connections`}>
|
||||
<ConnectionsPanel sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<ConnectionsPanel sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
</div>
|
||||
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
|
||||
<div key={`${id}-forwarding`}>
|
||||
<PortForwardingPanel sheet={sheet} change={change} />
|
||||
<SheetLock>
|
||||
<PortForwardingPanel sheet={sheet} change={change} />
|
||||
</SheetLock>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</SheetLocked.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Change } from './NameField.js';
|
||||
import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
|
||||
import { SheetTabs } from './SheetTabs.js';
|
||||
import { SheetPanel } from './SheetLock.js';
|
||||
|
||||
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
|
||||
const PROTOCOL_NAMES: Readonly<Record<FirewallProtocol, string>> = { all: '*', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
|
||||
@@ -51,7 +52,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
|
||||
selected={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
<div role="tabpanel" key={tab}>
|
||||
<SheetPanel key={tab}>
|
||||
{tab === 'general' ? (
|
||||
<>
|
||||
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
|
||||
@@ -174,7 +175,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { NameField, type Change } from './NameField.js';
|
||||
import { RowsEditor } from './RowsEditor.js';
|
||||
import { SheetTabs, type Tab } from './SheetTabs.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetPanel } from './SheetLock.js';
|
||||
|
||||
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
|
||||
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
|
||||
@@ -94,7 +95,7 @@ export function RouterFields({
|
||||
<>
|
||||
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
|
||||
{/* A panel per tab: a text typed on one card is not offered to the next. */}
|
||||
<div role="tabpanel" key={shown}>
|
||||
<SheetPanel key={shown}>
|
||||
{shown === 'general' ? (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
@@ -115,7 +116,7 @@ export function RouterFields({
|
||||
) : (
|
||||
<RouteTable sheet={sheet} change={change} />
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +145,7 @@ export function HomeRouterFields({
|
||||
return (
|
||||
<>
|
||||
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
|
||||
<div role="tabpanel" key={tab}>
|
||||
<SheetPanel key={tab}>
|
||||
{tab === 'general' ? (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
@@ -175,7 +176,7 @@ export function HomeRouterFields({
|
||||
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { Change } from './NameField.js';
|
||||
import { RowsEditor, type RowsColumn } from './RowsEditor.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetTabs } from './SheetTabs.js';
|
||||
import { SheetPanel } from './SheetLock.js';
|
||||
|
||||
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
@@ -44,7 +45,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
|
||||
selected={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
<div role="tabpanel" key={tab}>
|
||||
<SheetPanel key={tab}>
|
||||
{tab === 'basic' ? (
|
||||
<>
|
||||
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} />
|
||||
@@ -105,7 +106,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
|
||||
onRemove={(index) => change.run(() => removeDhcpStaticAssignment(change.doc, change.id, index, change.origin))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// The sheet of a person who only reads (on F): what it holds is shown, every
|
||||
// field, box and button that changes something is disabled — a fieldset
|
||||
// around a sheet's body and around each tab's panel, so that the tabs stay
|
||||
// to be read through.
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
export const SheetLocked = createContext(false);
|
||||
|
||||
/** A sheet's body, disabled while the sheet is locked. */
|
||||
export function SheetLock({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="sheet-lock" disabled={useContext(SheetLocked)}>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tab's panel, disabled while the sheet is locked. */
|
||||
export function SheetPanel({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="sheet-lock" role="tabpanel" disabled={useContext(SheetLocked)}>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,18 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* A word beside the mode buttons: who started the simulation, that this person only reads. */
|
||||
.editor-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -336,3 +348,18 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
/* A locked sheet's body and tab panels (SheetLock): a fieldset without its own frame. */
|
||||
.sheet-lock {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Instead of the editor on a project of F: that it is coming, or why not. */
|
||||
.remote-message {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Java's mode buttons (GUIMainMenu: documentation, design and simulation
|
||||
// mode, in Java's order, with Java's icons) and, while the simulation runs, its speed slider with the per cent
|
||||
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
|
||||
// beside it. Who only reads (on F) neither starts nor stops the room's
|
||||
// simulation nor sets its speed; the documentation mode is a view of one's own. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
|
||||
// a browser they bookmark and reload the page.
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
@@ -25,7 +26,20 @@ const modeButton: CSSProperties = {
|
||||
/** The editor's modes: the room's two, and the documentation mode, which is the design's for the room. */
|
||||
export type EditorMode = 'documentation' | 'design' | 'simulation';
|
||||
|
||||
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: EditorMode; readonly speed: number; readonly onMode: (mode: EditorMode) => void; readonly onSpeed: (value: number) => void }) {
|
||||
export function ModeBar({
|
||||
mode,
|
||||
speed,
|
||||
onMode,
|
||||
onSpeed,
|
||||
canRun = true,
|
||||
}: {
|
||||
readonly mode: EditorMode;
|
||||
readonly speed: number;
|
||||
readonly onMode: (mode: EditorMode) => void;
|
||||
readonly onSpeed: (value: number) => void;
|
||||
/** Whether this person may start and stop the simulation (a write grant on F). */
|
||||
readonly canRun?: boolean;
|
||||
}) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<>
|
||||
@@ -36,19 +50,20 @@ export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: Edito
|
||||
aria-pressed={mode === 'documentation'}
|
||||
aria-label={t('mode.documentation')}
|
||||
title={t('mode.documentation')}
|
||||
disabled={!canRun && mode === 'simulation'}
|
||||
onClick={() => onMode('documentation')}
|
||||
>
|
||||
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
|
||||
</button>
|
||||
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
|
||||
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} disabled={!canRun && mode === 'simulation'} onClick={() => onMode('design')}>
|
||||
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
|
||||
</button>
|
||||
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} onClick={() => onMode('simulation')}>
|
||||
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} disabled={!canRun} onClick={() => onMode('simulation')}>
|
||||
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
|
||||
</button>
|
||||
{mode === 'simulation' ? (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
|
||||
<input type="range" name="speed" min={1} max={100} value={speed} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
|
||||
<input type="range" name="speed" min={1} max={100} value={speed} disabled={!canRun} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
|
||||
<span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{speed}%
|
||||
</span>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// the cables that carry frames, a computer's network as it runs, the lines
|
||||
// and views of the programs' windows and the actions taken in them, the
|
||||
// cards' frames for the data exchange window. The room
|
||||
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
|
||||
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
|
||||
// runs behind a SimHost — on F for everyone in the project (RemoteSimHost),
|
||||
// or in a worker of this page (LocalSimHost) on the development page; the
|
||||
// editor keeps no simulation of its own.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
@@ -41,6 +42,10 @@ export interface Simulation {
|
||||
readonly views: ReadonlyMap<string, unknown>;
|
||||
/** Whether the core failed and the room went back to design mode, since the last start. */
|
||||
readonly failed: boolean;
|
||||
/** Who started the simulation that runs, on F. */
|
||||
readonly startedBy: string | undefined;
|
||||
/** The room's last no to this person — it only reads, the project is frozen — counted, so that each is told. */
|
||||
readonly refusal: { readonly code: 'readOnly' | 'frozen'; readonly count: number } | null;
|
||||
/** The time of day (milliseconds since 1970) the simulation's time 0 stands for: a viewer's times of day are written from it. */
|
||||
readonly clock: number;
|
||||
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
|
||||
@@ -75,6 +80,8 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
|
||||
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [startedBy, setStartedBy] = useState<string | undefined>(undefined);
|
||||
const [refusal, setRefusal] = useState<Simulation['refusal']>(null);
|
||||
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
|
||||
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
|
||||
const [clock, setClock] = useState(0);
|
||||
@@ -90,6 +97,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
setMode(message.mode);
|
||||
setSpeedState(message.speed);
|
||||
setClock(message.clock);
|
||||
setStartedBy(message.startedBy);
|
||||
if (message.mode === 'design') setActiveLinks(NO_LINKS);
|
||||
return;
|
||||
case 'activity':
|
||||
@@ -125,6 +133,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
return;
|
||||
case 'error':
|
||||
if (message.code === 'engineFailed') setFailed(true);
|
||||
if (message.code === 'readOnly' || message.code === 'frozen') {
|
||||
const code = message.code;
|
||||
setRefusal((before) => ({ code, count: (before?.count ?? 0) + 1 }));
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
@@ -199,5 +211,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
|
||||
}, []);
|
||||
|
||||
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
|
||||
return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// The room on F as the editor reaches it (RemoteSimHost), on sockets made
|
||||
// by the test and a clock it moves: what is sent before the socket opens
|
||||
// goes once it does; the room's messages are read as LocalSimHost reads
|
||||
// them; a lost socket comes back after a wait that doubles (to 10 s at
|
||||
// most) and watches again what it watched; F's refusal and parting end it
|
||||
// for good and are told; closing it ends it without a word.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, type HostMessage } from '@mdg-filius/protocol';
|
||||
import { RECONNECT_FIRST_MILLISECONDS, RECONNECT_MAXIMUM_MILLISECONDS, RemoteSimHost, simUrl, type RoomEnd, type SocketLike } from '../src/remote/remoteSimHost.js';
|
||||
|
||||
class FakeSocket implements SocketLike {
|
||||
readyState = 0;
|
||||
readonly sent: unknown[] = [];
|
||||
closedWith: [number | undefined, string | undefined] | undefined;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
|
||||
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
|
||||
constructor(readonly url: string) {}
|
||||
send(data: string): void {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
close(code?: number, reason?: string): void {
|
||||
this.closedWith = [code, reason];
|
||||
this.readyState = 3;
|
||||
}
|
||||
opened(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
hear(data: unknown): void {
|
||||
this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) });
|
||||
}
|
||||
lost(code: number, reason = ''): void {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code, reason });
|
||||
}
|
||||
}
|
||||
|
||||
let sockets: FakeSocket[];
|
||||
const host = () =>
|
||||
new RemoteSimHost('ws://f.test/sim?project=p', (url) => {
|
||||
const socket = new FakeSocket(url);
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sockets = [];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('the room on F', () => {
|
||||
it('sends what came before the socket opened once it does, and then at once', () => {
|
||||
const room = host();
|
||||
room.send({ v: 1, kind: 'sub', topics: ['activity'] });
|
||||
room.send({ v: 1, kind: 'start' });
|
||||
expect(sockets[0]!.sent).toEqual([]);
|
||||
sockets[0]!.opened();
|
||||
room.send({ v: 1, kind: 'stop' });
|
||||
expect(sockets[0]!.sent).toEqual([
|
||||
{ v: 1, kind: 'sub', topics: ['activity'] },
|
||||
{ v: 1, kind: 'start' },
|
||||
{ v: 1, kind: 'stop' },
|
||||
]);
|
||||
expect(sockets[0]!.url).toBe('ws://f.test/sim?project=p');
|
||||
});
|
||||
|
||||
it("reads the room's messages, and names what it cannot read", () => {
|
||||
const room = host();
|
||||
const got: HostMessage[] = [];
|
||||
const stop = room.onMessage((message) => got.push(message));
|
||||
sockets[0]!.opened();
|
||||
sockets[0]!.hear({ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 });
|
||||
sockets[0]!.hear('not json');
|
||||
sockets[0]!.hear({ v: 1, kind: 'nonsense' });
|
||||
stop();
|
||||
sockets[0]!.hear({ v: 1, kind: 'activity', links: [] });
|
||||
expect(got).toEqual([
|
||||
{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 },
|
||||
{ v: 1, kind: 'error', code: 'badMessage', detail: 'not an object' },
|
||||
{ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('comes back after a loss, waiting longer each time, and watches again what it watched', () => {
|
||||
const room = host();
|
||||
room.send({ v: 1, kind: 'sub', topics: ['activity', 'node:a'] });
|
||||
sockets[0]!.opened();
|
||||
room.send({ v: 1, kind: 'unsub', topics: ['node:a'] });
|
||||
sockets[0]!.lost(1006);
|
||||
// Sent while it is away: kept for the room that comes.
|
||||
room.send({ v: 1, kind: 'sub', topics: ['trace:p1'] });
|
||||
room.send({ v: 1, kind: 'speed', value: 50 });
|
||||
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS - 1);
|
||||
expect(sockets).toHaveLength(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(sockets).toHaveLength(2);
|
||||
sockets[1]!.opened();
|
||||
expect(sockets[1]!.sent).toEqual([
|
||||
{ v: 1, kind: 'sub', topics: ['activity', 'trace:p1'] },
|
||||
{ v: 1, kind: 'speed', value: 50 },
|
||||
]);
|
||||
// Lost again before it opened, again and again: 1 s, 2 s, … up to 10 s; an open socket begins anew.
|
||||
const waits: number[] = [];
|
||||
for (let index = 1; index <= 6; index++) {
|
||||
const before = sockets.length;
|
||||
sockets.at(-1)!.lost(index % 2 === 0 ? SIM_ROOM_FAILED_CODE : 1001);
|
||||
let waited = 0;
|
||||
while (sockets.length === before) {
|
||||
vi.advanceTimersByTime(100);
|
||||
waited += 100;
|
||||
}
|
||||
waits.push(waited);
|
||||
}
|
||||
expect(waits).toEqual([RECONNECT_FIRST_MILLISECONDS, 1_000, 2_000, 4_000, 8_000, RECONNECT_MAXIMUM_MILLISECONDS]);
|
||||
sockets.at(-1)!.opened();
|
||||
sockets.at(-1)!.lost(1006);
|
||||
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS);
|
||||
expect(sockets.at(-1)!.readyState).toBe(0);
|
||||
});
|
||||
|
||||
it('ends for good when F refuses or lets go, and says why', () => {
|
||||
for (const [code, end] of [
|
||||
[SIM_REFUSED_CODE, { kind: 'refused', reason: 'noAccess' }],
|
||||
[SIM_CLOSED_CODE, { kind: 'closed', reason: 'revoked' }],
|
||||
] as const) {
|
||||
sockets = [];
|
||||
const room = host();
|
||||
const ends: RoomEnd[] = [];
|
||||
room.onEnd((each) => ends.push(each));
|
||||
sockets[0]!.opened();
|
||||
sockets[0]!.lost(code, end.reason);
|
||||
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
|
||||
expect(sockets).toHaveLength(1);
|
||||
expect(ends).toEqual([end]);
|
||||
room.send({ v: 1, kind: 'start' });
|
||||
expect(sockets[0]!.sent).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends without a word when it is closed, and does not come back', () => {
|
||||
const room = host();
|
||||
const ends: RoomEnd[] = [];
|
||||
const stop = room.onEnd((each) => ends.push(each));
|
||||
sockets[0]!.opened();
|
||||
room.close();
|
||||
expect(sockets[0]!.closedWith).toEqual([undefined, undefined]);
|
||||
sockets[0]!.lost(1000);
|
||||
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
|
||||
expect([sockets.length, ends]).toEqual([1, []]);
|
||||
stop();
|
||||
// Closed while it waits to come back: it does not.
|
||||
const other = host();
|
||||
sockets.at(-1)!.lost(1006);
|
||||
other.close();
|
||||
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
|
||||
expect(sockets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('makes the address of a room from the address of F', () => {
|
||||
expect(simUrl('https://filius.hub.example/', 'abc')).toBe('wss://filius.hub.example/sim?project=abc');
|
||||
expect(simUrl('http://127.0.0.1:8080', 'abc')).toBe('ws://127.0.0.1:8080/sim?project=abc');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// The shared documents (/sync, plan §5): Hocuspocus, a project a document.
|
||||
// Who comes in is decided by the session the browser's cookie names (plan
|
||||
// §8.5): its grant to the project, read or write; a frozen project is read
|
||||
// by everyone; a refusal says why (SYNC_REFUSALS). A project is loaded from
|
||||
// by everyone; a refusal says why (SYNC_REFUSALS); the access a connection
|
||||
// has is told to it at once (and again when it changes). A project is loaded from
|
||||
// the store when its first person comes (projects are made through the
|
||||
// service), every
|
||||
// update stored as it comes, compacted a minute after the last change, when
|
||||
@@ -34,6 +35,8 @@ export const SESSION_COOKIE = 'filius_session';
|
||||
export interface ConnectionContext {
|
||||
readonly person: SessionPerson;
|
||||
readonly level: Level;
|
||||
/** Whether the project was frozen when the connection came. */
|
||||
readonly frozen: boolean;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
@@ -51,9 +54,14 @@ export interface Sync {
|
||||
discard(project: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A refusal of a connection, with the reason the person's editor is told (Hocuspocus sends an error's `reason`). */
|
||||
/**
|
||||
* A refusal of a connection, with the reason the person's editor is told
|
||||
* (Hocuspocus sends an error's `reason`). Without a message: Hocuspocus
|
||||
* writes a failed hook's message onto the console, outside F's JSON log,
|
||||
* which has the refusal already (`sync.refused`, `doc.unknown`).
|
||||
*/
|
||||
export function refusal(reason: SyncRefusal): Error & { readonly reason: SyncRefusal } {
|
||||
return Object.assign(new Error(reason), { reason });
|
||||
return Object.assign(new Error(), { reason });
|
||||
}
|
||||
|
||||
/** A cookie's value from a Cookie header. */
|
||||
@@ -109,7 +117,11 @@ export function createSync({ store, sessions, log, compactAfterIdleMilliseconds
|
||||
}
|
||||
connectionConfig.readOnly = access.level === 'read' || access.frozen;
|
||||
log.info('sync.joined', { project, user: access.person.login, readOnly: connectionConfig.readOnly });
|
||||
return { person: access.person, level: access.level };
|
||||
return { person: access.person, level: access.level, frozen: access.frozen };
|
||||
},
|
||||
// Told at once, so that the editor knows from the start whether it reads, and why.
|
||||
async connected({ connection, context }) {
|
||||
connection.sendStateless(accessMessage(connection.readOnly, (context as ConnectionContext).frozen));
|
||||
},
|
||||
async onLoadDocument({ documentName, document }) {
|
||||
const persistence = new DocumentPersistence(store, documentName, log);
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
|
||||
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
|
||||
import { cookieValue, PostgresSessions, PostgresStore, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
|
||||
import { cookieValue, PostgresSessions, PostgresStore, refusal, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
|
||||
import { testDatabase, type TestDatabase } from './database.js';
|
||||
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
|
||||
import { signTicket } from './tickets.js';
|
||||
@@ -226,6 +226,21 @@ describe('the way into a shared document', () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('refuses without a word on the console: the reason goes to the editor, the log has the rest', async () => {
|
||||
expect([refusal('noAccess').reason, refusal('noAccess').message]).toEqual(['noAccess', '']);
|
||||
const server = await start();
|
||||
const project = await newProject();
|
||||
const written: unknown[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => void written.push(args));
|
||||
try {
|
||||
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(written).toEqual([]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('reads a cookie among others, and nothing that only looks like it', () => {
|
||||
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
|
||||
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
|
||||
@@ -306,25 +321,30 @@ describe("the dashboard's service", () => {
|
||||
const project = await newProject();
|
||||
const writer = await enter(server, project, { level: 'write' });
|
||||
const reader = await enter(server, project, { level: 'read' });
|
||||
// Each is told its access as it comes in.
|
||||
await until(() => writer.messages.length === 1 && reader.messages.length === 1);
|
||||
expect([writer.messages, reader.messages]).toEqual([[{ v: 1, kind: 'access', readOnly: false, frozen: false }], [{ v: 1, kind: 'access', readOnly: true, frozen: false }]]);
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
|
||||
await until(() => writer.messages.length === 1);
|
||||
expect(writer.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
|
||||
await until(() => writer.messages.length === 2);
|
||||
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: true, frozen: true });
|
||||
// Whoever comes now reads too, with a write grant; what they change is not taken.
|
||||
const late = await enter(server, project, { level: 'write' });
|
||||
expect(late.provider.authorizedScope).toBe('readonly');
|
||||
await until(() => late.messages.length === 1);
|
||||
expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
|
||||
place(late.doc, 'a');
|
||||
await sleep(300);
|
||||
expect(await store.pending(project)).toBe(0);
|
||||
expect(deviceCount(writer.doc)).toBe(0);
|
||||
late.close();
|
||||
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
|
||||
await until(() => writer.messages.length === 2);
|
||||
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
|
||||
await until(() => writer.messages.length === 3);
|
||||
expect(writer.messages[2]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
|
||||
place(writer.doc, 'b');
|
||||
await until(async () => (await store.pending(project)) === 1);
|
||||
await until(() => deviceCount(reader.doc) === 1);
|
||||
// The reader was told nothing: it read before and reads still.
|
||||
expect(reader.messages).toEqual([]);
|
||||
// The reader was told nothing more: it read before and reads still.
|
||||
expect(reader.messages).toHaveLength(1);
|
||||
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
|
||||
writer.close();
|
||||
reader.close();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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 { 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('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();
|
||||
});
|
||||
});
|
||||
@@ -220,7 +220,8 @@ describe('a simulation room on F', () => {
|
||||
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);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user