test-filius refused every ticket as from the future: its clock was 886 ms behind the dashboard's machine, with NTP still 8 ms (one ticket in 125, as a ticket carries whole seconds). F now takes a ticket signed up to 5 s ahead (TICKET_AHEAD_SECONDS); itsdangerous refuses one second, and the oracle keeps that on record. tools/live measures and accepts on a live F, with fresh projects each run: m3 (200 rooms of the largest example with pings: probe p95 37 ms), m4 (63 KB document; dragging makes an update per pointer move, 59/s), m5 (an hour through the proxy), acceptance (two browsers, 14 checks, passed three times) and clock (F's clock against ours). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
9.3 KiB
TypeScript
162 lines
9.3 KiB
TypeScript
// 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 } 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<boolean>, ms = 10_000, detail?: () => Promise<string>) {
|
|
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<Page> {
|
|
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 at = await onDevice(page, id);
|
|
await page.mouse.click(at.x, at.y);
|
|
const desktop = page.locator(`section[data-desktop="${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);
|