Files
2026-09-21 19:03:31 +08:00

142 lines
6.1 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.
/* 真实浏览器视觉验证:用本机 Edge 无头模式给每个页面截图。
做法是把 index.html 复制成变体(放回 static 目录,保证相对路径能打到后端),
在 </body> 前注入「自动登录 + 等渲染」脚本,跳页交给应用自己的 hash 路由
(enterApp 会读 location.hash)。
为什么用 hash 路由而不是登录后再调一次 goto():
enterApp 自己已经按 hash 跳了一次,自己再调一次就是两次导航并发。应用侧现在
有导航序号保护(慢的那次会自行作废),但让浏览器直接带着目标 hash 打开页面
只有一次导航,最干净,也顺便验证了 hash 深链是通的。
哈希去重是必须的:只要跳页静默失效,所有截图会变成同一张(历史上真出现过
「11 张全是概览」),文件大小和分辨率都正常,光看清单看不出问题。
用法:node verify/shots.js [base] [outDir]
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const BASE = process.argv[2] || 'http://127.0.0.1:8010';
const ROOT = path.resolve(__dirname, '..');
const STATIC = path.join(ROOT, 'app', 'static');
const OUT = process.argv[3] || path.join(ROOT, 'verify', 'shots');
const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
const PROFILE = path.join(ROOT, 'verify', '_edge_profile');
const ACCOUNTS = {
admin: { user: 'admin', pass: 'admin123' },
viewer: { user: 'viewer', pass: 'viewer123' },
};
// page 为 null 表示登录页原样截图;account 省略时用 admin
const JOBS = [
{ name: 'login', page: null, label: '登录页' },
{ name: 'overview', page: 'overview', label: '概览' },
{ name: 'students', page: 'students', label: '学生管理' },
{ name: 'scores', page: 'scores', label: '成绩管理' },
{ name: 'employments', page: 'employments', label: '就业管理' },
{ name: 'classes', page: 'classes', label: '班级管理' },
{ name: 'teachers', page: 'teachers', label: '老师管理' },
{ name: 'advisors', page: 'advisors', label: '顾问管理' },
{ name: 'statistics', page: 'statistics', label: '统计分析' },
{ name: 'advanced', page: 'advanced', label: '高级筛选' },
{ name: 'students-modal', page: 'students', label: '新增学生弹窗', after: "document.querySelector('#btn-add').click();" },
// 只读账号:写入口应当整片消失,只留「详情」这类读操作
{ name: 'viewer-students', page: 'students', label: '学生管理(只读)', account: 'viewer' },
{ name: 'viewer-scores', page: 'scores', label: '成绩管理(只读)', account: 'viewer' },
];
function inject(html, job) {
if (job.page === null) return html; // 登录页:原样
const acc = ACCOUNTS[job.account || 'admin'];
const after = job.after || '';
const auto = `<script>
(async function () {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitFor = async (fn, n) => { for (let i = 0; i < n; i++) { if (fn()) return true; await sleep(20); } return false; };
await waitFor(() => typeof doLogin === 'function', 300);
document.querySelector('#login-user').value = ${JSON.stringify(acc.user)};
document.querySelector('#login-pass').value = ${JSON.stringify(acc.pass)};
document.querySelector('#login-btn').click();
await waitFor(() => document.querySelector('#app').style.display === 'flex', 300);
// 等应用自己按 location.hash 的那次跳转渲染完,不要插手
await waitFor(() => !document.querySelector('#view').textContent.trim().startsWith('加载中'), 400);
await sleep(500);
${after}
await sleep(700);
document.body.setAttribute('data-shot-ready', '1');
})();
</script>`;
return html.replace('</body>', auto + '\n</body>');
}
function pngInfo(file) {
const buf = fs.readFileSync(file);
return {
w: buf.readUInt32BE(16),
h: buf.readUInt32BE(20),
bytes: buf.length,
md5: crypto.createHash('md5').update(buf).digest('hex'),
};
}
(function main() {
fs.mkdirSync(OUT, { recursive: true });
try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) {}
const src = fs.readFileSync(path.join(STATIC, 'index.html'), 'utf8');
const results = [];
const variants = [];
try {
for (const job of JOBS) {
const file = path.join(STATIC, `_shot_${job.name}.html`);
fs.writeFileSync(file, inject(src, job), 'utf8');
variants.push(file);
const out = path.join(OUT, `${job.name}.png`);
const url = `${BASE}/static/_shot_${job.name}.html` + (job.page ? `#${job.page}` : '');
try {
execFileSync(EDGE, [
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-first-run',
'--force-device-scale-factor=1',
'--user-data-dir=' + PROFILE,
'--window-size=1680,1120',
'--virtual-time-budget=25000',
'--screenshot=' + out,
url,
], { stdio: 'pipe', timeout: 150000 });
const info = pngInfo(out);
results.push({ name: job.name, label: job.label, ok: true, ...info });
} catch (e) {
results.push({ name: job.name, label: job.label, ok: false, err: String(e.message || e).slice(0, 160) });
}
}
} finally {
variants.forEach((f) => { try { fs.unlinkSync(f); } catch (e) {} });
try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) {}
}
// 逐张报告,并把「哈希重复」当作失败 —— 重复说明跳页没生效
const seen = new Map();
for (const r of results) {
if (!r.ok) { console.log(` [FAIL] ${r.label.padEnd(14)} ${r.err}`); continue; }
const dup = seen.get(r.md5);
seen.set(r.md5, r.label);
if (dup) {
r.ok = false;
console.log(` [FAIL] ${r.label.padEnd(14)} 与「${dup}」截图完全相同(hash ${r.md5.slice(0, 8)})→ 跳页未生效`);
} else {
console.log(` [OK] ${r.label.padEnd(14)} ${r.w}x${r.h} ${(r.bytes / 1024).toFixed(0)}KB hash=${r.md5.slice(0, 8)}`);
}
}
const bad = results.filter((r) => !r.ok);
console.log(`\n截图:成功 ${results.length - bad.length}/${results.length},输出目录 ${OUT}`);
process.exit(bad.length ? 1 : 0);
})();