/* 前端行为验证(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 对 外部样式表是异步加载的,不等它算出来的样式是空的 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); });