仓库此前缺少部署脚本,只能靠临时文件操作,新人无法复现。补齐: - deploy/zd.sh:零停机部署(新容器先在对侧端口验证到底,含 /search 预热 SearXNG;再同 inode 覆写门户上游 + nginx -t + reload;切后哨兵"不得为 502"; 失败自动回滚,旧容器保留为回滚点)。可用环境变量覆盖主机/路径/镜像。 - deploy/verify-ui.js:部署后浏览器验收(渲染开销、后台窄屏、收边两段式、 被拒内嵌弹层、分组增删改与「历史访问」保护)。任一运行时异常即判失败。 README 新增: - 「零停机部署」含**单文件 bind mount 不能用 sed -i** 的坑(换 inode → 容器读到 旧值 → 全站 502,本项目真实踩过)与正确顺序(同 inode 覆写 → 断言容器看到 → nginx -t → reload) - 「部署需要的全部自定义文件」清单表 - 「管理后台」补分组自由增删、历史分组保护、完整 API 表 - 「工作空间视图」补可内嵌性三态探测(转发 Cookie、WAF 403 误判)、被拒弹层、 悬浮控件收边与"第一次交互只滑出"
153 lines
8.8 KiB
JavaScript
153 lines
8.8 KiB
JavaScript
/* 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: '.example.com', 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: 'https://dashy.example.com' + 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: 'https://dashy.example.com/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: 'https://dashy.example.com/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: 'https://dashy.example.com/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); });
|