/** * Web 管理端 UI 冒烟测试(Electron 驱动真实浏览器内核)。 * * 覆盖: * 1. 首页加载(生产构建产物) * 2. 登录页渲染与真实表单登录 * 3. 进入主布局 * 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染 * —— 这是"点击左侧边栏没反应"的回归测试 * 5. **上课时段发布紧急通知**:全屏警告 + 3 秒倒计时 + 确认后才真正发布 * —— 上课时段由 run.mjs 的探针(今天 00:00-23:59 的课表)真实制造 * 6. 前端路由深链直接访问 * 7. PWA manifest + Service Worker(浏览器可安装为应用) * * 用法(需要后端已启动,且 Web 管理端由其托管): * UI_SMOKE_URL=http://127.0.0.1:4000/ node scripts/ui-smoke/run.mjs */ const fs = require('node:fs'); const path = require('node:path'); const { app, BrowserWindow } = require('electron'); // 使用独立(每次清空)的 userData,避免 Service Worker 缓存影响验证结果 if (process.env.UI_SMOKE_PROFILE) { app.setPath('userData', process.env.UI_SMOKE_PROFILE); } const TARGET_URL = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/'; const USERNAME = process.env.UI_SMOKE_USER ?? 'teacher1'; const PASSWORD = process.env.UI_SMOKE_PASS ?? 'teacher123'; const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? ''; /** 侧边栏菜单:显示文案 -> 期望路由 */ const MENU_ITEMS = [ { label: '仪表盘', path: '/dashboard' }, { label: '班级管理', path: '/classes' }, { label: '学生管理', path: '/students' }, { label: '课表管理', path: '/schedules' }, { label: '作业发布', path: '/homeworks' }, { label: '通知发布', path: '/notifications' }, { label: '成绩录入', path: '/grades' }, ]; const results = []; function record(name, ok, detail = '') { results.push({ name, ok, detail }); console.log(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ` - ${detail}` : ''}`); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 在渲染进程里轮询等待条件成立 */ async function waitFor(win, expression, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const ok = await win.webContents.executeJavaScript( `(() => { try { return Boolean(${expression}); } catch { return false; } })()`, ); if (ok) return true; await sleep(150); } return false; } async function main() { const win = new BrowserWindow({ width: 1440, height: 900, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false }, }); // 把渲染进程的报错转发到控制台(兼容 Electron 新旧事件签名) win.webContents.on('console-message', (...args) => { const first = args[0]; const isEventObject = first && typeof first === 'object' && 'message' in first; const level = isEventObject ? first.level : args[1]; const message = isEventObject ? first.message : args[2]; // level: 旧版为 0..3 数字,新版为 'info' | 'warning' | 'error' | 'debug' const isProblem = level === 'error' || level === 'warning' || level === 2 || level === 3; if (isProblem) console.log(`[renderer] ${String(message)}`); }); console.log(`[ui-smoke] 打开 ${TARGET_URL}`); await win.loadURL(TARGET_URL); // 1. 首页 const title = await win.webContents.executeJavaScript('document.title'); record('Web 管理端首页加载', typeof title === 'string' && title.includes('班级小助手'), `title=${title}`); // 2. 登录页 const loginReady = await waitFor(win, `document.querySelectorAll('input').length >= 2`, 15000); record('登录页渲染(用户名/密码输入框)', loginReady); // 3. 填写表单并点击登录(用原生 setter + input 事件,保证 Vue 双向绑定生效) await win.webContents.executeJavaScript(`(() => { const inputs = Array.from(document.querySelectorAll('input')); const setValue = (element, value) => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; setter.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }; setValue(inputs[0], ${JSON.stringify(USERNAME)}); const passwordInput = inputs.find((item) => item.type === 'password') ?? inputs[1]; setValue(passwordInput, ${JSON.stringify(PASSWORD)}); return true; })()`); await win.webContents.executeJavaScript(`(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => (item.textContent ?? '').includes('登录')); if (!button) return false; button.click(); return true; })()`); const layoutReady = await waitFor( win, `document.querySelectorAll('.el-menu-item').length >= ${MENU_ITEMS.length}`, 20000, ); record( '真实表单登录并进入主布局', layoutReady, `菜单项=${await win.webContents.executeJavaScript(`document.querySelectorAll('.el-menu-item').length`)}`, ); if (!layoutReady) { await finish(win); return; } // 4. 逐一点击侧边栏菜单 const visited = []; const failures = []; for (const item of MENU_ITEMS) { const outcome = await win.webContents.executeJavaScript(`(async () => { const label = ${JSON.stringify(item.label)}; const expected = ${JSON.stringify(item.path)}; const items = Array.from(document.querySelectorAll('.el-menu-item')); const element = items.find((node) => (node.textContent ?? '').trim().startsWith(label)); if (!element) return { ok: false, path: location.pathname, title: '', reason: '菜单项未找到' }; element.click(); const deadline = Date.now() + 4000; while (Date.now() < deadline) { if (location.pathname === expected) break; await new Promise((resolve) => setTimeout(resolve, 60)); } // 等视图渲染 const viewDeadline = Date.now() + 3000; while (Date.now() < viewDeadline) { if (document.querySelector('.page-title')) break; await new Promise((resolve) => setTimeout(resolve, 60)); } const title = (document.querySelector('.page-title')?.textContent ?? '').trim(); return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected }; })()`); visited.push(`${item.label}→${outcome.path}${outcome.title ? `(${outcome.title})` : ''}`); if (!outcome.ok) failures.push( `${item.label}(期望 ${item.path} 实际 ${outcome.path}${outcome.reason ? ` ${outcome.reason}` : ''})`, ); } record( `侧边栏点击导航(逐一点击 ${MENU_ITEMS.length} 个菜单)`, failures.length === 0, `visited=[${visited.join(' ')}]${failures.length > 0 ? ` failures=${failures.join(';')}` : ''}`, ); // 5. 上课时段发布紧急通知:全屏警告 + 3 秒倒计时 + 教师确认后才真正发布 // "上课时段"由 run.mjs 的探针提前用 HTTP 建好(今天 00:00-23:59 的课表) const urgentTitle = process.env.UI_SMOKE_URGENT_TITLE ?? 'UI 冒烟紧急通知'; const inClass = process.env.UI_SMOKE_INCLASS === '1'; if (!inClass) { record( '上课时段发布紧急通知(全屏警告 + 3 秒倒计时)', false, `未能制造上课时段:${process.env.UI_SMOKE_PROBE_DETAIL || '未知原因'}`, ); } else { const urgent = await win.webContents.executeJavaScript(`(async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const waitFor = async (check, timeout = 8000) => { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const value = check(); if (value) return value; await sleep(80); } return null; }; const byText = (nodes, text) => Array.from(nodes).find((node) => (node.textContent ?? '').trim().includes(text)); const setValue = (element, value) => { const proto = element.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }; const formItem = (dialog, label) => Array.from(dialog.querySelectorAll('.el-form-item')).find((item) => (item.querySelector('.el-form-item__label')?.textContent ?? '').trim().startsWith(label), ); const menu = byText(document.querySelectorAll('.el-menu-item'), '通知发布'); if (!menu) return { ok: false, detail: '侧边栏未找到"通知发布"菜单' }; menu.click(); if (!(await waitFor(() => location.pathname === '/notifications'))) return { ok: false, detail: '未跳转到 /notifications' }; const openButton = await waitFor(() => byText(document.querySelectorAll('button'), '发布通知')); if (!openButton) return { ok: false, detail: '未找到"发布通知"按钮' }; openButton.click(); const dialog = await waitFor(() => document.querySelector('.el-dialog')); if (!dialog) return { ok: false, detail: '发布通知弹窗未打开' }; const titleItem = formItem(dialog, '标题'); const contentItem = formItem(dialog, '内容'); if (!titleItem || !contentItem) return { ok: false, detail: '弹窗表单缺少标题/内容字段' }; setValue(titleItem.querySelector('input'), ${JSON.stringify(urgentTitle)}); setValue(contentItem.querySelector('textarea'), '自动化校验:本节课紧急通知二次确认流程'); const urgentRadio = byText(dialog.querySelectorAll('.el-radio-button'), '紧急'); if (!urgentRadio) return { ok: false, detail: '未找到"紧急"优先级选项' }; (urgentRadio.querySelector('.el-radio-button__inner') ?? urgentRadio).click(); const radioActive = await waitFor(() => { const node = byText(dialog.querySelectorAll('.el-radio-button'), '紧急'); return Boolean(node && node.classList.contains('is-active')); }); if (!radioActive) return { ok: false, detail: '"紧急"优先级未选中' }; const publish = byText(dialog.querySelectorAll('.el-dialog__footer button'), '立即发布'); if (!publish) return { ok: false, detail: '未找到"立即发布"按钮' }; publish.click(); const mask = await waitFor(() => document.querySelector('.urgent-mask'), 10000); if (!mask) return { ok: false, detail: '上课时段发布紧急通知未弹出全屏警告' }; const maskText = (mask.textContent ?? '').replace(/\\s+/g, ' ').trim(); const periodText = (mask.querySelector('.urgent-period .period-value')?.textContent ?? '').trim(); const dangerButton = mask.querySelector('.urgent-actions button.el-button--danger'); if (!dangerButton) return { ok: false, detail: '全屏警告缺少确认按钮' }; const disabledAtStart = dangerButton.disabled === true; const startText = (dangerButton.textContent ?? '').trim(); const startedAt = Date.now(); const enabled = await waitFor(() => { const node = document.querySelector('.urgent-mask .urgent-actions button.el-button--danger'); return Boolean(node && node.disabled !== true); }, 9000); const countdownMs = Date.now() - startedAt; if (!enabled) return { ok: false, detail: '倒计时结束后确认按钮仍未可用' }; document.querySelector('.urgent-mask .urgent-actions button.el-button--danger').click(); const closed = await waitFor(() => !document.querySelector('.urgent-mask'), 6000); const listed = await waitFor( () => Array.from(document.querySelectorAll('.el-table__body td')).some((cell) => (cell.textContent ?? '').includes(${JSON.stringify(urgentTitle)}), ), 10000, ); return { ok: Boolean(closed && listed), detail: closed ? listed ? '' : '确认后警告已关闭,但通知列表未出现该通知' : '点击确认后全屏警告未关闭', maskText, periodText, disabledAtStart, startText, countdownMs, }; })()`); record( '上课时段发布紧急通知(全屏警告 + 3 秒倒计时)', Boolean(urgent?.ok) && Boolean(urgent?.maskText?.includes('现在为上课时间段')) && Boolean(urgent?.periodText) && urgent?.disabledAtStart === true && Number(urgent?.countdownMs) >= 1500, urgent ? `警告文案="${urgent.maskText?.slice(0, 60)}…" 上课时段=${urgent.periodText} ` + `初始按钮="${urgent.startText}"(禁用=${urgent.disabledAtStart}) ` + `倒计时=${urgent.countdownMs}ms${urgent.detail ? ` 问题=${urgent.detail}` : ''}` : '未执行', ); } // 6. SPA 深链刷新(生产环境静态托管必须支持) await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/grades`); const deepLinkOk = await waitFor( win, `document.querySelectorAll('.el-menu-item').length >= ${MENU_ITEMS.length}`, 15000, ); record( '前端路由深链直接访问(/grades 刷新可用)', deepLinkOk, `path=${await win.webContents.executeJavaScript('location.pathname')}`, ); // 7. PWA 可安装性:manifest 可获取 + Service Worker 已注册 // 注意:注入代码里不要使用 ${} 插值,避免与宿主文件的模板字符串冲突 const manifestInfo = await win.webContents.executeJavaScript(`(async () => { const link = document.querySelector('link[rel="manifest"]'); if (!link) return { ok: false, detail: '缺少 manifest 链接' }; const response = await fetch(link.getAttribute('href')); const json = await response.json().catch(() => null); const ok = response.ok && Boolean(json && json.name); return { ok, detail: String(response.status) + ' name=' + (json && json.name ? json.name : '-') + ' icons=' + (json && json.icons ? json.icons.length : 0) }; })()`); record('PWA manifest 可获取', Boolean(manifestInfo?.ok), String(manifestInfo?.detail ?? '')); const swInfo = await win.webContents.executeJavaScript(`(async () => { if (!('serviceWorker' in navigator)) return { ok: false, detail: '不支持 Service Worker' }; const registrations = await navigator.serviceWorker.getRegistrations(); return { ok: registrations.length > 0, detail: 'registrations=' + registrations.length }; })()`); record('Service Worker 已注册(浏览器可安装为应用)', Boolean(swInfo?.ok), String(swInfo?.detail ?? '')); await finish(win); } async function finish(win) { const failed = results.filter((item) => !item.ok); const passed = results.length - failed.length; console.log(`\n[ui-smoke] ${passed}/${results.length} 项通过`); if (RESULT_FILE) { try { fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true }); fs.writeFileSync( RESULT_FILE, JSON.stringify( { passed, total: results.length, ok: failed.length === 0, results, target: TARGET_URL }, null, 2, ), 'utf8', ); console.log(`[ui-smoke] 结果已写入 ${RESULT_FILE}`); } catch (error) { console.error('[ui-smoke] 写入结果失败', error); } } win.destroy(); app.exit(failed.length === 0 ? 0 : 1); } app.whenReady().then(() => main().catch(async (error) => { record('UI 冒烟执行异常', false, String(error)); console.error(error); app.exit(1); }), ); app.on('window-all-closed', () => app.quit());