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>
173 lines
6.8 KiB
JavaScript
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);
|
|
});
|