142 lines
6.1 KiB
JavaScript
142 lines
6.1 KiB
JavaScript
/* 真实浏览器视觉验证:用本机 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);
|
||
})();
|