Files
ClassHelper/scripts/ui-smoke/main.cjs
T

372 lines
16 KiB
JavaScript
Raw Normal View History

/**
* 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());