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.');
|
||||
});
|
||||
Reference in New Issue
Block a user