641 lines
34 KiB
JavaScript
641 lines
34 KiB
JavaScript
/* 前端行为验证(jsdom + 真实后端)
|
||
用法:node verify/frontend_check.js [base]
|
||
|
||
设计要点:
|
||
- 相对路径 fetch 打到真实后端,所以这是货真价实的端到端,不是 mock。
|
||
- 必须等 app.js 执行完(win.doLogin 成为函数)才点按钮,否则是测试自己的竞态。
|
||
- 记录每个 code!=0 的响应,用来抓住被 try/catch 吞掉的页面级错误。
|
||
- 只读账号段落用的是 getComputedStyle 算出来的 display,不是类名 ——
|
||
类名只是手段,"用户看不见"才是目的。
|
||
|
||
收尾:本脚本会走完整 UI 新增/删除一个学生,删除是逻辑删除(这是被测功能的
|
||
正确行为),所以跑完库里会多一行 is_del=1。想清掉就执行
|
||
`python verify/purge_selftest.py --yes`。
|
||
*/
|
||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||
|
||
const BASE = process.argv[2] || 'http://127.0.0.1:8010';
|
||
const PASS = [];
|
||
const FAIL = [];
|
||
const errors = []; // 未捕获 JS 异常
|
||
const apiErrors = []; // 后端返回 code != 0 的请求
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const text = (el) => {
|
||
if (el === null || el === undefined || el === false || el === true) return '';
|
||
const s = typeof el === 'string' ? el : (el.textContent || '');
|
||
return String(s).replace(/\s+/g, ' ').trim();
|
||
};
|
||
|
||
function check(title, cond, detail = '') {
|
||
if (cond) { PASS.push(title); console.log(` [PASS] ${title}${detail ? ' ' + detail : ''}`); }
|
||
else { FAIL.push(title + (detail ? ' — ' + detail : '')); console.log(` [FAIL] ${title} ${detail}`); }
|
||
}
|
||
function section(name) { console.log(`\n== ${name} ==`); }
|
||
|
||
async function waitFor(fn, timeout = 12000, interval = 60) {
|
||
const t0 = Date.now();
|
||
while (Date.now() - t0 < timeout) {
|
||
try { if (await fn()) return true; } catch (e) { /* 继续等 */ }
|
||
await sleep(interval);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
(async () => {
|
||
section('加载页面');
|
||
const vc = new VirtualConsole();
|
||
vc.on('jsdomError', (e) => errors.push('jsdomError: ' + (e.message || e)));
|
||
vc.on('error', (...a) => errors.push('console.error: ' + a.join(' ')));
|
||
|
||
const dom = await JSDOM.fromURL(BASE + '/', {
|
||
runScripts: 'dangerously',
|
||
resources: 'usable',
|
||
pretendToBeVisual: true,
|
||
virtualConsole: vc,
|
||
beforeParse(w) {
|
||
w.fetch = async (input, init) => {
|
||
const url = typeof input === 'string' && !/^https?:/.test(input) ? BASE + input : input;
|
||
const res = await fetch(url, init);
|
||
const method = (init && init.method) || 'GET';
|
||
const path = String(url).replace(BASE, '');
|
||
// 只读副本,避免消费掉 body
|
||
res.clone().json().then((b) => {
|
||
if (b && b.code !== 0) apiErrors.push(`${method} ${path} -> code=${b.code} ${b.msg}`);
|
||
}).catch(() => {});
|
||
return res;
|
||
};
|
||
w.URL.createObjectURL = () => 'blob:fake';
|
||
w.HTMLAnchorElement.prototype.click = function () {};
|
||
w.addEventListener('error', (e) => errors.push('window.error: ' + (e.message || e.error)));
|
||
w.addEventListener('unhandledrejection', (e) => errors.push('unhandled: ' + ((e.reason && e.reason.message) || e.reason)));
|
||
},
|
||
});
|
||
const win = dom.window;
|
||
const doc = win.document;
|
||
|
||
const ready = await waitFor(() => text(doc.querySelector('.sub')).includes('学生'));
|
||
check('index.html 首屏渲染出登录卡', ready, text(doc.querySelector('.sub')));
|
||
|
||
// 关键:等脚本真正执行完
|
||
const scriptReady = await waitFor(() => typeof win.doLogin === 'function' && doc.querySelector('#login-btn').onclick);
|
||
check('app.js 加载并完成事件绑定', scriptReady);
|
||
|
||
section('登录');
|
||
const loginInput = doc.querySelector('#login-user');
|
||
const passInput = doc.querySelector('#login-pass');
|
||
const loginBtn = doc.querySelector('#login-btn');
|
||
|
||
loginInput.value = 'admin';
|
||
passInput.value = 'definitely-wrong';
|
||
loginBtn.click();
|
||
const gotErr = await waitFor(() => doc.querySelector('#toasts .toast.err'));
|
||
check('错误密码给出错误提示', !!gotErr, gotErr ? text(doc.querySelector('#toasts .toast.err')) : '无提示');
|
||
check('错误密码后仍停在登录页',
|
||
doc.querySelector('#app').style.display === 'none',
|
||
'app.display=' + doc.querySelector('#app').style.display);
|
||
|
||
loginInput.value = 'admin';
|
||
passInput.value = 'admin123';
|
||
loginBtn.click();
|
||
const entered = await waitFor(() => doc.querySelector('#app').style.display === 'flex');
|
||
check('正确账号登录成功并进入主界面', entered);
|
||
if (!entered) { console.log('\n登录失败:', errors.slice(0, 5), apiErrors.slice(0, 5)); process.exit(1); }
|
||
await waitFor(() => text(doc.querySelector('#view')).length > 10);
|
||
|
||
section('导航与页面渲染');
|
||
const navItems = Array.from(doc.querySelectorAll('.nav-item'));
|
||
check('侧边栏渲染 9 个业务菜单', navItems.length === 9, '实际 ' + navItems.length);
|
||
check('侧边栏有 3 个分组标题', doc.querySelectorAll('.nav-group-title').length === 3,
|
||
'实际 ' + doc.querySelectorAll('.nav-group-title').length);
|
||
check('顶栏显示当前账号身份', /管理员/.test(text(doc.querySelector('#user-name'))),
|
||
text(doc.querySelector('#user-name')));
|
||
|
||
const PAGES = ['overview', 'students', 'scores', 'employments', 'classes', 'teachers', 'advisors', 'statistics', 'advanced'];
|
||
// 每个页面在视图区里应当出现的业务词
|
||
const EXPECT = {
|
||
overview: ['学生', '就业'],
|
||
students: ['学号', '姓名'],
|
||
scores: ['学生', '考核'],
|
||
employments: ['薪资', '公司'],
|
||
classes: ['班级'],
|
||
teachers: ['老师'],
|
||
advisors: ['顾问'],
|
||
statistics: ['班级', '就业'],
|
||
advanced: ['规则', '字段'],
|
||
};
|
||
|
||
const pageTexts = {};
|
||
for (const key of PAGES) {
|
||
const jsBefore = errors.length;
|
||
const apiBefore = apiErrors.length;
|
||
await win.eval(`goto('${key}')`);
|
||
await waitFor(() => !text(doc.querySelector('#view')).startsWith('加载中'), 10000);
|
||
await sleep(200);
|
||
const t = text(doc.querySelector('#view'));
|
||
pageTexts[key] = t;
|
||
check(`页面 ${key} 渲染成功`, t.length > 20 && !t.includes('加载失败'), t.length + ' 字');
|
||
check(`页面 ${key} 无未捕获 JS 异常`, errors.length === jsBefore, errors.slice(jsBefore).join(' | ').slice(0, 160));
|
||
check(`页面 ${key} 无后端报错`, apiErrors.length === apiBefore, apiErrors.slice(apiBefore).join(' | ').slice(0, 160));
|
||
const missing = (EXPECT[key] || []).filter((w) => !t.includes(w));
|
||
check(`页面 ${key} 含预期业务字段`, missing.length === 0, missing.length ? '缺 ' + missing.join('/') : '');
|
||
}
|
||
|
||
section('概览指标');
|
||
const numsInCards = (pageTexts.overview.match(/\d+/g) || []).length;
|
||
check('概览页出现多项数字指标', numsInCards >= 8, '数字个数 ' + numsInCards);
|
||
|
||
section('导航竞态:慢页面不能覆盖新页面');
|
||
// 这条回归对应一个真实 bug:goto() 原来没有任何并发保护,而各页面的
|
||
// `view.innerHTML = ...` 都写在 await 之后。于是「登录后立刻点学生管理」
|
||
// 会发生:学生页已画好,概览那两次慢接口随后返回,把学生页整个盖掉 ——
|
||
// 菜单停在学生管理,屏幕上是概览。
|
||
//
|
||
// 测试不能靠"概览恰好更慢"这种运气,否则哪天接口快了这条就形同虚设。
|
||
// 做法是临时给概览用到的接口注入固定延时,让"慢页面后返回"成为必然。
|
||
const realFetch = win.fetch;
|
||
const SLOW = ['/statistics/overview', '/statistics/employment/funnel'];
|
||
win.fetch = async (input, init) => {
|
||
const url = String(typeof input === 'string' ? input : (input && input.url) || '');
|
||
if (SLOW.some((p) => url.includes(p))) await sleep(900);
|
||
return realFetch(input, init);
|
||
};
|
||
|
||
const raceP1 = win.eval(`goto('overview')`); // 故意不 await:模拟用户等不及就点了别的
|
||
await win.eval(`goto('students')`);
|
||
await raceP1.catch(() => {}); // 让过期的那次导航跑完(它应当自行作废)
|
||
await sleep(1600); // 再等一会儿,确保被延时的慢接口全部回完
|
||
|
||
const raceText = text(doc.querySelector('#view'));
|
||
check('竞态后仍停在学生页(未被概览覆盖)',
|
||
!!doc.querySelector('#stu-table') && /关键词|重置/.test(raceText) && !/在册学生/.test(raceText),
|
||
'view 前 80 字:' + raceText.slice(0, 80));
|
||
check('竞态后菜单高亮与学生页一致',
|
||
text(doc.querySelector('.nav-item.active')) === '学生管理' || /学生/.test(text(doc.querySelector('.nav-item.active'))),
|
||
'高亮=' + text(doc.querySelector('.nav-item.active')));
|
||
check('竞态后学生表格真的渲染出了数据行',
|
||
doc.querySelectorAll('#stu-table tbody tr').length > 0,
|
||
doc.querySelectorAll('#stu-table tbody tr').length + ' 行');
|
||
|
||
win.fetch = realFetch; // 立刻还原,后面的用例沿用真实时延
|
||
await win.eval(`goto('students')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
|
||
section('交互:学生列表筛选');
|
||
await win.eval(`goto('students')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
const rowCount = doc.querySelectorAll('#view tbody tr').length;
|
||
check('学生列表默认渲染出数据行', rowCount > 0, rowCount + ' 行');
|
||
|
||
const kw = doc.querySelector('#f-kw');
|
||
check('存在关键词输入框', !!kw);
|
||
kw.value = '张';
|
||
doc.querySelector('#f-search').click();
|
||
await sleep(900);
|
||
const rowsAfter = Array.from(doc.querySelectorAll('#view tbody tr')).map(text);
|
||
check('关键字筛选后无报错', !text(doc.querySelector('#view')).includes('加载失败'), rowsAfter.length + ' 行');
|
||
check('筛选结果只含匹配项', rowsAfter.length === 0 || rowsAfter.every((n) => n.includes('张')),
|
||
rowsAfter.slice(0, 3).join(' / ').slice(0, 120));
|
||
|
||
doc.querySelector('#f-reset').click();
|
||
await sleep(900);
|
||
check('重置按钮恢复全量列表', doc.querySelectorAll('#view tbody tr').length > 0,
|
||
doc.querySelectorAll('#view tbody tr').length + ' 行');
|
||
|
||
section('交互:新增学生 → 编辑 → 删除(走完整 UI,结束后清理数据)');
|
||
const apiBeforeWrite = apiErrors.length;
|
||
doc.querySelector('#btn-add').click();
|
||
const modalReady = await waitFor(() => doc.querySelector('#modal-root .modal'));
|
||
check('「新增学生」打开弹窗', !!modalReady);
|
||
|
||
let createdId = null;
|
||
let createdNo = null;
|
||
if (modalReady) {
|
||
const form = doc.querySelector('#modal-root');
|
||
const set = (sel, val) => { const el = form.querySelector(sel); if (el) { el.value = val; el.dispatchEvent(new win.Event('input', { bubbles: true })); } return !!el; };
|
||
const nameOk = set('#s-name', '自检临时生');
|
||
const clsSel = form.querySelector('#s-class');
|
||
if (clsSel && clsSel.options.length > 1) clsSel.value = clsSel.options[1].value;
|
||
set('#s-gender', '1');
|
||
set('#s-birth', '2004-05-06');
|
||
check('弹窗表单含姓名输入框', nameOk);
|
||
|
||
// 点确定
|
||
const okBtn = form.querySelector('.modal-foot [data-ok]');
|
||
check('弹窗存在保存按钮', !!okBtn, okBtn ? text(okBtn) : '未找到 [data-ok]');
|
||
if (okBtn) {
|
||
okBtn.click();
|
||
const closed = await waitFor(() => !doc.querySelector('#modal-root .modal'), 12000);
|
||
check('保存后弹窗关闭', closed);
|
||
await sleep(600);
|
||
const apiErr = apiErrors.slice(apiBeforeWrite);
|
||
check('新增学生无后端报错', apiErr.length === 0, apiErr.join(' | ').slice(0, 200));
|
||
|
||
// 在列表里找出来
|
||
await win.eval(`goto('students')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
const searchInput = doc.querySelector('#f-kw');
|
||
searchInput.value = '自检临时生';
|
||
doc.querySelector('#f-search').click();
|
||
await sleep(900);
|
||
const rows = Array.from(doc.querySelectorAll('#view tbody tr'));
|
||
const hit = rows.find((r) => r.textContent.includes('自检临时生'));
|
||
check('新增的学生能在列表里按姓名查到', !!hit, hit ? text(hit).slice(0, 100) : `${rows.length} 行无匹配`);
|
||
if (hit) {
|
||
const noMatch = text(hit).match(/(WL|ZK|XL)?[A-Z]*\d{4,}/);
|
||
createdNo = noMatch ? noMatch[0] : null;
|
||
check('新增学生自动生成学号', !!createdNo, createdNo || '未识别到学号');
|
||
|
||
// 删除,保持数据干净
|
||
const delBtn = hit.querySelector('[data-del]');
|
||
createdId = delBtn ? delBtn.dataset.del : null;
|
||
check('列表行带删除操作', !!delBtn);
|
||
if (delBtn) {
|
||
delBtn.click();
|
||
const confirmReady = await waitFor(() => doc.querySelector('#modal-root .modal-foot [data-ok]'));
|
||
const yes = confirmReady ? doc.querySelector('#modal-root .modal-foot [data-ok]') : null;
|
||
check('删除前弹出二次确认', !!yes, yes ? text(yes) : '');
|
||
if (yes) {
|
||
yes.click();
|
||
await sleep(1200);
|
||
await win.eval(`goto('students')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
const si2 = doc.querySelector('#f-kw');
|
||
si2.value = '自检临时生';
|
||
doc.querySelector('#f-search').click();
|
||
await sleep(900);
|
||
const left = Array.from(doc.querySelectorAll('#view tbody tr')).filter((r) => r.textContent.includes('自检临时生'));
|
||
check('删除后列表中不再出现(数据已清理)', left.length === 0, left.length + ' 行残留');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
section('交互:成绩红线预警筛选');
|
||
await win.eval(`goto('scores')`);
|
||
await waitFor(() => doc.querySelector('#c-flag'), 10000);
|
||
const flagSel = doc.querySelector('#c-flag');
|
||
check('成绩页有「是否预警」筛选控件', !!flagSel);
|
||
if (flagSel) {
|
||
const apiBefore2 = apiErrors.length;
|
||
flagSel.value = '1';
|
||
doc.querySelector('#c-search').click();
|
||
await sleep(900);
|
||
const rows = Array.from(doc.querySelectorAll('#view tbody tr')).map(text);
|
||
const t = text(doc.querySelector('#view'));
|
||
check('筛选红线预警无报错', !t.includes('加载失败') && apiErrors.length === apiBefore2,
|
||
apiErrors.slice(apiBefore2).join(' | ').slice(0, 150));
|
||
check('预警筛选结果都带"需重点关注"标记',
|
||
rows.length === 0 || rows.every((r) => r.includes('需重点关注')),
|
||
rows.length ? rows.length + ' 行' : '0 行(无预警数据,可接受)');
|
||
// 筛选出 0 行时,至少要有空态提示,不能让用户面对空白
|
||
if (rows.length === 0) check('预警筛选空结果有空态文案', /暂无/.test(t), t.slice(0, 80));
|
||
flagSel.value = '';
|
||
doc.querySelector('#c-search').click();
|
||
await sleep(800);
|
||
}
|
||
|
||
section('交互:Excel 导入弹窗');
|
||
await win.eval(`goto('students')`);
|
||
await waitFor(() => doc.querySelector('#btn-import'), 10000);
|
||
doc.querySelector('#btn-import').click();
|
||
const impModal = await waitFor(() => doc.querySelector('#modal-root .modal'));
|
||
check('Excel 导入弹窗可打开', !!impModal);
|
||
if (impModal) {
|
||
const t = text(doc.querySelector('#modal-root'));
|
||
check('导入弹窗说明了必需列', /模板|表头|列/.test(t), t.slice(0, 110));
|
||
const cancel = doc.querySelector('#modal-root .modal-foot [data-close]');
|
||
if (cancel) cancel.click();
|
||
await sleep(300);
|
||
}
|
||
|
||
section('交互:统计分析 10 个标签页');
|
||
await win.eval(`goto('statistics')`);
|
||
await waitFor(() => doc.querySelector('#st-tabs'), 10000);
|
||
const tabs = Array.from(doc.querySelectorAll('#st-tabs .tab'));
|
||
check('统计分析渲染 10 个标签页', tabs.length === 10, tabs.length + ' 个');
|
||
|
||
const tabResults = [];
|
||
for (const tab of tabs) {
|
||
const apiBefore3 = apiErrors.length;
|
||
const label = text(tab);
|
||
tab.click();
|
||
await sleep(800);
|
||
const body = text(doc.querySelector('#st-body'));
|
||
const bad = apiErrors.slice(apiBefore3);
|
||
tabResults.push({ label, len: body.length, bad });
|
||
check(`统计-${label} 渲染出内容且无后端报错`, body.length > 10 && bad.length === 0,
|
||
bad.length ? bad.join(' | ').slice(0, 140) : body.length + ' 字');
|
||
}
|
||
|
||
section('交互:高级筛选规则引擎(真实提交)');
|
||
await win.eval(`goto('advanced')`);
|
||
await waitFor(() => doc.querySelector('#adv-run'), 10000);
|
||
await sleep(600);
|
||
check('高级筛选页渲染无异常', !text(doc.querySelector('#view')).includes('加载失败'));
|
||
|
||
const selects = Array.from(doc.querySelectorAll('#view select'));
|
||
check('高级筛选有字段与操作符下拉', selects.length >= 2, selects.length + ' 个 select');
|
||
const ruleFields = doc.querySelectorAll('#adv-builder select').length;
|
||
check('规则构建器渲染出条件行', ruleFields >= 2, ruleFields + ' 个下拉');
|
||
|
||
// 1) 默认条件直接执行
|
||
const apiA = apiErrors.length;
|
||
doc.querySelector('#adv-res').innerHTML = '';
|
||
doc.querySelector('#adv-run').click();
|
||
await waitFor(() => doc.querySelector('#adv-res .card'), 12000);
|
||
await sleep(400);
|
||
check('默认条件执行无后端报错', apiErrors.length === apiA, apiErrors.slice(apiA).join(' | ').slice(0, 180));
|
||
check('执行后展示了结果卡片', !!doc.querySelector('#adv-res .card'));
|
||
const resTxt = text(doc.querySelector('#adv-res'));
|
||
check('结果区展示了命中数与 SQL', /命中\s*\d+\s*条/.test(resTxt) && /SELECT/i.test(resTxt),
|
||
resTxt.slice(0, 100));
|
||
const hitA = Number((resTxt.match(/命中\s*(\d+)\s*条/) || [])[1]);
|
||
check('默认条件 age>25 命中条数可达', Number.isFinite(hitA), '命中 ' + hitA);
|
||
|
||
// 2) 需求文档里的嵌套 AND/OR 示例
|
||
doc.querySelector('#adv-demo').click();
|
||
await sleep(300);
|
||
const nested = doc.querySelectorAll('#adv-builder [data-group]').length;
|
||
check('「载入需求文档示例」产出嵌套 OR 分组', nested >= 2, nested + ' 个分组');
|
||
const apiB = apiErrors.length;
|
||
doc.querySelector('#adv-res').innerHTML = ''; // 关键:清空,否则会读到上一轮的残留
|
||
doc.querySelector('#adv-run').click();
|
||
const repainted = await waitFor(() => doc.querySelector('#adv-res .card'), 12000);
|
||
await sleep(500);
|
||
check('嵌套示例结果区已重新渲染', repainted);
|
||
check('嵌套 AND/OR 示例执行无后端报错', apiErrors.length === apiB,
|
||
apiErrors.slice(apiB).join(' | ').slice(0, 180));
|
||
|
||
const nestedTxt = text(doc.querySelector('#adv-res'));
|
||
const hitB = Number((nestedTxt.match(/命中\s*(\d+)\s*条/) || [])[1]);
|
||
check('嵌套示例返回了命中条数', Number.isFinite(hitB), '命中 ' + hitB);
|
||
// 语义校验:加了「性别=男 且 (薪资≥15000 或 班级含Java)」后,命中数必须比单独 age>25 更少
|
||
check('嵌套条件确实收紧了结果(命中数 < 单条件命中数)',
|
||
Number.isFinite(hitA) && Number.isFinite(hitB) && hitB < hitA,
|
||
`age>25 命中 ${hitA} → 加 AND/OR 组合后命中 ${hitB}`);
|
||
|
||
// 与后端独立对账:同样的规则直接打接口,看条数是否一致
|
||
const direct = await win.fetch(BASE + '/advanced/query', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + win.localStorage.getItem('wolin_token') },
|
||
body: JSON.stringify({
|
||
model: 'student',
|
||
rules: [
|
||
{ field: 'age', operator: '>', value: 25 },
|
||
{ field: 'gender', operator: '=', value: '男' },
|
||
{ logic: 'OR', sub_rules: [
|
||
{ field: 'salary', operator: '>=', value: 15000 },
|
||
{ field: 'class_name', operator: 'like', value: 'Java' },
|
||
] },
|
||
],
|
||
fields: ['stu_no', 'name', 'age', 'gender', 'salary', 'class_name'],
|
||
page: 1, page_size: 5,
|
||
}),
|
||
}).then((r) => r.json());
|
||
check('同一规则直连接口返回成功', direct.code === 0, 'code=' + direct.code + ' ' + String(direct.msg).slice(0, 80));
|
||
if (direct.code === 0) {
|
||
check('UI 命中数与接口 total 一致', direct.data.total === hitB,
|
||
`UI ${hitB} vs 接口 ${direct.data.total}`);
|
||
const items = direct.data.items || [];
|
||
// 注意:接口里 gender 是 int 代号(1=男),不是中文
|
||
const badRows = items.filter((r) => !(r.age > 25 && r.gender === 1
|
||
&& (Number(r.salary) >= 15000 || String(r.class_name || '').includes('Java'))));
|
||
check('返回的每一行都真实满足全部条件(含嵌套 OR)', badRows.length === 0,
|
||
badRows.length ? JSON.stringify(badRows[0]) : `${items.length} 行全部合规(age>25 且 性别=男 且 (薪资≥15000 或 班级含Java))`);
|
||
check('返回的字段就是请求的字段', JSON.stringify(direct.data.fields) ===
|
||
JSON.stringify(['stu_no', 'name', 'age', 'gender', 'salary', 'class_name']),
|
||
JSON.stringify(direct.data.fields));
|
||
|
||
// UI 侧:结果表里「性别」必须是中文,不能把 1/2 直接摆给用户看
|
||
const uiCols = Array.from(doc.querySelectorAll('#adv-res thead th')).map(text);
|
||
const genderIdx = uiCols.findIndex((c) => c === '性别');
|
||
check('结果表存在「性别」列', genderIdx >= 0, uiCols.slice(0, 12).join(' / '));
|
||
if (genderIdx >= 0) {
|
||
const cells = Array.from(doc.querySelectorAll('#adv-res tbody tr'))
|
||
.map((tr) => text(tr.children[genderIdx]));
|
||
check('「性别」列已还原成中文(非 1/2 代号)',
|
||
cells.length > 0 && cells.every((c) => c === '男' || c === '女' || c === '—'),
|
||
cells.join(' / ') || '无数据行');
|
||
}
|
||
// UI 命中条数应当与接口一致
|
||
const uiHit = Number((text(doc.querySelector('#adv-res')).match(/命中\s*(\d+)\s*条/) || [])[1]);
|
||
check('UI 展示的命中数与接口 total 一致', uiHit === direct.data.total,
|
||
`UI ${uiHit} vs 接口 ${direct.data.total}`);
|
||
}
|
||
|
||
// 3) 聚合标签页
|
||
const advTabs = Array.from(doc.querySelectorAll('#view .tab'));
|
||
check('高级筛选有分组聚合入口', advTabs.length >= 2, advTabs.length + ' 个标签');
|
||
const aggTab = advTabs.find((t) => /聚合/.test(t.textContent));
|
||
if (aggTab) {
|
||
aggTab.click();
|
||
await sleep(600);
|
||
const aggRun = Array.from(doc.querySelectorAll('#view button')).find((b) => /执行|分组|聚合/.test(b.textContent));
|
||
check('聚合页有执行按钮', !!aggRun, aggRun ? text(aggRun) : '');
|
||
if (aggRun) {
|
||
const apiC = apiErrors.length;
|
||
aggRun.click();
|
||
await sleep(1800);
|
||
check('聚合执行无后端报错', apiErrors.length === apiC, apiErrors.slice(apiC).join(' | ').slice(0, 180));
|
||
}
|
||
}
|
||
|
||
section('退出登录');
|
||
doc.querySelector('#btn-logout').click();
|
||
await sleep(400);
|
||
check('退出后回到登录页',
|
||
doc.querySelector('#login').style.display === 'flex' && doc.querySelector('#app').style.display === 'none',
|
||
`login=${doc.querySelector('#login').style.display} app=${doc.querySelector('#app').style.display}`);
|
||
check('退出后本地 token 已清除', !win.localStorage.getItem('wolin_token'));
|
||
check('退出后只读标记一并收起', doc.querySelector('#ro-badge').style.display === 'none',
|
||
'ro-badge.display=' + JSON.stringify(doc.querySelector('#ro-badge').style.display));
|
||
|
||
// ================================================================
|
||
// 只读账号走查:这是本轮新增的核心场景。
|
||
// 起因是走查时发现 viewer 登录后连一条数据都读不出来(所有读接口都错挂在
|
||
// WriteAccount 上,返回 403)。修完之后必须有人盯着「只读用户到底能不能
|
||
// 正常用」,否则同类问题还会再犯。
|
||
//
|
||
// 这里断言的不是「按钮有 w-act 这个类」,而是 getComputedStyle 算出来的
|
||
// display —— 类名只是手段,用户看不见才是目的。只想类名的话,CSS 规则被
|
||
// 写错或漏掉时测试照样绿。
|
||
// ================================================================
|
||
section('只读账号(viewer)走查');
|
||
// jsdom 对 <link> 外部样式表是异步加载的,不等它算出来的样式是空的
|
||
const cssReady = await waitFor(() => {
|
||
for (const sheet of Array.from(doc.styleSheets)) {
|
||
try {
|
||
const rules = sheet.cssRules;
|
||
if (rules && Array.from(rules).some((r) => r.selectorText === '.readonly-mode .w-act')) return true;
|
||
} catch (e) { /* 跨域表跳过 */ }
|
||
}
|
||
return false;
|
||
}, 10000);
|
||
check('外部样式表加载完成(能读到 .readonly-mode .w-act 规则)', cssReady);
|
||
|
||
const displayOf = (el) => (el ? win.getComputedStyle(el).display : 'MISSING');
|
||
// 关键:元素"根本不存在"绝不能算成"不可见"。
|
||
// 之前这里只判 `display !== 'none'`,而查不到元素时 displayOf 返回 'MISSING',
|
||
// 于是页面渲染失败的场景反倒让"写入口已隐藏"通过了 —— 最该红的时候绿了。
|
||
// 所以凡是断言"藏起来了",都必须先要求元素确实存在。
|
||
const hidden = (el) => !!el && win.getComputedStyle(el).display === 'none';
|
||
const shown = (el) => !!el && win.getComputedStyle(el).display !== 'none';
|
||
|
||
loginInput.value = 'viewer';
|
||
passInput.value = 'viewer123';
|
||
loginBtn.click();
|
||
const vEntered = await waitFor(() => doc.querySelector('#app').style.display === 'flex');
|
||
check('viewer 账号能登录进主界面', vEntered);
|
||
if (vEntered) {
|
||
await waitFor(() => text(doc.querySelector('#view')).length > 10);
|
||
check('顶栏身份显示为「只读」', /只读/.test(text(doc.querySelector('#user-name'))),
|
||
text(doc.querySelector('#user-name')));
|
||
check('顶栏亮出「只读模式」标记', shown(doc.querySelector('#ro-badge')),
|
||
'display=' + displayOf(doc.querySelector('#ro-badge')));
|
||
check('根节点带上 readonly-mode', doc.querySelector('#app').classList.contains('readonly-mode'));
|
||
|
||
// ---- 读能力:只读账号走遍所有页面,一次 403 都不该撞到 ----
|
||
const p403 = () => apiErrors.filter((e) => /code=403/.test(e));
|
||
const v403Before = p403().length;
|
||
for (const key of PAGES) {
|
||
if (key === 'students' || key === 'scores' || key === 'classes') continue; // 下面单独细看
|
||
await win.eval(`goto('${key}')`);
|
||
await waitFor(() => !text(doc.querySelector('#view')).startsWith('加载中'), 10000);
|
||
await sleep(150);
|
||
const t = text(doc.querySelector('#view'));
|
||
check(`只读-页面 ${key} 正常渲染出内容`, t.length > 20 && !t.includes('加载失败'), t.length + ' 字');
|
||
}
|
||
check('只读账号浏览各页期间未触发任何 403', p403().length === v403Before,
|
||
p403().slice(v403Before).join(' | ').slice(0, 200));
|
||
|
||
// ---- 写入口必须不可见 ----
|
||
await win.eval(`goto('students')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
await sleep(200);
|
||
|
||
// 顺带回归另一个走查撞出来的真 bug:三个列表的筛选条件是模块级状态,
|
||
// 会跨登出活下来。管理员上一轮按「自检临时生」筛过学生,若不复位,
|
||
// viewer 登进来看到的是一个空列表 + 一个自己从没输过的关键词。
|
||
check('只读-学生页筛选条件未被上一会话残留污染',
|
||
doc.querySelector('#f-kw').value === '',
|
||
'关键词框=' + JSON.stringify(doc.querySelector('#f-kw').value));
|
||
check('只读-学生页默认就能看到数据(未被残留条件筛成空表)',
|
||
doc.querySelectorAll('#stu-table tbody tr').length > 0,
|
||
doc.querySelectorAll('#stu-table tbody tr').length + ' 行');
|
||
|
||
check('只读-学生页「新增学生」按钮不可见', hidden(doc.querySelector('#btn-add')),
|
||
'display=' + displayOf(doc.querySelector('#btn-add')));
|
||
check('只读-学生页「Excel 导入」按钮不可见', hidden(doc.querySelector('#btn-import')),
|
||
'display=' + displayOf(doc.querySelector('#btn-import')));
|
||
|
||
// 按钮仍然在 DOM 里(只是被 CSS 藏了),因此断言"不存在"是错的,必须断言"不可见"
|
||
check('只读-写按钮仍在 DOM 中但有 w-act 标记',
|
||
doc.querySelector('#btn-add').classList.contains('w-act'),
|
||
'class=' + doc.querySelector('#btn-add').className);
|
||
|
||
check('只读-学生行「编辑」不可见', hidden(doc.querySelector('[data-edit]')),
|
||
'display=' + displayOf(doc.querySelector('[data-edit]')));
|
||
check('只读-学生行「录成绩」不可见', hidden(doc.querySelector('[data-score]')),
|
||
'display=' + displayOf(doc.querySelector('[data-score]')));
|
||
check('只读-学生行「删除」不可见', hidden(doc.querySelector('[data-del]')),
|
||
'display=' + displayOf(doc.querySelector('[data-del]')));
|
||
|
||
// 这几条是防「一刀切隐藏整列」的回归 —— 曾经差点把只读用户唯一能用的「详情」也藏掉
|
||
check('只读-学生行「详情」仍然可见', shown(doc.querySelector('[data-detail]')),
|
||
'display=' + displayOf(doc.querySelector('[data-detail]')));
|
||
check('只读-姓名列的可点链接仍然可见', shown(doc.querySelector('#view tbody [data-detail]')),
|
||
'display=' + displayOf(doc.querySelector('#view tbody [data-detail]')));
|
||
|
||
// 真的点一下,证明只读用户的查看功能是通的,不是"看起来有个链接"
|
||
const detailLink = doc.querySelector('#view tbody [data-detail]');
|
||
if (detailLink) {
|
||
const v403b2 = p403().length;
|
||
detailLink.click();
|
||
const detailOpen = await waitFor(() => doc.querySelector('#modal-root .modal'), 10000);
|
||
check('只读-点「详情」能打开学生详情弹窗', detailOpen);
|
||
if (detailOpen) {
|
||
const dt = text(doc.querySelector('#modal-root'));
|
||
check('只读-详情弹窗里能看到成绩与就业信息', /考核成绩/.test(dt) && /就业/.test(dt), dt.slice(0, 120));
|
||
const closeBtn = doc.querySelector('#modal-root .modal-foot [data-close]');
|
||
if (closeBtn) closeBtn.click();
|
||
await sleep(300);
|
||
}
|
||
check('只读-查看详情未触发 403', p403().length === v403b2,
|
||
p403().slice(v403b2).join(' | ').slice(0, 160));
|
||
}
|
||
|
||
// ---- 全是写链接的「操作」列:整列(含表头)一起隐藏,不留空壳 ----
|
||
await win.eval(`goto('scores')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
await sleep(200);
|
||
check('只读-成绩页「录入成绩」按钮不可见', hidden(doc.querySelector('#c-add')),
|
||
'display=' + displayOf(doc.querySelector('#c-add')));
|
||
const scoreOpTh = Array.from(doc.querySelectorAll('#view thead th'))
|
||
.find((th) => th.classList.contains('w-act') && text(th) === '操作');
|
||
check('只读-成绩页「操作」列表头一并隐藏(不留空列)', hidden(scoreOpTh),
|
||
scoreOpTh ? 'display=' + displayOf(scoreOpTh) : '未找到 w-act 表头');
|
||
check('只读-成绩页其余列仍然可见(别把整张表藏了)',
|
||
shown(doc.querySelector('#view thead th')),
|
||
'首列表头 display=' + displayOf(doc.querySelector('#view thead th')));
|
||
|
||
await win.eval(`goto('classes')`);
|
||
await waitFor(() => doc.querySelector('#view table'), 10000);
|
||
await sleep(200);
|
||
check('只读-班级页「新增班级」按钮不可见', hidden(doc.querySelector('#k-add')),
|
||
'display=' + displayOf(doc.querySelector('#k-add')));
|
||
|
||
// ---- 高级筛选是 POST,但语义是「读」,只读账号必须能用 ----
|
||
await win.eval(`goto('advanced')`);
|
||
await waitFor(() => doc.querySelector('#adv-run'), 10000);
|
||
await sleep(400);
|
||
// 先把"页面到底渲出来没有"单独断言一次。不这么写的话,下面那句
|
||
// `#adv-res` 一旦为 null 就会把整个脚本崩掉,失败原因还查不到。
|
||
check('只读-高级筛选页渲染出规则构建器与结果区',
|
||
!!doc.querySelector('#adv-run') && !!doc.querySelector('#adv-res'),
|
||
'view=' + text(doc.querySelector('#view')).slice(0, 120));
|
||
// 上一会话(管理员)切到过「分组聚合」,标签状态同样是模块级的;
|
||
// 不复位的话,下一个登录的人一进来就停在聚合页,而他是来写筛选规则的。
|
||
check('只读-高级筛选默认停在「通用筛选器」标签(未被上一会话的标签残留带走)',
|
||
/通用筛选器/.test(text(doc.querySelector('#view .tab.active'))),
|
||
'当前标签=' + text(doc.querySelector('#view .tab.active')));
|
||
if (doc.querySelector('#adv-res')) {
|
||
const v403b3 = p403().length;
|
||
doc.querySelector('#adv-res').innerHTML = '';
|
||
doc.querySelector('#adv-run').click();
|
||
const advRendered = await waitFor(() => doc.querySelector('#adv-res .card'), 12000);
|
||
check('只读-高级筛选(POST 读接口)可用', advRendered);
|
||
check('只读-高级筛选未触发 403', p403().length === v403b3,
|
||
p403().slice(v403b3).join(' | ').slice(0, 160));
|
||
}
|
||
|
||
// ---- 后端仍然兜得住:直接绕过 UI 发写请求必须被拒 ----
|
||
const vtok = win.localStorage.getItem('wolin_token');
|
||
const bypass = await win.fetch(BASE + '/students', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + vtok },
|
||
body: JSON.stringify({ name: '只读越权探针', gender: 1 }),
|
||
}).then((r) => r.json());
|
||
check('只读-绕过 UI 直接发写请求被后端拒绝', bypass.code === 403,
|
||
'code=' + bypass.code + ' ' + String(bypass.msg).slice(0, 80));
|
||
const readProbe = await win.fetch(BASE + '/statistics/overview', {
|
||
headers: { Authorization: 'Bearer ' + vtok },
|
||
}).then((r) => r.json());
|
||
check('只读-同一 token 读接口仍然放行', readProbe.code === 0, 'code=' + readProbe.code);
|
||
}
|
||
|
||
section('运行期 JS 异常汇总');
|
||
check('全程无未捕获 JS 异常', errors.length === 0, errors.slice(0, 4).join(' | '));
|
||
|
||
console.log(`\n${'='.repeat(60)}`);
|
||
console.log(`前端验证结果:通过 ${PASS.length} 项,失败 ${FAIL.length} 项`);
|
||
if (FAIL.length) { console.log('\n失败明细:'); FAIL.forEach((f, i) => console.log(` ${i + 1}. ${f}`)); }
|
||
if (apiErrors.length) {
|
||
console.log(`\n过程中出现过的后端非零返回(含预期内的错误提示用例):`);
|
||
Array.from(new Set(apiErrors)).slice(0, 12).forEach((e) => console.log(' - ' + e));
|
||
}
|
||
win.close();
|
||
process.exit(FAIL.length ? 1 : 0);
|
||
})().catch((e) => { console.error('验证脚本自身崩溃:', e); process.exit(2); });
|