A tap on a device in the simulation now only chooses it; its three-dot button (or a long press) opens what Java's click opened: the desktop, and newly a switch's SAT table and a home router's NAT table, beside each card's data exchange. The desktop, the tables and the data exchange are no windows in the window any more but the simulation's sheet (SimulationSheet), after the dashboard's AppSheet: below the toolbar to the bottom, a head with icon, title, who else has it open, see-through and close. One at a time — it covers the surface. It stays over a return to the design with the program open on a desktop; a sheet whose device was removed closes. See-through fades the body (0.55) and keeps the head; the choice is kept in the browser. Java opens these as movable windows, several at once, with a click; named in CHANGES.md. Tests open through the menu (showFromMenu, showDesktop in editorPage.ts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
16 KiB
TypeScript
286 lines
16 KiB
TypeScript
// 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 { createHash } from 'node:crypto';
|
||
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||
import { BLOB_THRESHOLD_BYTES } from '@mdg-filius/doc';
|
||
import { drag, onDevice, serveEditor, showDesktop } 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) {
|
||
await showDesktop(page, id);
|
||
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();
|
||
// Their taps chose the notebook too (its three-dot button), but in the simulation that is no frame for the others —
|
||
// told in the same message as the open desktop just seen.
|
||
for (const page of [mia, ben]) await expect(page.locator('rect.chosen-by')).toHaveCount(0);
|
||
// 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('shows a drag to the others while it lasts, and stores it in F once, when it is let go', async ({ browser }) => {
|
||
const project = await f.project('java-examples/internetwork_komplett.fls');
|
||
const mia = await person(browser, project.id, 'Mia');
|
||
const ben = await person(browser, project.id, 'Ben');
|
||
const id = project.node('Rechner 3.10');
|
||
const before = await box(ben, id);
|
||
await expect.poll(() => f.updateCount(project.id)).toBe(0);
|
||
const from = await onDevice(mia, id);
|
||
await mia.mouse.move(from.x, from.y);
|
||
await mia.mouse.down();
|
||
for (let step = 1; step <= 30; step++) {
|
||
await mia.mouse.move(from.x - 3 * step, from.y + 2 * step);
|
||
await mia.waitForTimeout(16);
|
||
}
|
||
// Still held: Mia and Ben see it on its way, F has stored nothing of it.
|
||
expect(await box(mia, id)).not.toBe(before);
|
||
await expect.poll(async () => (await box(ben, id)) !== before).toBe(true);
|
||
expect(await f.updateCount(project.id)).toBe(0);
|
||
await mia.mouse.up();
|
||
await expect.poll(async () => box(ben, id)).toBe(await box(mia, id));
|
||
await expect.poll(() => f.updateCount(project.id)).toBe(1);
|
||
await mia.waitForTimeout(500);
|
||
expect(await f.updateCount(project.id)).toBe(1);
|
||
expect(await box(ben, id)).toBe(await box(mia, id));
|
||
});
|
||
|
||
test('takes a large picture one person imports to F as a blob, and shows it to another', async ({ browser }) => {
|
||
test.setTimeout(120_000);
|
||
const project = await f.project('java-examples/internetwork_komplett.fls');
|
||
const notebook = project.node('Notebook 1.10');
|
||
const mia = await person(browser, project.id, 'Mia');
|
||
const ben = await person(browser, project.id, 'Ben');
|
||
// A picture far over 16 KB: a screenshot of the page.
|
||
const file = join(mkdtempSync(join(tmpdir(), 'filius-blob-')), 'bild.png');
|
||
await mia.screenshot({ path: file });
|
||
const size = statSync(file).size;
|
||
expect(size).toBeGreaterThan(BLOB_THRESHOLD_BYTES);
|
||
await mia.locator('button[name="simulationMode"]').click();
|
||
for (const page of [mia, ben]) await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation');
|
||
const open = async (page: Page) => {
|
||
return showDesktop(page, notebook);
|
||
};
|
||
const miaDesktop = await open(mia);
|
||
await miaDesktop.locator('[data-program="installation"]').click();
|
||
const installation = miaDesktop.locator('[data-program-window="installation"]');
|
||
for (const program of ['Datei-Explorer', 'Bildbetrachter']) await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: program }).click();
|
||
await installation.getByRole('button', { name: 'Installieren' }).click();
|
||
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
|
||
await miaDesktop.locator('[data-program="fileExplorer"]').click();
|
||
const explorer = miaDesktop.locator('[data-program-window="fileExplorer"]');
|
||
await explorer.getByRole('toolbar', { name: 'Aktionen' }).getByRole('button', { name: 'Importieren' }).click();
|
||
const importing = explorer.getByRole('dialog', { name: 'Datei importieren' });
|
||
await importing.locator('input[type="file"]').setInputFiles(file);
|
||
await importing.getByRole('button', { name: 'Datei importieren' }).click();
|
||
await expect(importing.getByRole('status')).toHaveText('Datei erfolgreich importiert!');
|
||
// Ben opens it in his viewer: the picture, as large as the page was.
|
||
const benDesktop = await open(ben);
|
||
await benDesktop.locator('[data-program="imageViewer"]').click();
|
||
const viewer = benDesktop.locator('[data-program-window="imageViewer"]');
|
||
await viewer.getByRole('button', { name: 'Öffnen' }).click();
|
||
const dialog = viewer.getByRole('dialog', { name: 'Datei Öffnen' });
|
||
await dialog.locator('.file-dialog-entries button', { hasText: 'bild.png' }).click();
|
||
await dialog.getByRole('button', { name: 'Öffnen' }).click();
|
||
await expect.poll(() => viewer.getByRole('img', { name: 'Bild' }).evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(mia.viewportSize()!.width);
|
||
// In F's store under its SHA-256.
|
||
const sha = createHash('sha256').update(readFileSync(file)).digest('hex');
|
||
expect(await f.blobSize(sha)).toBe(size);
|
||
});
|
||
|
||
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.');
|
||
});
|