// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。 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); });