PayrollManagementSystem/frontend/e2e/monitor.mjs
liuwenxin a2f9a58de5 feat(前端): 配套授权、监控与同步进度页面
- 角色授权:权限矩阵抽屉与角色创建,按模块分组勾选
- 系统监控:总览、趋势、接口与事件页,15 秒自动刷新,
  请求可中断、窗口切换暂停,详情抽屉支持导出
- T+ 同步:同步进度条组件与工作台状态拆分
- 工资:试用期面板、产品提取弹窗与无权限占位页
- 路由权限:按权限码控制菜单与页面可达性

影响范围:前端全部页面
验证:随本次后端联调本地人工核对,e2e 脚本入库待跑
2026-11-03 11:21:19 +08:00

126 lines
11 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.

/** 合成数据验证监控交互和故障状态,不登录真实账号、不写业务数据。 */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const base = process.env.MONITOR_UI_URL || 'http://127.0.0.1:5193'
const output = process.env.MONITOR_UI_OUTPUT || '.ai'
fs.mkdirSync(output, { recursive: true })
const browser = await chromium.launch({ headless: true, channel: 'msedge' })
const context = await browser.newContext({ viewport: { width: 1366, height: 1050 }, permissions: ['clipboard-read', 'clipboard-write'] })
const page = await context.newPage()
const errors = [], requests = [], checks = []
let failed = false, role = 'admin', empty = false
const at = '2026-11-02T10:00:00', id = 'a'.repeat(32)
const metric = { count: 48, errors_5xx: 3, errors_4xx: 2, error_rate: 6.25, p95_ms: 1200, max_ms: 2600, last_error_at: at }
const incident = { id: 1, instance_id: 'synthetic-instance', kind: 'HEALTH', code: 'WORKER_OFFLINE', component: 'tplus', level: 'warning', message: '采集进程心跳中断,请检查托管任务', request_id: null, method: null, route: null, status_code: null, duration_ms: null, batch_no: 'SYNTHETIC-BATCH', first_seen: at, last_seen: at, occurrences: 4, resolved_at: null }
const event = { ...incident, id: 2, kind: 'REQUEST_ERROR', code: 'HTTP_5XX', component: 'http', level: 'error', message: '接口处理异常,请按请求编号检索服务端日志。', request_id: id, method: 'GET', route: '/api/v1/base/products/{id}', status_code: 500, duration_ms: 2600, occurrences: 1, batch_no: null }
page.on('pageerror', error => errors.push(error.message))
await page.clock.install({ time: new Date('2026-11-02T10:00:00+08:00') })
await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-monitor-browser-test'))
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname
requests.push({ path, query: Object.fromEntries(url.searchParams) })
assert.equal(request.method(), 'GET')
if (path.endsWith('/auth/me')) return route.fulfill({ json: { code: 200, message: 'success', data: { id: 1, username: 'synthetic', real_name: '监控页面验证', roles: [role], permissions: role === 'admin' ? ['system:monitor:view', 'tplus:view', 'dashboard:view'] : ['base:products:view'], is_payroll_maker: false, can_write_payroll: false, payroll_approval_enabled: false } } })
if (path.includes('/monitor/') && failed) return route.fulfill({ status: 503, json: { code: 503, message: '合成连接故障', data: null } })
let data = {}
if (path.endsWith('/monitor/overview')) data = {
status: empty ? 'unknown' : 'warning', environment: '合成演练', version: '0.1.0', instance_id: 'synthetic-instance', uptime_seconds: 12345,
collected_at: empty ? null : at, last_persisted_at: empty ? null : at, collection_status: empty ? 'unknown' : 'normal', collection_message: empty ? '等待首次采集' : '采集正常', dropped_events: 0,
resources: empty ? {} : { cpu_percent: 32.8, memory_percent: 48.2, disk_percent: 55.4, disk_free_gb: 124.5, process_cpu_percent: 2.3, process_memory_mb: 185, scope: '合成数据,仅用于页面验证' },
requests: empty ? { count: 0, error_rate: 0, p95_ms: null, max_ms: 0, errors_4xx: 0, errors_5xx: 0, last_error_at: null } : metric,
dependencies: empty ? [] : ['应用', 'MySQL', 'Redis', 'T+ 采集进程'].map(name => ({ name, status: name.startsWith('T+') ? 'warning' : 'normal', message: name.startsWith('T+') ? '心跳中断,请查看同步任务。' : '连接正常。', checked_at: at, duration_ms: 2 })),
alerts: empty ? [] : [incident], tasks: { status: 'warning', message: '存在采集异常,请查看相关任务。', enabled: true, worker_phase: 'STOPPED', heartbeat_at: at, last_success_at: at, next_retry_at: at, alerts: [{ code: 'WORKER_OFFLINE', message: incident.message, batch_no: incident.batch_no }] },
}
else if (path.endsWith('/monitor/trends')) data = { step_minutes: 1, points: empty ? [] : Array.from({ length: 60 }, (_, i) => ({ at: `2026-11-02T09:${String(i).padStart(2, '0')}:00`, sample_count: i === 25 ? 0 : 4, cpu_percent: i === 25 ? null : 25 + i % 13, memory_percent: i === 25 ? null : 42 + i % 8, disk_percent: 55, request_count: i === 25 ? null : i % 9 + 1, error_rate: i === 25 ? null : i % 17 === 0 ? 5 : 0, p95_ms: i === 25 ? null : 100 + i % 7 * 100 })) }
else if (path.endsWith('/monitor/interfaces')) data = { list: empty ? [] : [{ method: event.method, route: event.route, ...metric }], truncated: false }
else if (path.endsWith('/monitor/events')) data = { list: empty ? [] : [event], total: empty ? 0 : 1, pageNum: 1, pageSize: 20 }
else if (/\/monitor\/events\/\d+$/.test(path)) data = path.endsWith('/1') ? incident : event
else if (path.endsWith('/monitor/diagnostic')) return route.fulfill({ headers: { 'content-disposition': 'attachment; filename="monitor.json"' }, json: { generated_at: at, note: '脱敏诊断摘要' } })
else if (path.endsWith('/tplus/overview')) data = { is_configured: false, configuration_message: '合成任务定位验证', status_counts: {}, total_staged_records: 0, latest_audit: null }
else if (path.endsWith('/tplus/batches/SYNTHETIC-BATCH')) data = { batch_no: 'SYNTHETIC-BATCH', status: 'FAILED', task_type: 'READ', create_time: at, can_retry: false }
else data = { list: [], total: 0, pageNum: 1, pageSize: 20 }
return route.fulfill({ json: { code: 200, message: 'success', data } })
})
try {
await page.goto(`${base}/system/monitor`)
await page.getByRole('heading', { name: '系统监控', exact: true }).waitFor()
await page.getByText('合成演练', { exact: true }).waitFor()
await page.getByText(incident.message, { exact: true }).waitFor()
await page.clock.runFor(600)
for (const width of [1366, 1920, 768]) {
await page.setViewportSize({ width, height: 1050 })
await page.screenshot({ path: `${output}/monitor-ui-${width}.png`, fullPage: true })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `页面横向溢出 ${width}`)
}
checks.push('1366/1920/768 布局、真实图表渲染与无横向溢出')
await page.setViewportSize({ width: 1366, height: 1050 })
await page.getByRole('tab', { name: '接口性能', exact: true }).click()
await page.getByRole('button', { name: '查看异常', exact: true }).click()
await page.getByRole('button', { name: '详情', exact: true }).click()
await page.getByRole('heading', { name: '诊断事件详情', exact: true }).waitFor()
await page.getByRole('button', { name: '复制请求编号', exact: true }).click()
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), id)
await page.screenshot({ path: `${output}/monitor-event-detail.png`, fullPage: true })
await page.getByRole('button', { name: '关闭此对话框' }).click()
checks.push('接口排行联动异常列表、抽屉详情与请求编号复制')
await page.getByRole('button', { name: '诊断摘要', exact: true }).click()
await page.getByRole('tab', { name: '任务与同步', exact: true }).click()
await page.getByRole('button', { name: '查看相关批次', exact: true }).click()
await page.waitForURL('**/system/tplus?batch=SYNTHETIC-BATCH')
await page.getByRole('dialog').waitFor()
checks.push('诊断摘要下载、T+ 批次跳转与原详情打开')
await page.goto(`${base}/system/monitor`)
await page.getByText('合成演练', { exact: true }).waitFor()
await Promise.all([page.waitForResponse(response => response.url().includes('/monitor/trends') && response.url().includes('hours=24')),
page.getByText('24 小时', { exact: true }).click()])
checks.push('历史时间窗口筛选')
failed = true
await page.getByRole('button', { name: '刷新', exact: true }).click()
await page.getByText('监控连接失败,请检查服务或稍后刷新。', { exact: true }).waitFor()
assert.equal(await page.locator('.el-message--error').count(), 0, '轮询故障不应重复弹窗')
assert.ok(await page.getByText('合成演练', { exact: true }).isVisible(), '应保留上次成功状态')
checks.push('故障保留上次状态、明确未知且不弹窗轰炸')
failed = false; empty = true
await page.getByRole('button', { name: '刷新', exact: true }).click()
await page.getByText('当前状态尚不能确认', { exact: true }).waitFor()
assert.equal(await page.getByText('当前未发现活动异常', { exact: true }).count(), 0)
checks.push('初次无数据与正常状态明确区分')
const count = () => requests.filter(item => item.path.endsWith('/monitor/overview')).length
await page.locator('.toolbar .el-switch').click()
assert.equal(await page.getByRole('switch', { name: '自动刷新监控' }).isChecked(), false)
const disabledCount = count()
await page.clock.runFor(31000)
assert.equal(count(), disabledCount)
await page.locator('.toolbar .el-switch').click()
await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); document.dispatchEvent(new Event('visibilitychange')) })
const hiddenCount = count()
await page.clock.runFor(46000)
assert.equal(count(), hiddenCount)
await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); document.dispatchEvent(new Event('visibilitychange')) })
const resumed = page.waitForRequest(request => request.url().includes('/monitor/overview'))
await page.clock.runFor(16000); await resumed
checks.push('关闭自动刷新、隐藏页面暂停、恢复可见后继续采集视图')
await page.getByRole('menuitem', { name: 'T+ 数据同步', exact: true }).click()
const leftCount = count()
await page.clock.runFor(46000)
assert.equal(count(), leftCount)
checks.push('离开监控页停止轮询')
role = 'user'
await page.goto(`${base}/system/monitor`)
await page.waitForURL('**/base/products')
assert.equal(await page.getByRole('heading', { name: '系统监控', exact: true }).count(), 0)
checks.push('普通用户直接访问被拒绝,菜单不暴露监控入口')
assert.deepEqual(errors, [])
fs.writeFileSync(`${output}/monitor-browser-result.json`, JSON.stringify({ checks, errors, requests: requests.length }, null, 2))
console.log(JSON.stringify({ checks, errors, requests: requests.length }))
} catch (error) {
await page.screenshot({ path: `${output}/monitor-browser-failure.png`, fullPage: true })
fs.writeFileSync(`${output}/monitor-browser-failure.txt`, String(error) + '\n' + JSON.stringify({ errors, requests, url: page.url() }) + '\n' + await page.locator('body').innerText())
throw error
} finally { await browser.close() }