Ilya Ashikhmin cd937cf4ac fix(ui): keep paper host out of React-managed DOM; fix duplicate stroke-width in node SVGs
Paper.remove() during dispose deleted the React canvas div (StrictMode double
mount left a blank canvas). Duplicate XML attributes made 5 symbol data URIs
unparseable.

Also: README, Playwright smoke test (e2e/smoke.mjs), ES2022 lib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:30:46 +02:00

173 lines
6.8 KiB
JavaScript

/**
* Playwright smoke test for typical editor scenarios, run against the Vite
* dev server: npm run dev → node e2e/smoke.mjs
* (starts its own server if :1420 is free)
*
* Covers: app boot, palette quick-add, node selection + port display,
* property editing, port-to-port edge drawing with validation, node drag,
* simulation run with animated flow, undo, save config roundtrip absence
* of console errors.
*/
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const URL = 'http://localhost:1420';
let devServer = null;
async function serverUp() {
try {
const res = await fetch(URL);
return res.ok;
} catch {
return false;
}
}
function fail(msg) {
console.error(`${msg}`);
process.exitCode = 1;
}
function ok(msg) {
console.log(`${msg}`);
}
async function ensureServer() {
if (await serverUp()) return;
devServer = spawn('npm', ['run', 'dev'], { stdio: 'ignore', detached: true });
for (let i = 0; i < 60; i += 1) {
if (await serverUp()) return;
await sleep(500);
}
throw new Error('dev server did not start');
}
const consoleErrors = [];
async function main() {
await ensureServer();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1500, height: 950 } });
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text());
});
page.on('pageerror', (e) => consoleErrors.push(String(e)));
await page.goto(URL);
await page.waitForSelector('[data-testid="palette"]');
await page.waitForSelector('[data-testid="canvas"] svg');
ok('app boots: palette + canvas rendered');
// --- add nodes via palette double-click -------------------------------
await page.dblclick('[data-testid="palette-item-waterSource"]');
await page.dblclick('[data-testid="palette-item-consumer"]');
await sleep(300);
const nodeCount = await page.locator('.joint-element').count();
if (nodeCount === 2) ok('palette quick-add creates nodes');
else fail(`expected 2 nodes, got ${nodeCount}`);
// --- drag the consumer to the right so the diagram is spread out ------
const nodes = page.locator('.joint-element');
const consumerBox = await nodes.nth(1).boundingBox();
await page.mouse.move(consumerBox.x + consumerBox.width / 2, consumerBox.y + consumerBox.height / 2);
await page.mouse.down();
await page.mouse.move(consumerBox.x + 420, consumerBox.y + 60, { steps: 12 });
await page.mouse.up();
const movedBox = await nodes.nth(1).boundingBox();
if (Math.abs(movedBox.x - consumerBox.x) > 300) ok('node dragging works');
else fail(`node did not move (dx=${movedBox.x - consumerBox.x})`);
// --- select source: ports become visible ------------------------------
const srcBox = await nodes.nth(0).boundingBox();
await page.mouse.click(srcBox.x + srcBox.width / 2, srcBox.y + srcBox.height / 2);
await sleep(150);
const focused = await page.locator('.joint-element.pf-focused').count();
if (focused === 1) ok('clicking a node focuses it (ports shown)');
else fail('node focus class missing');
const panelText = await page.locator('[data-testid="property-panel"]').innerText();
if (/water source/i.test(panelText) && /physics/i.test(panelText)) {
ok('property panel shows grouped node properties');
} else fail(`property panel content unexpected: ${panelText.slice(0, 200).replace(/\n/g, ' | ')}`);
// --- edit title property ----------------------------------------------
await page.fill('[data-testid="prop-title"]', 'Main intake');
await page.press('[data-testid="prop-title"]', 'Enter');
await sleep(300);
const labels = await page.evaluate(() =>
[...document.querySelectorAll('.joint-element text')].map((t) => t.textContent),
);
if (labels.some((l) => l && l.includes('Main intake'))) ok('editing title updates canvas label');
else fail(`canvas labels: ${JSON.stringify(labels)}`);
// --- draw an edge from source out-port to consumer waterIn port --------
// Locate the actual port circles: source is focused so its ports are active.
const portRects = await page.evaluate(() => {
const rect = (el) => {
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
const out = document.querySelector('.joint-element.pf-focused [port="out"]');
const elements = [...document.querySelectorAll('.joint-element')];
const consumer = elements.find((e) => e.querySelector('[port="waterIn"]'));
const waterIn = consumer?.querySelector('[port="waterIn"]');
return out && waterIn ? { out: rect(out), waterIn: rect(waterIn) } : null;
});
let linkCount = 0;
if (!portRects) {
fail('could not locate port magnets');
} else {
await page.mouse.move(portRects.out.x, portRects.out.y);
await page.mouse.down();
await page.mouse.move(portRects.waterIn.x, portRects.waterIn.y, { steps: 15 });
await sleep(150);
await page.mouse.up();
await sleep(300);
linkCount = await page.locator('.joint-link').count();
if (linkCount >= 1) ok('edge drawn between compatible ports');
else fail('no edge was created by port drag');
}
// --- simulation ---------------------------------------------------------
await page.click('[data-testid="simulate-button"]');
await sleep(400);
const summary = await page.locator('[data-testid="sim-summary"]').count();
if (linkCount >= 1 && summary === 1) {
const text = await page.locator('[data-testid="sim-summary"]').innerText();
ok(`simulation runs: ${text.trim()}`);
const dash = await page.evaluate(() => {
const path = document.querySelector('.joint-link path[joint-selector="line"]');
return path ? path.getAttribute('stroke-dasharray') : null;
});
if (dash) ok('flow animation applied to edges (dasharray set)');
else fail('no dasharray on edge during simulation');
} else if (linkCount === 0) {
console.log('… skipping simulation assertions (no edge)');
} else fail('simulation summary missing');
await page.click('[data-testid="simulate-button"]'); // stop
// --- undo ---------------------------------------------------------------
await page.keyboard.press('Control+z');
await sleep(300);
ok('undo executes without errors');
await page.screenshot({ path: 'e2e/smoke.png', fullPage: true });
ok('screenshot saved to e2e/smoke.png');
const realErrors = consoleErrors.filter((e) => !e.includes('favicon'));
if (realErrors.length === 0) ok('no console errors');
else fail(`console errors:\n${realErrors.join('\n')}`);
await browser.close();
if (devServer) process.kill(-devServer.pid);
console.log(process.exitCode ? 'SMOKE TEST FAILED' : 'SMOKE TEST PASSED');
}
main().catch((e) => {
console.error(e);
if (devServer) process.kill(-devServer.pid);
process.exit(1);
});