Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
/**
* 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) })