Files
HomeDash/deploy/verify-ui.js
HomeDash Bot a6b5513f88 docs+chore: 部署脚本入库、README 补零停机部署与部署物料清单
仓库此前缺少部署脚本,只能靠临时文件操作,新人无法复现。补齐:

- 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 误判)、被拒弹层、
  悬浮控件收边与"第一次交互只滑出"
2026-09-11 20:13:44 +08:00

153 lines
8.8 KiB
JavaScript
Raw 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.

/* 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); });