Files
mdg-filius/packages/editor/browser/shared.spec.ts
T
julian.kiedaischandClaude Opus 5.5 47beda6b85 feat: F5.4 presence: who else is in the project
Over Yjs awareness each editor tells who it is (F names the person in
the access message), the device it has chosen and the windows it has
open, and nothing else: no pointer, nothing that changes without a
cause, sent only when it changed. Others' states are read with care.
A frame in their colour round a device they chose, a dot on a window
they have open too, a circle with initials each beside the mode
buttons; the bridge tells the dashboard who is there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:09:34 +00:00

210 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, and each sees the others (a circle each, a frame round a device
// another has chosen, a dot on a window another has open too); 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 { colorOf } from '../src/remote/presence.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 [miaUser, benUser, ritaUser] = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()];
const mia = await person(browser, project.id, 'Mia', 'write', miaUser);
const ben = await person(browser, project.id, 'Ben', 'write', benUser);
const rita = await person(browser, project.id, 'Rita', 'read', ritaUser);
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);
// Each sees the others, by their initials.
for (const [page, others] of [
[mia, [benUser, ritaUser]],
[ben, [miaUser, ritaUser]],
[rita, [miaUser, benUser]],
] as const) {
await expect(page.locator('[data-person]')).toHaveCount(2);
for (const other of others) await expect(page.locator(`[data-person="${other}"]`)).toBeVisible();
}
await expect(mia.locator(`[data-person="${benUser}"]`)).toHaveText('B');
// Mia chooses a device: Ben sees a frame round it in her colour, until she lets it go.
const notebook = project.node('Notebook 1.10');
const router = project.node('Switch 1');
const routerAt = await onDevice(mia, router);
await mia.mouse.click(routerAt.x, routerAt.y);
const frame = ben.locator(`g.device[data-node="${router}"] rect.chosen-by`);
await expect(frame).toHaveCount(1);
// The ticket gave Mia no colour: she has the one her id gives, the same in every editor.
await expect(frame).toHaveAttribute('data-color', colorOf({ id: miaUser, name: 'Mia', color: '' }));
await mia.getByRole('complementary', { name: 'Einstellungen' }).getByRole('button', { name: 'Schließen' }).click();
await expect(frame).toHaveCount(0);
// Nobody sees their own frame.
await expect(mia.locator('rect.chosen-by')).toHaveCount(0);
// Mia moves a device; Ben and Rita see it where she put it.
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 });
// Both have the notebook's desktop open: each sees the other's dot on it.
await expect(desktop(mia, notebook).locator(`[data-watcher="${benUser}"]`)).toBeVisible();
await expect(desktop(ben, notebook).locator(`[data-watcher="${miaUser}"]`)).toBeVisible();
// Rita may look, not type; she sees both on the desktop, and they see her.
const ritas = await commandLine(rita, notebook);
await expect(desktop(rita, notebook).locator('[data-watcher]')).toHaveCount(2);
await expect(desktop(mia, notebook).locator(`[data-watcher="${ritaUser}"]`)).toBeVisible();
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('tells the dashboard over the bridge who else is in the project, each time it changes', 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();
// The page is its own parent here: what it tells the dashboard comes back to it.
const origin = new URL(editor.base).origin;
await page.addInitScript(() => {
(window as unknown as { told: unknown[] }).told = [];
window.addEventListener('message', (event) => (window as unknown as { told: unknown[] }).told.push(event.data));
});
await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}&parent=${encodeURIComponent(origin)}`);
await page.waitForSelector('svg.filius-canvas');
const people = () => page.evaluate(() => (window as unknown as { told: { type?: string; people?: { name: string }[] }[] }).told.filter((message) => message.type === 'people').map((message) => message.people!.map((each) => each.name)));
await expect.poll(people).toEqual([[]]);
const benUser = crypto.randomUUID();
const ben = await person(browser, project.id, 'Ben', 'write', benUser);
await expect.poll(people).toEqual([[], ['Ben']]);
// Mia in a second tab: she is not one of the others for herself, and Ben sees her once.
const second = await context.newPage();
await second.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`);
await second.waitForSelector('svg.filius-canvas');
await expect(ben.locator('[data-person]')).toHaveCount(1);
await expect(second.locator('[data-person]')).toHaveCount(1);
await expect(second.locator(`[data-person="${benUser}"]`)).toBeVisible();
await second.close();
await ben.context().close();
await expect.poll(people, { timeout: 40_000 }).toEqual([[], ['Ben'], []]);
await context.close();
});
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.');
});