mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-17 05:01:30 +02:00
* feat: integrate the mobile UI runtime layer from the migration package 迁移包给出的移动端改造是「运行时注入」架构:mobile.css 只写 767.98px 以下的 规则,mobile-ui.js 在末位加载并包装 switchPage、注入抽屉/底部标签栏/更多面板。 模板侧只有 4 处接线(viewport-fit + interactive-widget、样式层链接、ELK/SheetJS 移动端延迟加载、交互层末位脚本),其余保持上游原样,桌面端零影响。 * feat: 移动端注入层接入 i18n,并把触控下限统一到 44px 迁移包的交互层把标签栏、抽屉、更多面板的文案全写死成中文,en-US/ru-RU 下会中英 混杂;现在所有注入节点统一挂 data-i18n,交给 i18n.js 的 applyTranslations 在语言 切换时自动重译,本层只保留中文兜底。语言项改为按桌面端下拉里真实存在的语言循环 (原来只在 zh/en 之间来回,俄语用户点不到),面板里的 API 文档/GitHub 不再抄一份 URL,改为触发桌面端既有控件;iOS 上没有全屏 API 时给出提示而不是静默无反应; 不在四个标签里的页面点亮"更多",否则三分之一的页面底部栏无任何高亮。 同时补齐 PWA manifest、api-docs 页移动端样式层,并把通知铃铛与头像按钮从 38px 提到本层自己的 --m-touch(44px) 口径。配 web/static/js/mobile-ui-i18n.test.cjs: 键集三方一致、引用键必须三语都有译文、helper 区之外不许出现裸写文案的 API。 * style: 移动端外壳改 iOS 观感,并修掉抽屉页脚错位 观感:顶栏/底栏改透出材质+发丝分隔线,图标按钮一律正圆去底框,文字按钮一律胶囊, 卡片 18、面板顶角 22,按压给 scale 反馈并在 prefers-reduced-motion 下关掉;焦点环 从 2px 硬描边换成 3px 柔光环(forced-colors 下把硬描边还回去)。材质一律用 color-mix 从主题自己的 --bg-primary/--border-color 派生,浅色暗色都跟随。 两处不是"不好看"而是真错: - 抽屉页脚被 insertBefore 插到菜单上面,"返回顶部"出现在列表上方; - 顶栏材质在暗色下被 style.css 的 html[data-theme="dark"] header 不透明渐变压住, 只写 header 选择器不生效,改成同特异性后暗色才真正透出。 圆角与胶囊走 --m-r-icon/--m-r-btn 令牌(24 节那批 !important 规则读的就是它们), 不再往下补选择器与 !important 对轰。 配 scripts/mobile-shot.mjs:手机视口下抓外壳图,--why 用 CDP 匹配样式表回答 "规则写了没生效是没命中还是被谁压住",--eval 量真实计算值。 * fix: 抽屉页脚与底部固定件被 Safari 工具栏压在后面 iOS 的 100dvh 是「工具栏收起时」的最大视口高度,工具栏展开时可见高度只有 innerHeight。两个全屏抽屉写死 height:100dvh,于是页脚那一截正好落在 Safari 底栏后面;bottom:0 的底栏与贴底面板同理。现在抽屉改用 mobile-ui.js 每次 resize 重写的 --m-app-height,贴底固定件按 max(0px, 100dvh - 可见高度) 抬升;不支持 dvh 或桌面端抬升量恒为 0,实测默认态底栏/抽屉位置零位移。 * fix: 去掉底部标签栏里重复的「更多」入口 顶栏的 ··· 与标签栏的「更多」打开的是同一个面板。按一个入口一份实现收掉底部那枚, 标签栏只留四个页面跳转;随之清掉 more2 图标、mobile.tabMore 键与门禁里的点击断言。 落点在四个标签之外的页面不再高亮任何标签——没有「更多」可点了。 * fix: 清掉另外两处重复入口,并把同类视口缺陷扫干净 顺着「底部更多与顶栏 ··· 重复」这条线扫了一遍,还有两处同类: - 面板里的「退出登录」与头像菜单里的退出是同一个动作两个入口,头像菜单是桌面端 一贯的位置,去掉面板那枚;连带删掉只有它能触发的 m-danger 样式与 logout 图标。 - 抽屉页脚的「返回顶部」与面板里的返回顶部是同一个动作两个入口,且抽屉是导航容器, 动作按钮不该住在里面;去掉整个页脚(那句"移动端导航"文案也没有信息量)。 同类视口缺陷两处: - #m-toast 的 bottom 只算到 tabbar-h + safe,底栏按 --m-bar-bottom 抬升时提示条不抬, 工具栏展开时会压在底栏上;实测补前后间距 16px -> 16px。 - api-docs 页容器写 min-height:100dvh,恒定比可见区高出一截,最后一行落在 Safari 底栏后面;改成 100svh(最小视口)。 i18n 契约测试同步:header.logout / mobile.navFooter 不再被引用,mobile 命名空间 18 键。 * style: 手机端对齐 new-api 的卡片密度与 iOS 的填充式表单 仪表盘那排 KPI 在 ≤480px 被桌面样式拍成一列 116px 高的长条,手机上要滚两屏才看完 四个数字。收成两列紧凑网格(奇数张末卡通栏不留空洞),数字 26px/600 + tabular-nums 做主角,标签 12px 弱化,图标块 28px。卡片表面统一成发丝边 + 16 圆角 + 几乎看不见的 投影——桌面的双层阴影在小屏上一屏四张会散。表单改 iOS 填充式(靠底色区分字段), 聚焦只留一圈柔光环(登录框实测叠过两圈),登录卡 20 圆角 + 满宽胶囊主按钮。 KPI 按 nth-child 分的淡彩渐变是产品自己的身份,这一节不跟它抢。 * fix: 补生产侧俄语、修掉白底白字的提示条,并把逐按钮门禁跑到 PASS 用户报的两件事: - 面板里「切换语言」只在中英之间跳 —— 不是逻辑问题,是 ~/csai-生产版 缺俄语资产 (模板没有 ru-RU 选项、i18n/ 只有两个 json、i18n.js 是不认 ru 的旧版)。补齐后 真手指连点验通 中→英→俄→中,且该行文字自身跟着换。 - 点面板按钮时屏幕中间冒出一块白的 —— 是我自己造成的:25.6 把提示条改成浅色材质, 字色还是基底的 #fff,等于白底白字。回到深色 HUD 胶囊,只留一层投影。 逐按钮门禁(需要登录态,本轮首次跑通全量 34 个界面 = 31 页 + 抽屉/面板/会话抽屉展开态): - 硬缺陷 covered/offscreen/zero/pointer-events 全 0;11 条外壳真手指点击全过; - 小于 44px 从 79 降到 3,残留是桌面密集表格里的控件,钉进基线只降不升; - 修掉的真缺陷:子菜单项 42px(本层自己漏的下限)、.ai-channel-manager 的 overflow:hidden 把 4 个通道选择按钮裁到滚不进来、会话抽屉里搜索框 42/分组按钮 24 宽/ 项目行 38/折叠头 30、工具条里 <a onclick> 只有 21px 高(这些链接既没 href 也没 onclick 属性可依赖,判据要放宽到 <a>);复选框口径统一到 24.11 一处并放大到 30px。 - 门禁自己的两个错也修了:scrollIntoView 在「外层 hidden + 内层 auto」的嵌套里根本不滚, 造成 59 个 offscreen 假红,改成逐层滚可滚祖先模拟手指;touch+mouse 双发会让一次点击 变成两次(抽屉开了又关),只发 touch。 - 退出码口径写死:够不到/遮挡/零尺寸/pointer-events 与自家外壳必须 0,桌面遗留小命中区 走基线;基线被压低时注入探针验红(RATCHET FAIL ×2)后撤销复验绿。 * style: 手机端玻璃质感升级,并补上对话上传的安卓适配 观感目标从"iOS 化的平面灰底"升到玻璃质感。平底上做半透明无物可透,玻璃读不出来, 所以先造画布再上材质(mobile.css 第 28 节): - .container 铺环境光(浅色=近白底+右上 accent/左下紫柔光,暗色=深底+更亮柔光), .content-area / .chat-messages 交出实心底色,半透明表面从此有色可透; - 模糊只给小面积/瞬时件:顶栏、底栏、底部面板、抽屉、登录卡、弹窗;常驻卡片 只做半透明+发丝边+顶缘高光,不挂模糊——固定在滚动内容上的大面积模糊是掉帧主因 (25 节实测口径不变);浅深色全用 color-mix 从主题令牌派生,只有顶缘高光按主题 换向;prefers-reduced-transparency 与不支持模糊的环境退回实心。 - index.html 的 mobile.css 版本号递增,防 iPhone Safari 吃旧缓存。 安卓适配:对话上传的 accept="*" 不是合法 MIME 通配(各浏览器/WebView 解析不一致), 改为 "*/*";mobile-tap-audit.mjs 新增 --ua=iphone|android,同一套门禁可换安卓 UA 复跑 (壳层按视口宽度判定、与 UA 无关,换 UA 是给"安卓浏览器下同样合格"留可复现证据)。 验证(都在生产版文件夹跑):iPhone/Android 双 UA 端到端上传(按钮 44px → 触达 选择器 → POST /api/chat-uploads 200 落盘 → 附件条渲染)PASS;双 UA 全量门禁各 34 页 PASS(硬缺陷 0、点击失败 0、44px 基线无回潮)。
461 lines
25 KiB
JavaScript
461 lines
25 KiB
JavaScript
/*
|
||
* CyberStrikeAI — mobile tap audit.
|
||
*
|
||
* Proves the claim "every button on a phone can actually be tapped and shows up
|
||
* correctly". It is deliberately narrower than scripts/mobile-audit.mjs (layout
|
||
* defects): here every interactive element in the *active* page must pass four
|
||
* geometric/reachability checks, and the injected mobile chrome must pass a
|
||
* scripted finger tap.
|
||
*
|
||
* node scripts/mobile-tap-audit.mjs --user=admin --pass=... --width=390 --height=844
|
||
* CSAI_AUDIT_USER=admin CSAI_AUDIT_PASS=... node scripts/mobile-tap-audit.mjs --all
|
||
* node scripts/mobile-tap-audit.mjs --cookie=<auth_token 的值> --all
|
||
*
|
||
* Flags: --base=https://127.0.0.1:8088 --pages=a,b,c --allow-writes=0|1 --ua=iphone|android
|
||
* --chrome=0 (skip the finger-tap suite) --json=<path>
|
||
*
|
||
* Nothing destructive is tapped: elements whose label matches a write verb are
|
||
* measured but never clicked, and 新对话-style writes need --allow-writes=1.
|
||
* Credentials are never defaulted — a baked-in password leaks the moment this
|
||
* file is committed or served.
|
||
*/
|
||
import fs from 'node:fs';
|
||
import { setTimeout as sleep } from 'node:timers/promises';
|
||
|
||
const argv = Object.fromEntries(process.argv.slice(2).map(a => {
|
||
const i = a.indexOf('=');
|
||
return i === -1 ? [a.replace(/^--/, ''), '1'] : [a.slice(2, i), a.slice(i + 1)];
|
||
}));
|
||
|
||
const W = Number(argv.width || 390), H = Number(argv.height || 844);
|
||
const PORT = argv.port || '9712';
|
||
const BASE = (argv.base || 'https://127.0.0.1:8088').replace(/\/$/, '');
|
||
const USER = argv.user || process.env.CSAI_AUDIT_USER || '';
|
||
const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || '';
|
||
/* 不想交出口令时的替代路径:在浏览器里登录 127.0.0.1:8088,从 DevTools 复制
|
||
auth_token 的值,用 --cookie=... 或 CSAI_AUDIT_COOKIE=... 传进来。会话本身 12 小时
|
||
过期,比长期有效的主机口令暴露面小。两者都没有则拒绝运行。 */
|
||
const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || '';
|
||
const ALLOW_WRITES = argv['allow-writes'] === '1';
|
||
const MIN_TAP = Number(argv['min-tap'] || 44);
|
||
const CHROME = argv.chrome !== '0';
|
||
const OUT = argv.out || `/tmp/cs-tap/${W}x${H}`;
|
||
const JSON_OUT = argv.json || `${OUT}/tap-audit.json`;
|
||
const BASELINE = argv.baseline || 'scripts/mobile-tap-baseline.json';
|
||
|
||
if (!COOKIE && (!USER || !PASS)) {
|
||
console.error('Refusing to run without credentials: pass --user=... --pass=... (or CSAI_AUDIT_USER/CSAI_AUDIT_PASS), or an existing session via --cookie=... (or CSAI_AUDIT_COOKIE).');
|
||
process.exit(2);
|
||
}
|
||
|
||
const SPA_PAGES = (argv.pages || (argv.all ? '' : 'dashboard,chat,hitl,tasks'))
|
||
.split(',').filter(Boolean);
|
||
|
||
const PAGES = argv.all
|
||
? ['dashboard', 'chat', 'projects', 'vulnerabilities', 'tasks', 'workflows', 'asset-overview',
|
||
'asset-library', 'info-collect', 'hitl', 'tool-guard', 'webshell', 'c2-listeners', 'c2-sessions',
|
||
'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles', 'chat-files', 'mcp-monitor', 'mcp-management',
|
||
'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac',
|
||
'skills-monitor', 'skills-management', 'agents-management', 'settings', 'audit-log', 'monitor']
|
||
: SPA_PAGES;
|
||
|
||
fs.mkdirSync(OUT, { recursive: true });
|
||
|
||
/* Write verbs: an element carrying one of these in its label is measured, never
|
||
tapped. Without this the audit would delete real rows in whatever instance it
|
||
points at. */
|
||
const WRITE_RE = /(删除|清空|清理|退出|注销|重启|停止|终止|中止|执行|运行|发送|提交|保存|新建|新增|创建|导入|上传|应用|重置|启用|禁用|安装|卸载|开始|立即|一键|批量|覆盖|恢复|回滚|delete|remove|clear|logout|submit|save|create|import|upload|apply|reset|install|uninstall|run|execute|send|restart|stop|kill|rollback|restore)/i;
|
||
|
||
const CH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||
const chrome = await import('node:child_process').then(m =>
|
||
m.spawn(CH, ['--headless=new', `--remote-debugging-port=${PORT}`,
|
||
`--user-data-dir=/tmp/cs-tap-${PORT}`, '--ignore-certificate-errors',
|
||
'--no-sandbox', '--allow-running-insecure-content', 'about:blank'], { stdio: 'ignore' }));
|
||
/* 任何提前退出(连不上、登录失败)都不许把 headless Chrome 留在机器上 */
|
||
process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* 已退出 */ } });
|
||
|
||
let ws;
|
||
for (let i = 0; i < 80; i++) {
|
||
try {
|
||
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||
ws = new WebSocket((await r.json()).webSocketDebuggerUrl);
|
||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; setTimeout(rej, 4000); });
|
||
break;
|
||
} catch { await sleep(300); }
|
||
}
|
||
if (!ws) { console.error('chrome devtools unreachable'); process.exit(1); }
|
||
|
||
let idc = 0; const pending = new Map(); const events = [];
|
||
ws.onmessage = e => {
|
||
const m = JSON.parse(e.data);
|
||
if (m.id && pending.has(m.id)) {
|
||
const { res, rej } = pending.get(m.id); pending.delete(m.id);
|
||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||
} else if (m.method) { events.push(m.method); }
|
||
};
|
||
const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
|
||
const id = ++idc; pending.set(id, { res, rej });
|
||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||
});
|
||
|
||
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
|
||
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
|
||
for (const m of ['Page.enable', 'Runtime.enable', 'Security.enable', 'Log.enable']) await send(m, {}, sessionId);
|
||
await send('Security.setIgnoreCertificateErrors', { ignore: true }, sessionId);
|
||
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId);
|
||
await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId);
|
||
/* --ua=android 用安卓 Chrome 的 UA 复跑同一套门禁(壳层按视口宽度判定,与 UA 无关;
|
||
这里换 UA 是为了让"同一份 UI 在安卓浏览器下同样合格"有可复现的证据)。 */
|
||
const UA_STRINGS = {
|
||
iphone: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||
android: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'
|
||
};
|
||
const UA = UA_STRINGS[(argv.ua || 'iphone').toLowerCase()];
|
||
if (!UA) { console.error(`unknown --ua=${argv.ua} (iphone | android)`); process.exit(2); }
|
||
await send('Network.setUserAgentOverride', { userAgent: UA }, sessionId);
|
||
|
||
const ev = async expr => {
|
||
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }, sessionId);
|
||
if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) };
|
||
return r.result?.value;
|
||
};
|
||
|
||
/* 真手指只有 touch:Chromium 在移动模拟下会自己从 touchEnd 合成一次 click。
|
||
再补一对 mouse 事件就是第二次 click —— 抽屉/面板会被「开了又关」,
|
||
于是 menu-open 这类断言假失败(实测踩过)。 */
|
||
async function fingerTap(x, y) {
|
||
const t = { x: Math.round(x), y: Math.round(y) };
|
||
await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [t] }, sessionId);
|
||
await sleep(40);
|
||
await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId);
|
||
await sleep(260);
|
||
}
|
||
|
||
const DETECT = `(function(){
|
||
var vw=innerWidth, vh=innerHeight, MIN=${MIN_TAP};
|
||
var SEL='button,a[href],input:not([type=hidden]),select,textarea,[onclick],[role=button],[role=tab],[role=menuitem],[role=switch],[role=checkbox],[role=link],.nav-item-content,.nav-submenu-item,.settings-custom-select-trigger,.tab,.switch';
|
||
function nm(el){
|
||
var s=el.tagName.toLowerCase();
|
||
if(el.id) s+='#'+el.id;
|
||
else if(typeof el.className==='string'&&el.className.trim()){
|
||
var c=el.className.trim().split(/\\s+/).filter(function(x){return !/^is-|^active$|^has-/.test(x)});
|
||
s+='.'+(c[0]||''); if(c[1]&&/layout|sidebar|content|panel|main|nav|list|detail|grid|row|header|body|wrap|table|bar|tab/i.test(c[1])) s+='.'+c[1];
|
||
}
|
||
return s;
|
||
}
|
||
function path(el){ var p=[],n=el; for(var i=0;i<4&&n&&n.tagName;i++){p.unshift(nm(n));n=n.parentElement} return p.join('>') }
|
||
function label(el){
|
||
var t=(el.innerText||'').trim();
|
||
if(!t) t=(el.value||'').trim();
|
||
if(!t) t=el.getAttribute('aria-label')||el.getAttribute('title')||el.getAttribute('placeholder')||'';
|
||
if(!t&&el.getAttribute('data-i18n')) t=el.getAttribute('data-i18n');
|
||
if(!t){ var i=el.querySelector('i,svg,img'); t=(el.className||'')+'[icon]'; }
|
||
return String(t).replace(/\\s+/g,' ').trim().slice(0,42);
|
||
}
|
||
function shown(el){
|
||
var n=el;
|
||
while(n&&n!==document.documentElement){
|
||
var c=getComputedStyle(n);
|
||
if(c.display==='none'||c.visibility==='hidden'||parseFloat(c.opacity)===0) return false;
|
||
n=n.parentElement;
|
||
}
|
||
return true;
|
||
}
|
||
function inActivePage(el){
|
||
var p=el.closest('.page');
|
||
return !p||p.classList.contains('active');
|
||
}
|
||
function scrollAncestor(el,axis){
|
||
var a=el.parentElement;
|
||
while(a){ var c=getComputedStyle(a);
|
||
if(axis==='x'&&/auto|scroll/.test(c.overflowX)) return a;
|
||
if(axis==='y'&&/auto|scroll/.test(c.overflowY)) return a;
|
||
if(a===document.body) break; a=a.parentElement; }
|
||
return null;
|
||
}
|
||
function clipAncestor(el){
|
||
var a=el.parentElement;
|
||
while(a){ var c=getComputedStyle(a);
|
||
if(c.overflowX==='hidden'||c.overflowY==='hidden'||c.overflow==='hidden') return a;
|
||
if(a===document.body) break; a=a.parentElement; }
|
||
return null;
|
||
}
|
||
var fixedTally=function(){
|
||
var out=[];
|
||
[...document.querySelectorAll('*')].forEach(function(el){
|
||
var c=getComputedStyle(el);
|
||
if(c.position!=='fixed'&&c.position!=='sticky') return;
|
||
if(!shown(el)) return;
|
||
var r=el.getBoundingClientRect();
|
||
if(r.width<1||r.height<1) return;
|
||
if(c.pointerEvents==='none') return;
|
||
if(parseFloat(c.zIndex||0)<0&&c.zIndex!=='auto') {}
|
||
out.push({el:el,r:r,n:nm(el),z:c.zIndex});
|
||
});
|
||
return out;
|
||
}();
|
||
function overFixed(r){
|
||
for(var i=0;i<fixedTally.length;i++){
|
||
var f=fixedTally[i];
|
||
if(r.top>=f.r.top&&r.bottom<=f.r.bottom&&r.left>=f.r.left&&r.right<=f.r.right) return f.n;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
var bad=[], scanned=0, chromeSeen={};
|
||
var list=[...document.querySelectorAll(SEL)];
|
||
list.forEach(function(el){
|
||
if(!shown(el)||!inActivePage(el)) return;
|
||
/* 折叠 <details> 里的控件本来就要展开才存在,不算"点不到" */
|
||
var det=el.closest('details');
|
||
if(det&&!det.open&&!det.querySelector('summary').contains(el)) return;
|
||
/*SCOPE*/
|
||
if(el.ownerSVGElement) return;
|
||
if(el.closest('.markdown-body')&&getComputedStyle(el).display==='inline') return;
|
||
scanned++;
|
||
var c=getComputedStyle(el);
|
||
var r=el.getBoundingClientRect();
|
||
/* try to bring it into view first: unreachable means a real dead button */
|
||
if(r.top<0||r.bottom>vh||r.left<0||r.right>vw){
|
||
/* 判"够不够得到"必须按手指能做的事来:逐层把可滚祖先滚到把元素居中。
|
||
scrollIntoView 不算——实测它在这种「外层 overflow:hidden + 内层 auto」的嵌套里
|
||
根本不滚(元素原地不动),会把 59 个划得到的控件误报成 offscreen。
|
||
overflow:hidden 的祖先手指也划不动,所以只滚 auto/scroll 的层。 */
|
||
(function reveal(node){
|
||
var a=node.parentElement;
|
||
while(a&&a!==document.documentElement){
|
||
var cc=getComputedStyle(a);
|
||
if(/auto|scroll/.test(cc.overflowY)&&a.scrollHeight>a.clientHeight+2){
|
||
var ab=a.getBoundingClientRect(), rb2=node.getBoundingClientRect();
|
||
a.scrollTop+= (rb2.top+rb2.height/2)-(ab.top+ab.height/2);
|
||
}
|
||
if(/auto|scroll/.test(cc.overflowX)&&a.scrollWidth>a.clientWidth+2){
|
||
var ab2=a.getBoundingClientRect(), rb3=node.getBoundingClientRect();
|
||
a.scrollLeft+= (rb3.left+rb3.width/2)-(ab2.left+ab2.width/2);
|
||
}
|
||
a=a.parentElement;
|
||
}
|
||
})(el);
|
||
r=el.getBoundingClientRect();
|
||
}
|
||
var w=r.width,h=r.height;
|
||
var out={el:nm(el),p:path(el),t:label(el),w:Math.round(w),h:Math.round(h)};
|
||
if(w<2||h<2){ out.why='zero'; bad.push(out); return; }
|
||
if(c.pointerEvents==='none'||c.pointerEvents==='hidden'){ out.why='pointer-none'; bad.push(out); return; }
|
||
if(w<MIN||h<MIN){
|
||
/* 复选/单选框的命中区常常由外层 label 提供(iOS 设置里也是整行可点):
|
||
自身 >=32 且 label 两个方向都到下限才算合格,否则照报 tiny */
|
||
var pass=false;
|
||
/* 本层对复选/单选框的口径是 30px(见 mobile.css 24.11),整行/label 才是命中区 */
|
||
if((el.type==='checkbox'||el.type==='radio')&&w>=30&&h>=30){
|
||
/* 命中区常常由 label 或整行提供(本层 24.11 就是按"整行可点"写的):
|
||
向上找第一个真正可点的祖先,它到下限就算合格 */
|
||
var n=el, d=0;
|
||
while(n&&n!==document.body&&d++<5){
|
||
var clickable=n.tagName==='LABEL'||n.hasAttribute('onclick')||getComputedStyle(n).cursor==='pointer';
|
||
if(clickable){var nb=n.getBoundingClientRect(); if(nb.width>=MIN&&nb.height>=MIN){pass=true;break;}}
|
||
n=n.parentElement;
|
||
}
|
||
}
|
||
if(!pass){ out.why='tiny'; bad.push(out); return; }
|
||
}
|
||
var cx=r.left+w/2, cy=r.top+h/2;
|
||
if(cx<0||cy<0||cx>vw||cy>vh){
|
||
var cl=clipAncestor(el), sx=scrollAncestor(el,'x'), sy=scrollAncestor(el,'y');
|
||
out.why='offscreen'; out.clip=cl?nm(cl):null; out.swipes=[sx?'x':null,sy?'y':null].filter(Boolean).join('');
|
||
bad.push(out); return;
|
||
}
|
||
var hitTop=document.elementFromPoint(cx,cy);
|
||
var ok=false, cur=hitTop;
|
||
while(cur){ if(cur===el){ok=true;break} cur=cur.parentElement; }
|
||
if(!ok){
|
||
cur=el;
|
||
while(cur&&!ok){ if(cur.contains&&hitTop&&cur.contains(hitTop)) ok=true; cur=cur.parentElement; }
|
||
}
|
||
if(!ok&&hitTop){
|
||
/* delegation: a handler on an ancestor is a legitimate target */
|
||
var a=el.parentElement, d=0;
|
||
while(a&&d++<4){ if(a.contains(hitTop)){ok=true;break} a=a.parentElement; }
|
||
}
|
||
if(!ok){
|
||
out.why='covered';
|
||
out.blocker=hitTop?nm(hitTop):'none';
|
||
out.blockerPath=hitTop?path(hitTop):'';
|
||
var of=overFixed(r); if(of) out.blockerFixed=of;
|
||
bad.push(out); return;
|
||
}
|
||
if(el.id) chromeSeen[el.id]=1;
|
||
});
|
||
|
||
var scr=document.scrollingElement||document.documentElement;
|
||
return {
|
||
vw:vw,vh:vh,scanned:scanned,bad:bad,
|
||
doc:{h:Math.round(scr.scrollHeight),clientH:scr.clientHeight,canScrollY:scr.scrollHeight>scr.clientHeight+2,
|
||
bodyOverflowY:getComputedStyle(document.body).overflowY,htmlTouch:getComputedStyle(document.documentElement).touchAction},
|
||
chrome:{tabbar:!!document.querySelector('#m-tabbar'),sheet:!!document.querySelector('#m-sheet'),
|
||
menu:!!document.querySelector('.m-menu-btn'),more:!!document.querySelector('.m-more-btn'),
|
||
chatbar:!!document.querySelector('#m-chat-bar'),scrim:!!document.querySelector('#m-scrim')},
|
||
locale:(document.documentElement.getAttribute('lang')||'')
|
||
};
|
||
})()`;
|
||
|
||
/* DETECT 默认扫"当前页 + 全局固定件"。遮罩展开时页面元素会被 scrim 判成 covered,
|
||
所以扫抽屉/面板要限定作用域,否则全是假红。 */
|
||
const detectFor = scope => DETECT.replace('/*SCOPE*/',
|
||
scope ? `if(!el.closest(${JSON.stringify(scope)})) return;` : '');
|
||
|
||
async function openPage(hash) {
|
||
await ev(`location.hash=${JSON.stringify(hash)}`);
|
||
await sleep(2600);
|
||
}
|
||
|
||
const host = new URL(BASE).hostname;
|
||
if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: host, path: '/' }, sessionId);
|
||
await send('Page.navigate', { url: BASE + '/' }, sessionId);
|
||
await sleep(3500);
|
||
|
||
/* The login card is a phone surface too — check it before authenticating. */
|
||
const login = await ev(DETECT);
|
||
if (!COOKIE) {
|
||
await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`);
|
||
await sleep(8000);
|
||
} else {
|
||
console.log('using the session cookie supplied via --cookie; no password was sent');
|
||
}
|
||
const who = await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`);
|
||
if (who !== 'none') { console.error('login did not take (overlay still displayed):', who); process.exit(3); }
|
||
|
||
const report = { login, pages: {}, chromeTaps: [] };
|
||
let grand = {};
|
||
const bump = k => { grand[k] = (grand[k] || 0) + 1; };
|
||
|
||
const log = (o, k) => { (o.bad || []).forEach(b => bump(b.why)); };
|
||
|
||
for (const pg of PAGES) {
|
||
await openPage(pg);
|
||
const r = await ev(DETECT);
|
||
if (!r || r.__err) { console.log(pg, 'DETECT ERROR', r && r.__err); continue; }
|
||
report.pages[pg] = r;
|
||
log(r);
|
||
const n = r.bad.length;
|
||
console.log(`${pg.padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(n).padStart(3)} ${n ? '' : ' OK'}`);
|
||
r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`));
|
||
const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||
fs.writeFileSync(`${OUT}/${pg}.png`, Buffer.from(shot.data, 'base64'));
|
||
}
|
||
|
||
/* ---------------- overlays: 抽屉与面板展开后的按钮 ---------------- */
|
||
const OVERLAYS = [
|
||
{ name: 'sheet', route: 'dashboard', open: `CSAMobile.openSheet()`, scope: '#m-sheet' },
|
||
{ name: 'drawer', route: 'dashboard', open: `CSAMobile.openDrawer()`, scope: '#main-sidebar' },
|
||
{ name: 'chat-drawer', route: 'chat', open: `CSAMobile.openChatDrawer()`, scope: '#conversation-sidebar' },
|
||
];
|
||
for (const o of OVERLAYS) {
|
||
await openPage(o.route);
|
||
await ev(`(()=>{try{${o.open}}catch(e){}})()`);
|
||
await sleep(900); /* 等开合动画结束,动画中量到的是位移中的盒子 */
|
||
const r = await ev(detectFor(o.scope));
|
||
if (!r || r.__err) { console.log(o.name, 'DETECT ERROR', r && r.__err); continue; }
|
||
report.pages[o.name] = r;
|
||
log(r);
|
||
console.log(`${('[' + o.name + ']').padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(r.bad.length).padStart(3)} ${r.bad.length ? '' : ' OK'}`);
|
||
r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`));
|
||
await ev(`CSAMobile.closeSheet();CSAMobile.closeDrawer();CSAMobile.closeChatDrawer();`);
|
||
}
|
||
|
||
/* ---------------- chrome finger taps ---------------- */
|
||
/* 关闭要走用户真能点到的控件:抽屉展开后汉堡键被抽屉自己盖住,
|
||
再点它点的是侧栏(真实用户用抽屉内的 × 或遮罩)。每步前先复位,
|
||
否则一条失败会连锁把后面的断言全带偏。 */
|
||
const RESET = `CSAMobile.closeDrawer();CSAMobile.closeSheet();CSAMobile.closeChatDrawer();`;
|
||
const TAPS = [
|
||
{ name: 'menu-open', sel: '.m-menu-btn', pre: RESET, expect: `document.querySelector('#main-sidebar').classList.contains('m-open')` },
|
||
{ name: 'menu-close', sel: '#main-sidebar .m-close-btn', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` },
|
||
{ name: 'menu-scrim-close', sel: '#m-scrim', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` },
|
||
{ name: 'more-open', sel: '.m-more-btn', pre: RESET, expect: `document.querySelector('#m-sheet').classList.contains('m-show')` },
|
||
{ name: 'more-close', sel: '#m-sheet .m-close-btn', pre: `${RESET}CSAMobile.openSheet();`, expect: `!document.querySelector('#m-sheet').classList.contains('m-show')` },
|
||
{ name: 'tab-hitl', sel: '#m-tabbar [data-tab=hitl]', expect: `document.querySelector('#page-hitl').classList.contains('active')` },
|
||
{ name: 'tab-tasks', sel: '#m-tabbar [data-tab=tasks]', expect: `document.querySelector('#page-tasks').classList.contains('active')` },
|
||
{ name: 'tab-dashboard', sel: '#m-tabbar [data-tab=dashboard]', expect: `document.querySelector('#page-dashboard').classList.contains('active')` },
|
||
{ name: 'tab-chat', sel: '#m-tabbar [data-tab=chat]', expect: `document.querySelector('#page-chat').classList.contains('active')` },
|
||
{ name: 'chat-drawer-open', sel: '#m-chat-bar button:first-child', expect: `document.querySelector('#conversation-sidebar').classList.contains('m-open')` },
|
||
{ name: 'chat-drawer-close', sel: '#conversation-sidebar .m-close-btn', expect: `!document.querySelector('#conversation-sidebar').classList.contains('m-open')` },
|
||
];
|
||
|
||
if (CHROME) {
|
||
await openPage('chat');
|
||
for (const t of TAPS) {
|
||
if (t.pre) await ev(`(()=>{try{${t.pre}}catch(e){}})()`);
|
||
await sleep(320);
|
||
const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(t.sel)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2,w:r.width,h:r.height,shown:getComputedStyle(e).display!=='none'}})()`);
|
||
if (!box || !box.shown) { report.chromeTaps.push({ name: t.name, ok: false, why: 'absent' }); console.log(`tap ${t.name.padEnd(20)} ABSENT`); continue; }
|
||
events.length = 0;
|
||
await fingerTap(box.x, box.y);
|
||
const passed = await ev(`(()=>{try{return !!(${t.expect})}catch(e){return false}})()`);
|
||
const err = events.includes('Runtime.exceptionThrown');
|
||
report.chromeTaps.push({ name: t.name, ok: !!passed && !err, state: passed, err });
|
||
console.log(`tap ${t.name.padEnd(20)} ${passed && !err ? 'ok' : 'FAIL'} (state=${passed} jsErr=${err})`);
|
||
}
|
||
/* language round-trip: the injected chrome must re-label itself */
|
||
await ev(`window.changeLanguage && window.changeLanguage('en-US')`);
|
||
await sleep(2500);
|
||
const en = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`);
|
||
report.langEn = en;
|
||
console.log('en-US chrome labels:', JSON.stringify(en));
|
||
await ev(`window.changeLanguage && window.changeLanguage('ru-RU')`);
|
||
await sleep(2500);
|
||
const ru = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`);
|
||
report.langRu = ru;
|
||
console.log('ru-RU chrome labels:', JSON.stringify(ru));
|
||
await ev(`window.changeLanguage && window.changeLanguage('zh-CN')`);
|
||
await sleep(2000);
|
||
}
|
||
|
||
fs.writeFileSync(JSON_OUT, JSON.stringify(report, null, 2));
|
||
|
||
const chromeFail = report.chromeTaps.filter(t => !t.ok).length;
|
||
const total = Object.values(grand).reduce((a, b) => a + b, 0);
|
||
console.log(`\n=========== MOBILE TAP AUDIT @${W}x${H} ===========`);
|
||
console.log(`pages ${Object.keys(report.pages).length}`);
|
||
console.log(`covered (occluded) ${grand.covered || 0}`);
|
||
console.log(`offscreen ${grand.offscreen || 0}`);
|
||
console.log(`zero-size ${grand.zero || 0}`);
|
||
console.log(`pointer-events:none ${grand['pointer-none'] || 0}`);
|
||
console.log(`tap target < ${MIN_TAP}px ${grand.tiny || 0}`);
|
||
console.log(`chrome taps failed ${chromeFail}`);
|
||
console.log(`report ${JSON_OUT}`);
|
||
|
||
/* Ratchet: the钉死的 baseline may only go down. */
|
||
let bl = null;
|
||
try { bl = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); } catch { }
|
||
let ratchetFail = false;
|
||
if (bl) {
|
||
for (const [k, v] of Object.entries(grand)) {
|
||
if ((bl[k] ?? 0) < v) { console.log(`RATCHET FAIL ${k}: baseline ${bl[k]} -> now ${v}`); ratchetFail = true; }
|
||
}
|
||
for (const k of Object.keys(bl)) {
|
||
if (k.startsWith('page:')) {
|
||
const name = k.slice(5), now = (report.pages[name] || {}).bad?.length ?? 0;
|
||
if (bl[k] < now) { console.log(`RATCHET FAIL ${name}: baseline ${bl[k]} -> now ${now}`); ratchetFail = true; }
|
||
}
|
||
}
|
||
if (chromeFail > (bl.chromeFail ?? 0)) { console.log(`RATCHET FAIL chrome: ${bl.chromeFail} -> ${chromeFail}`); ratchetFail = true; }
|
||
} else {
|
||
const fresh = { ...grand };
|
||
for (const [p, r] of Object.entries(report.pages)) fresh['page:' + p] = r.bad.length;
|
||
fresh.chromeFail = chromeFail;
|
||
fresh.note = 'generated by scripts/mobile-tap-audit.mjs; numbers may only decrease';
|
||
fs.writeFileSync(BASELINE, JSON.stringify(fresh, null, 2));
|
||
console.log(`baseline written -> ${BASELINE}`);
|
||
}
|
||
|
||
/* 退出码口径(写死,别每次靠人判断):
|
||
- 够不到 / 被遮挡 / 零尺寸 / pointer-events:none —— 任何一处都是硬失败,控件点不到没有"存量债"可言;
|
||
- 自家注入的抽屉、面板、会话抽屉里的按钮 —— 必须 0,不许留债;
|
||
- 桌面遗留控件的小命中区 —— 走基线只降不升,超基线即红。 */
|
||
const hard = (grand.covered || 0) + (grand.offscreen || 0) + (grand.zero || 0) + (grand['pointer-none'] || 0);
|
||
const ownChrome = ['sheet', 'drawer', 'chat-drawer'].reduce((n, k) => n + ((report.pages[k] || {}).bad || []).length, 0);
|
||
const fail = hard || ownChrome || chromeFail || ratchetFail;
|
||
console.log(`\n判定: 硬缺陷 ${hard} | 自家外壳 ${ownChrome} | 点击失败 ${chromeFail} | 基线回潮 ${ratchetFail ? '是' : '否'} -> ${fail ? 'FAIL' : 'PASS'}`);
|
||
process.exit(fail ? 1 : 0);
|