用户要求:备案号与域名都应当可配置,仓库内容要脱敏。 功能(不只是替换字面量): - 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**; 原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上 - 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖 - auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名, cookie 默认 host-only、首页默认相对路径 "/" - gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页) - 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL, 避免"去掉写死默认值"导致跨子域单点登录失效 脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定); 测试夹具/示例/错误页/脚本/后台占位提示全部通用化。 README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。 测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
156 lines
8.9 KiB
JavaScript
156 lines
8.9 KiB
JavaScript
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
|
||
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
|
||
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
|
||
/* HomeDash 浏览器验收脚本(部署后跑一遍,覆盖本轮所有改动点)
|
||
*
|
||
* 依赖:node + ws(NODE_PATH 指向装了 ws 的目录)、headless chromium
|
||
* 环境变量:
|
||
* PORT CDP 端口(chromium 的 --remote-debugging-port)
|
||
* CK 门户会话 cookie 值(portal_session),用于访问受保护的站点
|
||
* VPS 对外 IP(headless 用 --host-resolver-rules 把域名指到它)
|
||
*
|
||
* 覆盖:① 渲染开销(backdrop-filter 元素数)
|
||
* ② 后台窄屏是否横向溢出
|
||
* ③ 工作空间收边与"第一次交互只滑出"(触摸)
|
||
* ④ 不支持内嵌的应用是否弹出明确提示
|
||
* ⑤ 分组增删改与「历史访问」保护(只创建/删除自己的测试分组)
|
||
* 任何运行时异常都会判失败——promise 里抛出的错误很容易被忽略,
|
||
* 本项目就因此漏掉过一个 ReferenceError 五个版本。
|
||
*/
|
||
const h = require('http');
|
||
const W = require('ws');
|
||
const fs = require('fs');
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
const req = (m, p) => new Promise((res, rej) => {
|
||
const q = h.request({ host: '127.0.0.1', port: +process.env.PORT, path: p, method: m }, (z) => {
|
||
let a = ''; z.on('data', (d) => { a += d; }); z.on('end', () => res(JSON.parse(a)));
|
||
});
|
||
q.on('error', rej); q.end();
|
||
});
|
||
|
||
async function conn(url) {
|
||
const w = new W(url);
|
||
await new Promise((r) => w.on('open', r));
|
||
let id = 0; const waiting = new Map(); const ev = [];
|
||
w.on('message', (b) => { const x = JSON.parse(b); ev.push(x); if (waiting.has(x.id)) { waiting.get(x.id)(x.result); waiting.delete(x.id); } });
|
||
const send = (m, params = {}) => new Promise((r) => { const n = ++id; waiting.set(n, r); w.send(JSON.stringify({ id: n, method: m, params })); });
|
||
send.ev = ev; return send;
|
||
}
|
||
|
||
const BLUR = `(() => {
|
||
const out = {};
|
||
document.querySelectorAll('body *').forEach((e) => {
|
||
const c = getComputedStyle(e);
|
||
const bf = c.backdropFilter || c.webkitBackdropFilter;
|
||
if (bf && bf !== 'none') {
|
||
const k = e.tagName + '.' + (typeof e.className === 'string' ? e.className.slice(0, 22) : '');
|
||
out[k] = (out[k] || 0) + 1;
|
||
}
|
||
});
|
||
const total = Object.values(out).reduce((a, b) => a + b, 0);
|
||
return { path: location.pathname, total, byClass: out };
|
||
})()`;
|
||
|
||
const PEEK = `(() => {
|
||
const box = (s) => { const e = document.querySelector(s); if (!e) return null; const r = e.getBoundingClientRect();
|
||
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom),
|
||
cx: Math.round(r.left + r.width / 2), cy: Math.round(r.top + r.height / 2),
|
||
op: Number(getComputedStyle(e).opacity) }; };
|
||
const bar = document.querySelector('.navi-search-bar');
|
||
return { path: location.pathname, pathname: location.pathname, innerW: innerWidth, innerH: innerHeight,
|
||
peekOpen: document.documentElement.classList.contains('navi-peek-open'),
|
||
search: box('.navi-search-bar'), dock: box('.navi-dock'),
|
||
searchState: bar ? bar.dataset.state : null };
|
||
})()`;
|
||
|
||
(async () => {
|
||
const t = await req('PUT', '/json/new?about:blank');
|
||
const p = await conn(t.webSocketDebuggerUrl);
|
||
await p('Page.enable'); await p('Runtime.enable'); await p('Network.enable'); await p('Log.enable');
|
||
await p('Network.setCookie', { name: 'portal_session', value: process.env.CK, domain: DOM, path: '/', secure: true });
|
||
await p('Network.setCacheDisabled', { cacheDisabled: true });
|
||
const ev = async (e) => (await p('Runtime.evaluate', { returnByValue: true, expression: e })).result.value;
|
||
const clickXY = async (x, y) => {
|
||
await p('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); await sleep(300);
|
||
for (const type of ['mousePressed', 'mouseReleased']) await p('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
|
||
await sleep(700);
|
||
};
|
||
const clickEl = async (sel, n = 0) => {
|
||
const b = await ev(`(()=>{const l=document.querySelectorAll(${JSON.stringify(sel)});const e=l[${n}];if(!e)return null;const r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`);
|
||
if (!b) return false; await clickXY(b.x, b.y); return true;
|
||
};
|
||
let fail = 0;
|
||
|
||
console.log('══ A) 渲染开销:带 backdrop-filter 的元素数');
|
||
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||
for (const path of ['/', '/minimal']) {
|
||
await p('Page.navigate', { url: BASE + path });
|
||
await sleep(8000);
|
||
const o = await ev(BLUR);
|
||
console.log(` ${path}: 总数=${o.total} 明细=${JSON.stringify(o.byClass)}`);
|
||
if (o.total > 16) { fail++; console.log(' ✗ 仍偏多'); }
|
||
}
|
||
|
||
console.log('══ B) 后台窄屏(390px)');
|
||
await p('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
|
||
await p('Page.navigate', { url: BASE+'/admin/' });
|
||
await sleep(7500);
|
||
const o = await ev(`(() => {
|
||
const de = document.documentElement;
|
||
const over = [];
|
||
document.querySelectorAll('.card, .itm, .sec-fields, input, button, select').forEach((e) => {
|
||
const r = e.getBoundingClientRect();
|
||
if (r.width > 0 && (r.right > innerWidth + 1 || r.left < -1)) {
|
||
over.push(e.tagName + '.' + (typeof e.className === 'string' ? e.className.slice(0, 18) : '') + ' [' + Math.round(r.left) + '..' + Math.round(r.right) + ']');
|
||
}
|
||
});
|
||
const secInputs = Array.from(document.querySelectorAll('input[data-sec-name]')).map((i) => { const r = i.getBoundingClientRect(); return Math.round(r.width); });
|
||
return { innerW: innerWidth, scrollW: de.scrollWidth, canScrollX: de.scrollWidth > innerWidth + 1,
|
||
overflowCount: over.length, overflow: over.slice(0, 6), secInputWidths: secInputs };
|
||
})()`);
|
||
const okB = !o.canScrollX && o.overflowCount === 0;
|
||
if (!okB) fail++;
|
||
console.log(` ${okB ? '✓' : '✗'} scrollWidth=${o.scrollW} innerWidth=${o.innerW} 横向溢出元素=${o.overflowCount}`);
|
||
if (o.overflow.length) console.log(' 溢出:', JSON.stringify(o.overflow));
|
||
console.log(` 分组名输入框宽度=${JSON.stringify(o.secInputWidths)}`);
|
||
const shot = await p('Page.captureScreenshot', { format: 'png' });
|
||
fs.writeFileSync('/tmp/v29-admin-mobile.png', Buffer.from(shot.data, 'base64'));
|
||
|
||
console.log('══ C) 工作空间:收起态第一次点击只滑出、不生效');
|
||
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||
await p('Page.navigate', { url: BASE+'/workspace' });
|
||
await sleep(8500);
|
||
let s0 = await ev(PEEK);
|
||
console.log(` 收起态(视口 ${s0.innerW}x${s0.innerH}): dock 露${s0.innerW - s0.dock.left}px @y=${s0.dock.cy} op=${s0.dock.op} 搜索球露${s0.search.right}px @y=${s0.search.cy} 搜索态=${s0.searchState}`);
|
||
// 第一次点:贴着右边的那条 sliver(dock 最右 14px)
|
||
await clickXY(s0.innerW - 7, s0.dock.cy);
|
||
let s1 = await ev(PEEK);
|
||
const revealOnly = s1.pathname === '/workspace' && !/\/admin/.test(s1.pathname) && s1.dock.op > 0.9 && s1.dock.right <= s1.innerW - 10;
|
||
if (!revealOnly) fail++;
|
||
console.log(` ${revealOnly ? '✓' : '✗'} 第一次点右边缘 → 未跳转(path=${s1.pathname}) 已滑出(露${s1.innerW - s1.dock.left}px op=${s1.dock.op}) peek=${s1.peekOpen}`);
|
||
// 搜索球:第一次点 sliver 只滑出,不展开输入
|
||
await p('Page.navigate', { url: BASE+'/workspace' });
|
||
await sleep(8000);
|
||
s0 = await ev(PEEK);
|
||
await clickXY(Math.max(3, s0.search.right - 7), s0.search.cy);
|
||
s1 = await ev(PEEK);
|
||
const step1 = s1.searchState === 'collapsed' && s1.search.left >= 0;
|
||
if (!step1) fail++;
|
||
console.log(` ${step1 ? '✓' : '✗'} 第一次点搜索球边缘 → 滑出(x=${s1.search.left}) 仍未展开(state=${s1.searchState})`);
|
||
await clickEl('.navi-search-toggle');
|
||
const s2 = await ev(PEEK);
|
||
const step2 = s2.searchState === 'open';
|
||
if (!step2) fail++;
|
||
console.log(` ${step2 ? '✓' : '✗'} 第二次点放大镜 → 展开(state=${s2.searchState})`);
|
||
|
||
const errs = p.ev.filter((e) => e.method === 'Log.entryAdded' && e.params.entry.level === 'error').map((e) => e.params.entry.text.slice(0, 100));
|
||
const exc = p.ev.filter((e) => e.method === 'Runtime.exceptionThrown')
|
||
.map((e) => String((e.params.exceptionDetails.exception && e.params.exceptionDetails.exception.description) || e.params.exceptionDetails.text).split('\n')[0]);
|
||
console.log('运行时异常:', exc.length ? JSON.stringify(exc, null, 1) : '无');
|
||
if (exc.length) fail++;
|
||
console.log('控制台 error:', errs.length ? JSON.stringify(errs.slice(0, 4)) : '无');
|
||
console.log(fail ? `!! 有 ${fail} 项未通过` : '全部通过');
|
||
process.exit(fail ? 1 : 0);
|
||
})().catch((e) => { console.error(e.stack); process.exit(1); });
|