// Acceptance of F5 on a live server F (plan §F5): two real browsers in one // fresh project. At the same time both move a device; one places a computer, // the other cables it while the first moves another; then one starts the // simulation, the other makes a file and pings in a computer's command line, // the first sees that same command line and types in it too; the second // stops. In the end Mia's drawing, Ben's drawing and the document F stored // are the same, and the file made in the simulation is in the document. // Every run makes its own project and deletes it (Scratch's lesson 9). // // npx vite-node tools/live/acceptance.ts import { randomUUID } from 'node:crypto'; import { chromium, type Browser, type Page } from '@playwright/test'; import { nodeFiles, readProject } from '@mdg-filius/doc'; import { drag, onDevice, showDesktop } from '../../packages/editor/browser/editorPage.js'; import { cleanUp, joinDoc, seededProject, signIn, signInAddress } from './lib.js'; const results: [string, boolean, string][] = []; const check = (name: string, ok: boolean, detail = '') => { results.push([name, ok, detail]); console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` (${detail})`}`); }; async function eventually(name: string, test: () => Promise, ms = 10_000, detail?: () => Promise) { for (const end = Date.now() + ms; ;) { if (await test().catch(() => false)) return check(name, true); if (Date.now() > end) return check(name, false, detail === undefined ? 'timed out' : await detail()); await new Promise((resolve) => setTimeout(resolve, 100)); } } const boxes = (page: Page) => page.locator('g.device[data-node]').evaluateAll((all) => Object.fromEntries(all.map((each) => [each.getAttribute('data-node'), each.getAttribute('data-box')]))); const linkCount = (page: Page) => page.locator('path[data-link]').count(); const canvas = (page: Page) => page.locator('svg.filius-canvas'); async function person(browser: Browser, project: string, name: string, user: string): Promise { const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); const page = await context.newPage(); page.on('pageerror', (error) => check(`${name}: no page error`, false, error.message)); await page.goto(signInAddress(project, { name, user })); await page.waitForSelector('svg.filius-canvas'); pages.push(page); return page; } async function commandLine(page: Page, id: string) { const desktop = await showDesktop(page, id); await desktop.locator('[data-program="commandPrompt"]').click(); const window = desktop.locator('[data-program-window="commandPrompt"]'); return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') }; } const browser = await chromium.launch(); const pages: Page[] = []; try { const { project, example } = await seededProject('java-examples/internetwork_komplett.fls'); const node = (name: string) => example.graph.nodes.find((each) => each.displayName === name)!.id; // A computer's address is its device's setting (a router's are its cards'). const ipOf = (name: string) => example.deviceConfigurations.get(node(name))!.ipAddress; const [miaUser, benUser] = [randomUUID(), randomUUID()]; const mia = await person(browser, project, 'Mia', miaUser); const ben = await person(browser, project, 'Ben', benUser); await eventually('Mia and Ben see each other', async () => (await mia.locator(`[data-person="${benUser}"]`).isVisible()) && (await ben.locator(`[data-person="${miaUser}"]`).isVisible())); const devices = await mia.locator('g.device').count(); const links = await linkCount(mia); // Both move a device at the same time. const before = await boxes(mia); const shift = async (page: Page, id: string, dx: number, dy: number) => { const from = await onDevice(page, id); await drag(page, from, { x: from.x + dx, y: from.y + dy }); }; // Onto free ground: a device moved onto another would take the next click meant for the one below. await Promise.all([shift(mia, node('Rechner 3.10'), -50, 60), shift(ben, node('Notebook 4.10'), 80, 60)]); await eventually('both moves arrive at both', async () => { const [m, b] = [await boxes(mia), await boxes(ben)]; return JSON.stringify(m) === JSON.stringify(b) && m[node('Rechner 3.10')] !== before[node('Rechner 3.10')] && m[node('Notebook 4.10')] !== before[node('Notebook 4.10')]; }); // Ben places a computer; Mia cables it to Switch 1 while Ben moves the web server. const known = new Set(Object.keys(await boxes(ben))); const palette = (await ben.locator('[data-palette="pc"]').boundingBox())!; const surface = (await canvas(ben).boundingBox())!; await drag(ben, { x: palette.x + palette.width / 2, y: palette.y + palette.height / 2 }, { x: surface.x + 330, y: surface.y + 230 }); await ben.keyboard.press('Escape'); let placed = ''; await eventually('the computer Ben placed is at Mia too', async () => { placed = Object.keys(await boxes(ben)).find((id) => !known.has(id)) ?? ''; return placed !== '' && (await mia.locator(`g.device[data-node="${placed}"]`).count()) === 1; }); await mia.locator('[data-palette="cable"]').click(); await Promise.all([(async () => drag(mia, await onDevice(mia, placed), await onDevice(mia, node('Switch 1'))))(), shift(ben, node('Webserver 5.10'), 70, -40)]); await mia.locator('[data-palette="cable"]').click(); await eventually( 'the cable and the move arrive at both', async () => (await linkCount(mia)) === links + 1 && (await linkCount(ben)) === links + 1 && JSON.stringify(await boxes(mia)) === JSON.stringify(await boxes(ben)), ); // Mia starts; the simulation runs for both, named after her. await mia.locator('button[name="simulationMode"]').click(); await eventually( 'the simulation runs for both, started by Mia', async () => (await canvas(ben).getAttribute('data-mode')) === 'simulation' && (await ben.locator('[data-note="startedBy"]').textContent()) === 'Simulation gestartet von Mia', ); // Ben makes a file and pings in Notebook 1.10's command line; Mia sees the same and types in it too. const notebook = node('Notebook 1.10'); const benLine = await commandLine(ben, notebook); await benLine.input.fill('touch acceptance.txt'); await benLine.input.press('Enter'); await benLine.input.fill(`ping ${ipOf('Webserver 5.10')}`); await benLine.input.press('Enter'); const miaLine = await commandLine(mia, notebook); await eventually( "Ben's ping is in Mia's command line", async () => /4 Paket\(e\) empfangen/.test((await miaLine.text.textContent()) ?? ''), 20_000, async () => ((await miaLine.text.textContent()) ?? '').slice(-200), ); await miaLine.input.fill('ls'); await miaLine.input.press('Enter'); await eventually("Mia's ls, with Ben's file, is in Ben's command line", async () => /acceptance\.txt/.test((await benLine.text.textContent()) ?? '')); // Ben stops: both are back in design mode. await ben.locator('button[name="designMode"]').click(); await eventually('both are back in design mode', async () => (await canvas(mia).getAttribute('data-mode')) === 'design' && (await canvas(ben).getAttribute('data-mode')) === 'design'); // The same everywhere: both drawings and the document F stored. const [m, b] = [await boxes(mia), await boxes(ben)]; check("Mia's drawing is Ben's", JSON.stringify(m) === JSON.stringify(b)); check('one more device and one more cable', Object.keys(m).length === devices + 1 && (await linkCount(mia)) === links + 1, `${Object.keys(m).length} devices, ${await linkCount(mia)} cables`); await new Promise((resolve) => setTimeout(resolve, 2000)); const stored = await joinDoc(project, await signIn(project, { name: 'Prüfung' })); const read = readProject(stored.doc); check("F's document has Mia's devices", read.graph.nodes.length === Object.keys(m).length && read.graph.nodes.every((each) => m[each.id] !== undefined), `${read.graph.nodes.length} devices`); check("F's document has Mia's cables", read.graph.links.length === links + 1, `${read.graph.links.length} cables`); const moved = ['Rechner 3.10', 'Notebook 4.10', 'Webserver 5.10'].map((name) => { const was = example.graph.nodes.find((each) => each.displayName === name)!.position; const now = read.graph.nodes.find((each) => each.id === node(name))!.position; return now.x !== was.x || now.y !== was.y; }); check("F's document has all three moves", moved.every(Boolean), JSON.stringify(moved)); check( "the file Ben made in the simulation is in F's document", nodeFiles(stored.doc, notebook) ?.allEntries() .some((each) => each.path.endsWith('/acceptance.txt')) === true, ); stored.close(); } catch (error) { check('ran to the end', false, error instanceof Error ? error.message.split('\n')[0]! : String(error)); // What each saw when it stopped, for the one who looks next. for (const [index, page] of pages.entries()) await page.screenshot({ path: `acceptance-${index === 0 ? 'mia' : 'ben'}.png` }).catch(() => undefined); } finally { await browser.close(); await cleanUp(); } const failed = results.filter(([, ok]) => !ok).length; console.log(failed === 0 ? `\nF5 accepted: ${results.length} checks` : `\n${failed} of ${results.length} checks failed`); process.exit(failed === 0 ? 0 : 1);