2026-09-12 00:20:21 +08:00
|
|
|
/**
|
|
|
|
|
* Web 管理端 UI 冒烟测试(Electron 驱动真实浏览器内核)。
|
|
|
|
|
*
|
|
|
|
|
* 覆盖:
|
|
|
|
|
* 1. 首页加载(生产构建产物)
|
|
|
|
|
* 2. 登录页渲染与真实表单登录
|
|
|
|
|
* 3. 进入主布局
|
|
|
|
|
* 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染
|
|
|
|
|
* —— 这是"点击左侧边栏没反应"的回归测试
|
2026-09-12 08:41:08 +08:00
|
|
|
* 5. **上课时段发布紧急通知**:全屏警告 + 3 秒倒计时 + 确认后才真正发布
|
|
|
|
|
* —— 上课时段由 run.mjs 的探针(今天 00:00-23:59 的课表)真实制造
|
|
|
|
|
* 6. 前端路由深链直接访问
|
|
|
|
|
* 7. PWA manifest + Service Worker(浏览器可安装为应用)
|
2026-09-12 00:20:21 +08:00
|
|
|
*
|
|
|
|
|
* 用法(需要后端已启动,且 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(';')}` : ''}`,
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-12 08:41:08 +08:00
|
|
|
// 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 深链刷新(生产环境静态托管必须支持)
|
2026-09-12 00:20:21 +08:00
|
|
|
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')}`,
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-12 08:41:08 +08:00
|
|
|
// 7. PWA 可安装性:manifest 可获取 + Service Worker 已注册
|
2026-09-12 00:20:21 +08:00
|
|
|
// 注意:注入代码里不要使用 ${} 插值,避免与宿主文件的模板字符串冲突
|
|
|
|
|
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());
|