// 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 }; 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 { 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.'); });