/** * 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); });