/** * E2E smoke test for the Canvas / Inspector / Table redesign (Q1–Q6). * * Launches a real OPC server on port 18766, loads the UI in headless * Chromium via Playwright, and asserts: * * 1. .se-container mounts * 2. Canvas view is default; @xyflow/react root has non-zero height * 3. Canvas renders N nodes matching loaded roles + 1 owner node * 4. Clicking a canvas node opens the Inspector panel with correct role data * 5. Switching to Table view shows rows matching role count * 6. WS org_config_export returns non-empty YAML (regression guard) * 7. Keyboard shortcut: Escape closes Inspector * 8. No page console errors throughout the run * * Usage: * node tests/e2e/canvas_smoke.mjs * * Prerequisites: * - OPC installed (opc.cli.app importable) * - Frontend built (opc/plugins/office_ui/frontend_dist populated) * - @playwright/test + ws in frontend_src/node_modules * * Exit code 0 on PASS, non-zero on any failure. */ import { spawn } from 'node:child_process' import { setTimeout as delay } from 'node:timers/promises' import fs from 'node:fs' import path from 'node:path' const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..') const PLAYWRIGHT = `${REPO_ROOT}/opc/plugins/office_ui/frontend_src/node_modules/playwright/index.mjs` const WS_LIB = `${REPO_ROOT}/opc/plugins/office_ui/frontend_src/node_modules/ws/wrapper.mjs` const PORT = 18766 const CONFIG = `${REPO_ROOT}/config/company_corporate_config.yaml` const BACKUP = `/tmp/cc_canvas_smoke_backup.yaml` /* ── Utilities ────────────────────────────────────────────────── */ function assert(cond, msg) { if (cond) { console.log(` ✓ ${msg}`); return } console.error(` ✗ FAIL: ${msg}`) process.exit(1) } async function waitFor(cond, { tries = 50, intervalMs = 200 } = {}) { for (let i = 0; i < tries; i++) { if (await cond()) return true await delay(intervalMs) } return false } /* ── Main ─────────────────────────────────────────────────────── */ async function main() { // 1. Snapshot config fs.copyFileSync(CONFIG, BACKUP) console.log(`[1/8] Config snapshotted → ${BACKUP}`) // 2. Boot OPC const server = spawn('python3', ['-m', 'opc.cli.app', 'ui', '--port', String(PORT), '--host', '127.0.0.1'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], }) let serverLog = '' server.stdout.on('data', (d) => { serverLog += d.toString() }) server.stderr.on('data', (d) => { serverLog += d.toString() }) const ready = await waitFor(() => Promise.resolve(serverLog.includes('Office-UI running')), { tries: 120 }) if (!ready) { console.error('OPC failed to start:\n' + serverLog.slice(-2000)) server.kill() process.exit(2) } console.log('[2/8] OPC ready') let exitCode = 0 try { // 3. Switch mode to company/corporate (has 11 built-in roles to render) const { default: WebSocket } = await import(WS_LIB) await new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`) const t = setTimeout(() => { ws.close(); reject(new Error('mode switch timeout')) }, 10000) ws.on('open', () => ws.send(JSON.stringify({ type: 'set_execution_mode', mode: 'company', profile: 'corporate' }))) ws.on('message', () => { clearTimeout(t); setTimeout(() => { ws.close(); resolve() }, 600) }) ws.on('error', reject) }) console.log('[3/8] Switched to company/corporate mode') // 4. Launch browser const { chromium } = await import(PLAYWRIGHT) const browser = await chromium.launch({ headless: true }) const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }) const consoleErrors = [] page.on('pageerror', (err) => consoleErrors.push(err.message)) page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push('[console] ' + msg.text()) }) await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle', timeout: 30000 }) await page.waitForSelector('#root > *', { timeout: 15000 }) await delay(2500) console.log('[4/8] SPA loaded') // 5. Navigate to Organization → Team (default) const orgBtn = page.locator('button:has-text("Organization"), button:has-text("Org")').first() if (await orgBtn.count()) { await orgBtn.click(); await delay(500) } await delay(1500) // let Canvas finish mount // 6. Canvas assertions assert(await page.locator('.se-container').count() === 1, 'StructureEditor container mounted') const rfSize = await page.locator('.react-flow').first().evaluate((el) => { const r = el.getBoundingClientRect() return { w: Math.round(r.width), h: Math.round(r.height) } }) assert(rfSize.h > 200, `Canvas has non-zero height (measured ${rfSize.w}×${rfSize.h})`) const nodeCount = await page.locator('.oc-canvas-node').count() assert(nodeCount >= 10, `Canvas renders ≥10 nodes (owner + roles); got ${nodeCount}`) // 7. Canvas view is default const canvasActive = await page.locator('.se-view-btn:has-text("Canvas")').first().getAttribute('aria-selected') assert(canvasActive === 'true', 'Canvas view active by default') console.log('[5/8] Canvas renders correctly') // 8. Click a non-owner canvas node → Inspector opens const nonOwner = page.locator('.oc-canvas-node:not(.is-owner)').first() await nonOwner.click() await delay(600) const inspectorCount = await page.locator('.ri-panel').count() assert(inspectorCount === 1, 'Clicking a node opens Inspector') const title = (await page.locator('.ri-panel-title').first().textContent())?.trim() ?? '' assert(title.length > 0, `Inspector shows role name (got "${title}")`) console.log(`[6/8] Inspector opens with role "${title}"`) // 9. Keyboard: Escape closes Inspector await page.keyboard.press('Escape') await delay(400) assert(await page.locator('.ri-panel').count() === 0, 'Escape closes Inspector') // 10. Switch to Table view await page.locator('.se-view-btn:has-text("Table")').first().click() await delay(700) const rowCount = await page.locator('.rt-row').count() assert(rowCount >= 5, `Table view shows ≥5 rows (got ${rowCount})`) console.log(`[7/8] Table view shows ${rowCount} rows`) // 11. WS regression: export should still work const exportPayload = await new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`) const t = setTimeout(() => { ws.close(); reject(new Error('export timeout')) }, 8000) ws.on('open', () => ws.send(JSON.stringify({ type: 'org_config_export' }))) ws.on('message', (buf) => { const msg = JSON.parse(buf.toString()) if (msg.type === 'org_config_export') { clearTimeout(t); ws.close() resolve(msg.payload?.yaml ?? '') } }) ws.on('error', reject) }) assert(exportPayload.length > 50, `WS org_config_export returns YAML (${exportPayload.length} chars)`) // 12. Console errors assert(consoleErrors.length === 0, `No page console errors (got ${consoleErrors.length})`) if (consoleErrors.length > 0) { console.error(' Errors:', consoleErrors.slice(0, 5).join(' | ')) } console.log('[8/8] All assertions passed') await browser.close() } catch (err) { console.error('TEST ERROR:', err.stack || err.message) exitCode = 3 } finally { server.kill() await delay(800) // Restore config if mutated try { const now = fs.readFileSync(CONFIG, 'utf8') const backup = fs.readFileSync(BACKUP, 'utf8') if (now !== backup) { fs.writeFileSync(CONFIG, backup, 'utf8') console.log('Config restored from backup') } } catch (_) { /* ignore */ } console.log(exitCode === 0 ? '\n✅ PASS' : `\n❌ FAIL (exit ${exitCode})`) process.exit(exitCode) } } main().catch((err) => { console.error(err); process.exit(99) })