Files
test/verify/frontend_check.js
2026-09-21 19:03:31 +08:00

641 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 前端行为验证(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); });