Files
OpenOPC/tests/e2e/canvas_smoke.mjs
2026-07-01 17:56:31 +08:00

192 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E smoke test for the Canvas / Inspector / Table redesign (Q1Q6).
*
* 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) })