feat: 生产化改造(服务端安装程序/PWA/Docker+HTTPS)并修复侧边栏点击无反应、SPA 静态托管 dotfiles 两个缺陷
- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用 - 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500 - 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、 /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件 - Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用 - 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据) - 新增 Docker/Compose/Nginx 部署样例与 docs/production.md - 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
This commit is contained in:
1 parent
6e4c7c1050
commit
5545ef6f55
46 files changed
+3020
-152
No files matched your search
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Web 管理端 UI 冒烟测试(Electron 驱动真实浏览器内核)。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 首页加载(生产构建产物)
|
||||
* 2. 登录页渲染与真实表单登录
|
||||
* 3. 进入主布局
|
||||
* 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染
|
||||
* —— 这是"点击左侧边栏没反应"的回归测试
|
||||
*
|
||||
* 用法(需要后端已启动,且 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. 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')}`,
|
||||
);
|
||||
|
||||
// 6. 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());
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Web 管理端 UI 冒烟测试启动器。
|
||||
*
|
||||
* 复用 desktop-client 里已安装的 Electron 运行时(无需额外依赖),
|
||||
* 以「真实浏览器内核 + 真实点击」的方式验证 Web 管理端。
|
||||
*
|
||||
* 用法:
|
||||
* 1) 启动后端(同时托管 Web 管理端):pnpm dev:server 或生产模式 node dist/index.js
|
||||
* 2) pnpm verify:web
|
||||
*
|
||||
* 环境变量:
|
||||
* UI_SMOKE_URL 默认 http://127.0.0.1:4000/
|
||||
* UI_SMOKE_USER 默认 teacher1
|
||||
* UI_SMOKE_PASS 默认 teacher123
|
||||
* UI_SMOKE_RESULT 结果 JSON 输出路径(可选)
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||
|
||||
if (!fs.existsSync(electronDir)) {
|
||||
console.error('[ui-smoke] 未找到 Electron(packages/desktop-client/node_modules/electron)');
|
||||
console.error(' 请先执行:pnpm install');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const pathFile = path.join(electronDir, 'path.txt');
|
||||
const executable = fs.existsSync(pathFile)
|
||||
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
||||
: path.join(electronDir, 'dist', 'electron.exe');
|
||||
|
||||
if (!fs.existsSync(executable)) {
|
||||
console.error(`[ui-smoke] Electron 可执行文件不存在:${executable}`);
|
||||
console.error(' 请执行:node packages/desktop-client/node_modules/electron/install.js');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const entry = path.join(root, 'scripts', 'ui-smoke', 'main.cjs');
|
||||
const target = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/';
|
||||
const resultFile = process.env.UI_SMOKE_RESULT ?? path.join(root, '.cache', 'ui-smoke-result.json');
|
||||
const profileDir = path.join(root, '.cache', 'ui-smoke-profile');
|
||||
|
||||
// 每次运行前清空浏览器配置目录,避免 Service Worker / 缓存影响结果
|
||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`[ui-smoke] 目标:${target}`);
|
||||
console.log(`[ui-smoke] Electron:${executable}`);
|
||||
|
||||
const child = spawn(executable, [entry], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
UI_SMOKE_URL: target,
|
||||
UI_SMOKE_RESULT: resultFile,
|
||||
UI_SMOKE_PROFILE: profileDir,
|
||||
},
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
Reference in new issue
Block a user