mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
Merge remote-tracking branch 'origin/main' into feat/self-update
# Conflicts: # config.example.yaml
This commit is contained in:
commit
069abc0a7c
21 files changed
+6299
-23
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- 登录密码显隐按钮改为输入框内的眼睛图标,统一输入框宽高,支持键盘焦点、44px 触控区域、中英俄文无障碍标签及浅色/深色主题。
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)。一键更新完成后,会用新代码里这个文件的值
|
||||
# 同步更新你的 config.yaml 的 version 字段(页头版本号与静态资源缓存随代码走)。
|
||||
version: "v1.7.21"
|
||||
version: "v1.7.22"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
## F0 Intake
|
||||
|
||||
用户在确认内置眼睛按钮方案后要求“最佳实践优化”。仓库 main 干净,起点 762f798。独立分支 codex/login-password-eye。
|
||||
Workflow Gate: P7 局部 UI 修整→P9 验证;上游为用户截图及已确认方案;无 API/权限变化,允许实现。
|
||||
Maintainability Gate: auth.js 1014 行、模板 6987 行、style.css 46717 行。大文件风险高,但只替换现有组件的局部标记/样式/标签同步,不新增职责,按 narrow_fix 执行;无需整体重构。
|
||||
|
||||
## F1 Requirements
|
||||
|
||||
范围依据:用户明确要求执行上一轮方案。无需另行产品决策。
|
||||
REQ-001 / AC-001: 密码框与用户名框同宽,右侧内置灰色线条眼睛,图标 20px、触控区域 44px。
|
||||
REQ-002 / AC-002: 原生非提交按钮支持键盘 Enter/Space,焦点可见;显示/隐藏切换不改变密码值。
|
||||
REQ-003 / AC-003: 图标随状态切换,显示密码/隐藏密码标签和悬浮提示随现有语言系统同步;登录框重新打开仍默认隐藏。
|
||||
非目标:后端登录、密码策略及其他密码输入框。
|
||||
|
||||
## F2 Design
|
||||
|
||||
保留原按钮 ID 和 toggleLoginPasswordVisibility 入口;SVG 为装饰图像并 aria-hidden。按钮以 aria-label/title 命名,以现有 aria-pressed 表示状态;data-i18n-skip-text 防止翻译覆盖图标,data-i18n-attr 同步标签。
|
||||
相对定位容器+绝对定位按钮;输入框预留右内边距,图标呈现由 aria-pressed 的 CSS 选择器控制。使用现有主题色、原生按钮交互,不引入依赖。
|
||||
|
||||
## F3 Plan
|
||||
|
||||
修改 index.html 的按钮内部结构,style.css 的局部布局和交互样式,auth.js 的标签同步,扩充已有 password-feedback.test.cjs 的状态/标签断言。
|
||||
验证:已有密码反馈测试、JS syntax、diff check,实际浏览器桌面/窄屏与语言、键盘切换检查。回退可 revert 本功能代码提交。发布记录在 CHANGELOG.md Unreleased;不合并或发布。
|
||||
|
||||
## F4 Implementation
|
||||
|
||||
已替换独立文字按钮为输入框内 20px SVG 眼睛,44px 原生按钮点击区域,登录两输入框最小高度 48px;支持主题色及 focus-visible。JS 不再替换按钮 textContent,使用 aria-label/title 同步现有翻译键;模板标记支持语言重绘。更新 CSS/auth.js 缓存版本。已有密码反馈测试 4/4、JS syntax 和 diff check 通过。
|
||||
|
||||
## F5 Verification
|
||||
|
||||
- node --test web/static/js/password-feedback.test.cjs: 4/4 PASS。
|
||||
- node --check web/static/js/auth.js、git diff --check: PASS。
|
||||
- Headless Chrome / Playwright 使用实际登录模板、style.css/mobile.css、实际显隐函数和 applyTranslations;1280/390/320px 各检查等宽输入框、44×44 点击区、Space/Enter 不提交表单的显隐、值不变、focus-visible、双图标切换和中英俄文 aria-label/title 同步,全部 PASS。
|
||||
- 六张浅色/深色截图已生成,人工查看 1280px 浅色和 320px 深色;布局正常。验证完成登录入场动画后进行尺寸测量。
|
||||
- 未进行真实服务登录和后端测试:本次未改变认证请求或后端。未执行整个 JS 套件。
|
||||
- F0–F3 文档提交 2f2e01e/30b680d/6a31e29/4d397a3;F4 代码提交 d590cdd,均已推送。
|
||||
|
||||
## F6 Review / Release Record
|
||||
|
||||
局部复核完成:CSS 选择器可覆盖共享输入框规则,按钮为 type=button;翻译只更新属性不会移除 SVG;沿用 aria-controls/aria-pressed;auth/CSS URL 版本同时更新避免旧缓存。CHANGELOG.md 已登记 Unreleased 修复。
|
||||
全部完成阶段均保留文档并提交推送至 codex/login-password-eye。未创建 PR、未合并 main、未发布版本。合并前可使用以下说明:
|
||||
|
||||
> 登录页“显示密码”按钮占用输入框宽度并抢占视觉重点。改为内置眼睛按钮,统一两输入框宽高,并保留显隐、键盘和多语言无障碍行为。验证:4 项密码反馈测试、JS syntax/diff check、桌面和两种窄屏 Chrome 检查(浅/深主题、中英俄语言、键盘及点击交互)。
|
||||
|
||||
## F8 Merge Summary — 2026-10-08
|
||||
|
||||
用户授权合并 main。合并前重新 fetch,origin/main 仍为 762f798,4 项密码反馈测试、JS syntax、diff check 再次通过。功能分支以 fast-forward 合并,本地和远端 main 已推送并核对为 4f64347752860add4c0beb2e03a0ae08706e1cf7。核心实现提交 d590cdd;未创建 PR,未发布版本或部署运行服务。本节为合并后的追踪记录,替代 F6 的未合并状态。
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* CyberStrikeAI — mobile shell screenshots.
|
||||
*
|
||||
* Captures the injected mobile chrome (appbar / tab bar / drawer / bottom sheet)
|
||||
* at a phone viewport without needing a login: the chrome is injected by
|
||||
* mobile-ui.js boot() purely from the viewport width, so hiding the login
|
||||
* overlay is enough to make syncAuthChrome re-show the tab bar. Data-driven page
|
||||
* bodies stay empty — this is for judging the *shell*, not the content.
|
||||
*
|
||||
* node scripts/mobile-shot.mjs --state=plain --out=/tmp/shot.png
|
||||
* node scripts/mobile-shot.mjs --state=drawer --base=http://127.0.0.1:8088
|
||||
* states: login | plain | drawer | sheet | chat
|
||||
*/
|
||||
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 || '9713';
|
||||
const BASE = (argv.base || 'http://127.0.0.1:8088').replace(/\/$/, '');
|
||||
const STATE = argv.state || 'plain';
|
||||
const OUT = argv.out || `/tmp/cs-shot/${STATE}-${W}x${H}.png`;
|
||||
|
||||
fs.mkdirSync(dirnameOf(OUT), { recursive: true });
|
||||
function dirnameOf(p) { const i = p.lastIndexOf('/'); return p.slice(0, 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-shot-${PORT}`, '--no-sandbox', 'about:blank'], { stdio: 'ignore' }));
|
||||
process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* gone */ } });
|
||||
|
||||
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();
|
||||
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);
|
||||
}
|
||||
};
|
||||
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']) await send(m, {}, sessionId);
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId);
|
||||
await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId);
|
||||
await send('Network.setUserAgentOverride', {
|
||||
userAgent: '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'
|
||||
}, 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, 200) };
|
||||
return r.result?.value;
|
||||
};
|
||||
|
||||
/* 主题要先落进本站点的 localStorage,about:blank 上写是写给另一个 origin。
|
||||
换主题用 Page.navigate 重新进,不要在 eval 里 location.reload()——awaitPromise
|
||||
会等一个永远不回来的执行上下文。 */
|
||||
await send('Page.navigate', { url: BASE + '/' }, sessionId);
|
||||
await sleep(1500);
|
||||
const theme = argv.theme || (argv.dark === '1' ? 'dark' : '');
|
||||
if (theme) {
|
||||
await ev(`localStorage.setItem('cyberstrike-theme', ${JSON.stringify(theme)})`);
|
||||
await send('Page.navigate', { url: BASE + '/' }, sessionId);
|
||||
}
|
||||
await sleep(3000);
|
||||
|
||||
if (STATE !== 'login') {
|
||||
/* 藏掉登录遮罩会触发 mobile-ui.js 的 MutationObserver,把底部栏显示回来 */
|
||||
await ev(`document.getElementById('login-overlay').style.display='none'`);
|
||||
await sleep(600);
|
||||
if (STATE === 'drawer') await ev(`window.CSAMobile && CSAMobile.openDrawer()`);
|
||||
if (STATE === 'sheet') await ev(`window.CSAMobile && CSAMobile.openSheet()`);
|
||||
if (STATE === 'chat') { await ev(`window.switchPage && switchPage('chat')`); await sleep(1500); }
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
const state = await ev(`(()=>({
|
||||
booted: !!window.CSAMobile,
|
||||
tabbar: !!document.querySelector('#m-tabbar') && getComputedStyle(document.querySelector('#m-tabbar')).display,
|
||||
sheet: !!document.querySelector('#m-sheet'),
|
||||
drawerOpen: !!document.querySelector('#main-sidebar.m-open'),
|
||||
sheetOpen: !!document.querySelector('#m-sheet.m-show'),
|
||||
lang: document.documentElement.getAttribute('lang')
|
||||
}))()`);
|
||||
console.log(JSON.stringify(state));
|
||||
|
||||
/* 带凭据时真的登进去:未登录时对话/列表页的容器根本不渲染,看不到真东西。
|
||||
凭据同样只从参数或环境变量来,绝不写进文件。 */
|
||||
const USER = argv.user || process.env.CSAI_AUDIT_USER || '';
|
||||
const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || '';
|
||||
const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || '';
|
||||
if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: new URL(BASE).hostname, path: '/' }, sessionId);
|
||||
if (USER && PASS) {
|
||||
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(7000);
|
||||
console.log('logged in:', JSON.stringify(await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`)));
|
||||
}
|
||||
if (argv.route) { await ev(`location.hash=${JSON.stringify(argv.route)}`); await sleep(3000); }
|
||||
|
||||
/* --eval='...' 用来在同一个已登录/已注入的页面上量一把真实计算样式 */
|
||||
if (argv.eval) {
|
||||
const probe = await ev(`(function(){${argv.eval}})()`);
|
||||
console.log(JSON.stringify(probe, null, 2));
|
||||
}
|
||||
|
||||
/* --why='.notification-btn' --prop=border-radius
|
||||
规则写了却不生效时,用 CDP 的匹配样式表分清:没命中,还是被更高优先级压住。 */
|
||||
if (argv.why) {
|
||||
await send('DOM.enable', {}, sessionId);
|
||||
await send('CSS.enable', {}, sessionId);
|
||||
const { root } = await send('DOM.getDocument', { depth: -1 }, sessionId);
|
||||
const { nodeId } = await send('DOM.querySelector', { nodeId: root.nodeId, selector: argv.why }, sessionId);
|
||||
if (!nodeId) { console.log('selector 未命中任何节点:', argv.why); }
|
||||
else {
|
||||
const matched = await send('CSS.getMatchedStylesForNode', { nodeId }, sessionId);
|
||||
const want = (argv.prop || 'border-radius,border,background').split(',');
|
||||
const rows = [];
|
||||
for (const e of matched.matchedCSSRules || []) {
|
||||
const m = e.rule;
|
||||
const decl = (m.style.cssProperties || []).filter(p => want.includes(p.name) && !p.disabled && p.value);
|
||||
if (!decl.length) continue;
|
||||
const sel = (m.selectorList && m.selectorList.text) || '?';
|
||||
rows.push({
|
||||
origin: m.origin,
|
||||
sheet: (m.style.styleSheetId || '').split('@').pop(),
|
||||
selector: sel.length > 78 ? sel.slice(0, 78) + '…' : sel,
|
||||
decls: decl.map(d => `${d.name}:${d.value}${d.important ? ' !important' : ''}`),
|
||||
});
|
||||
}
|
||||
console.log(`== ${argv.why} 上命中且声明了 [${want.join(', ')}] 的规则,按级联顺序(后=更优先)==`);
|
||||
rows.forEach((r, i) => console.log(`${String(i).padStart(2)} ${r.origin.padEnd(10)} ${r.decls.join(' | ')}\n ${r.selector}`));
|
||||
const cs = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.why)});const c=getComputedStyle(e);return JSON.stringify(${JSON.stringify(want)}.reduce((o,k)=>(o[k]=c[k],o),{}),null,1)})()`);
|
||||
console.log('最终计算值:', cs);
|
||||
}
|
||||
}
|
||||
|
||||
/* --tap='SEL' 用真手指(只发 touch)点一个选择器,配合 --shots=3 连拍,
|
||||
用来看"点下去之后屏幕上闪出来的那块是什么"。 */
|
||||
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);
|
||||
}
|
||||
if (argv.tap) {
|
||||
const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.tap)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2}})()`);
|
||||
if (!box) console.log('tap 目标不存在:', argv.tap);
|
||||
else {
|
||||
await fingerTap(box.x, box.y);
|
||||
const shots = Number(argv.shots || 3);
|
||||
for (let i = 0; i < shots; i++) {
|
||||
const png = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||||
fs.writeFileSync(OUT.replace(/\.png$/, '') + `-t${i}.png`, Buffer.from(png.data, 'base64'));
|
||||
const who = await ev(`(()=>{const e=document.elementFromPoint(innerWidth/2,innerHeight/2);if(!e)return null;var s=e.tagName.toLowerCase()+(e.id?"#"+e.id:"")+(typeof e.className==="string"&&e.className?"."+e.className.trim().split(/\s+/)[0]:"");var r=e.getBoundingClientRect();var c=getComputedStyle(e);return JSON.stringify({el:s,z:c.zIndex,pos:c.position,box:[Math.round(r.width),Math.round(r.height)],bg:c.backgroundColor})})()`);
|
||||
console.log(`t${i} 视口中央:`, who);
|
||||
await sleep(Number(argv.shotDelay || 300));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||||
fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64'));
|
||||
console.log('shot -> ' + OUT);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* 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);
|
||||
@@ -0,0 +1 @@
|
||||
{"tiny": 3, "page:dashboard": 0, "page:chat": 0, "page:projects": 0, "page:vulnerabilities": 0, "page:tasks": 0, "page:workflows": 0, "page:asset-overview": 0, "page:asset-library": 1, "page:info-collect": 0, "page:hitl": 0, "page:tool-guard": 2, "page:webshell": 0, "page:c2-listeners": 0, "page:c2-sessions": 0, "page:c2-tasks": 0, "page:c2-payloads": 0, "page:c2-events": 0, "page:c2-profiles": 0, "page:chat-files": 0, "page:mcp-monitor": 0, "page:mcp-management": 0, "page:knowledge-management": 0, "page:knowledge-retrieval-logs": 0, "page:roles-management": 0, "page:platform-rbac": 0, "page:skills-monitor": 0, "page:skills-management": 0, "page:agents-management": 0, "page:settings": 0, "page:audit-log": 0, "page:monitor": 0, "page:sheet": 0, "page:drawer": 0, "page:chat-drawer": 0, "chromeFail": 0, "note": "generated by scripts/mobile-tap-audit.mjs; numbers may only decrease"}
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* CyberStrikeAI — API docs page (/api-docs) on phones.
|
||||
* Separate template from index.html, so it needs its own adaptation layer.
|
||||
* Loaded after the template's inline <style> so equal-specificity rules win.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--cs-touch: 44px;
|
||||
--cs-sa-top: env(safe-area-inset-top, 0px);
|
||||
--cs-sa-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--cs-sa-left: env(safe-area-inset-left, 0px);
|
||||
--cs-sa-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html,
|
||||
body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.api-docs-container {
|
||||
padding: 12px calc(12px + var(--cs-sa-right)) calc(24px + var(--cs-sa-bottom)) calc(12px + var(--cs-sa-left));
|
||||
min-height: 100vh;
|
||||
/* svh = 工具栏展开时的最小视口:写 dvh 会让页面恒定比可见区域高出一截,
|
||||
最后一行内容落在 Safari 底栏后面 */
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
/* --- header: the desktop layout reserves 280px of right padding for the
|
||||
action buttons; the title does not need to be 2rem on a phone --- */
|
||||
.api-docs-header {
|
||||
padding-right: 0;
|
||||
padding-bottom: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.api-docs-header h1 {
|
||||
font-size: 1.375rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-docs-header h1 svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.api-docs-header p {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
/* --- two-column grid left only ~86px for the endpoint list --- */
|
||||
.api-docs-content {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* group nav becomes a horizontally scrollable chip row */
|
||||
.api-docs-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.api-docs-sidebar h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.api-group-list {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.api-group-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-group-item {
|
||||
margin-bottom: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.api-group-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-primary);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.api-group-link.active {
|
||||
border-color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- endpoint cards --- */
|
||||
.api-endpoint {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.api-endpoint-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.api-endpoint-title {
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-endpoint-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* every section may hold a wide params table — let it swipe rather than clip */
|
||||
.api-section,
|
||||
.api-endpoint-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-section {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.api-params-table {
|
||||
/* table-layout:fixed + percentage widths + nowrap truncated every
|
||||
param name to "cate…". Size columns to content and swipe instead. */
|
||||
table-layout: auto;
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
max-width: none;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.api-params-table th,
|
||||
.api-params-table td {
|
||||
padding: 9px 10px;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.api-params-table th:nth-child(1),
|
||||
.api-params-table td:nth-child(1),
|
||||
.api-params-table th:nth-child(2),
|
||||
.api-params-table td:nth-child(2),
|
||||
.api-params-table th:nth-child(4),
|
||||
.api-params-table td:nth-child(4),
|
||||
.api-params-table th:nth-child(5),
|
||||
.api-params-table td:nth-child(5) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the long free-text column is the one worth wrapping */
|
||||
.api-params-table th:nth-child(3),
|
||||
.api-params-table td:nth-child(3) {
|
||||
white-space: normal;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* --- code / example blocks scroll on their own --- */
|
||||
.api-code-block,
|
||||
.api-example-code,
|
||||
.api-response-body,
|
||||
pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
/* --- touch targets --- */
|
||||
button,
|
||||
.api-test-btn,
|
||||
.api-toggle,
|
||||
.api-example-tab,
|
||||
a.api-group-link {
|
||||
min-height: var(--cs-touch);
|
||||
}
|
||||
|
||||
.api-test-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.api-test-form input,
|
||||
.api-test-form select,
|
||||
.api-test-form textarea {
|
||||
font-size: 16px; /* <16px makes iOS Safari zoom the whole page on focus */
|
||||
min-height: var(--cs-touch);
|
||||
}
|
||||
|
||||
/* --- auth accordion --- */
|
||||
.api-auth-toggle,
|
||||
.api-docs-auth-header {
|
||||
padding: 14px;
|
||||
min-height: var(--cs-touch);
|
||||
}
|
||||
|
||||
/* --- hover-only affordances must not be the only way to see state --- */
|
||||
.api-group-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.api-endpoint-header:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.api-docs-sidebar {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
}
|
||||
|
||||
/* Horizontal swipe regions: name the axis explicitly so iOS does not have to
|
||||
guess, and keep the page's own vertical scroll from being hijacked. */
|
||||
@media (max-width: 768px) {
|
||||
.api-table-wrapper,
|
||||
.api-group-list,
|
||||
.api-code-block,
|
||||
.api-example-code,
|
||||
pre {
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.api-docs-sidebar,
|
||||
.api-docs-main {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The API docs page does not load mobile-nav.js, so the generic touch pass
|
||||
that runs on the SPA never reaches these form fields and swipe regions. */
|
||||
@media (max-width: 768px) {
|
||||
textarea,
|
||||
input,
|
||||
select,
|
||||
.api-test-form textarea,
|
||||
.api-test-form input,
|
||||
.api-test-form select,
|
||||
.api-table-wrapper,
|
||||
.api-group-list,
|
||||
.api-code-block,
|
||||
.api-example-code,
|
||||
pre {
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.api-docs-header-actions button,
|
||||
.api-docs-header-actions .btn-secondary,
|
||||
#theme-toggle-btn,
|
||||
.auth-info-header,
|
||||
.api-auth-toggle {
|
||||
min-height: var(--cs-touch);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-info-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.api-test-form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-test-form textarea {
|
||||
min-height: 88px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6551,19 +6551,59 @@ html[data-theme="dark"] .chat-input-container .send-btn:disabled::before {
|
||||
}
|
||||
|
||||
.login-password-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-password-field input {
|
||||
.login-form .login-password-field input[type="password"],
|
||||
.login-form .login-password-field input[type="text"] {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-inline-end: 52px;
|
||||
}
|
||||
|
||||
.login-password-field button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
.login-password-toggle {
|
||||
position: absolute;
|
||||
inset-inline-end: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.login-password-toggle:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-password-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.login-password-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-password-icon--hide,
|
||||
.login-password-toggle[aria-pressed="true"] .login-password-icon--show {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-password-toggle[aria-pressed="true"] .login-password-icon--hide {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.password-field-error {
|
||||
@@ -6587,6 +6627,7 @@ html[data-theme="dark"] .chat-input-container .send-btn:disabled::before {
|
||||
|
||||
.login-form input[type="text"],
|
||||
.login-form input[type="password"] {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 0.9375rem;
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
"enUS": "English",
|
||||
"ruRU": "Русский"
|
||||
},
|
||||
"mobile": {
|
||||
"openNav": "Open navigation menu",
|
||||
"closeNav": "Close navigation menu",
|
||||
"moreActions": "More actions",
|
||||
"searchMenu": "Search menu…",
|
||||
"noMatch": "No matching menu items",
|
||||
"backToTop": "Back to top",
|
||||
"chatList": "Conversations",
|
||||
"closeChatList": "Close conversation list",
|
||||
"mainNav": "Main navigation",
|
||||
"switchTheme": "Switch theme",
|
||||
"themeNow": "Theme: {{name}}",
|
||||
"switchLanguage": "Switch language",
|
||||
"refreshPage": "Reload page",
|
||||
"fullscreen": "Fullscreen",
|
||||
"fullscreenUnsupported": "This browser does not support fullscreen",
|
||||
"conversations": "Chats",
|
||||
"newChat": "New chat",
|
||||
"chatModuleMissing": "Chat module not loaded yet"
|
||||
},
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
"enUS": "English",
|
||||
"ruRU": "Русский"
|
||||
},
|
||||
"mobile": {
|
||||
"openNav": "Открыть меню навигации",
|
||||
"closeNav": "Закрыть меню навигации",
|
||||
"moreActions": "Другие действия",
|
||||
"searchMenu": "Поиск меню…",
|
||||
"noMatch": "Совпадений нет",
|
||||
"backToTop": "Наверх",
|
||||
"chatList": "Список чатов",
|
||||
"closeChatList": "Закрыть список чатов",
|
||||
"mainNav": "Основная навигация",
|
||||
"switchTheme": "Сменить тему",
|
||||
"themeNow": "Тема: {{name}}",
|
||||
"switchLanguage": "Сменить язык",
|
||||
"refreshPage": "Обновить страницу",
|
||||
"fullscreen": "Полный экран",
|
||||
"fullscreenUnsupported": "Браузер не поддерживает полноэкранный режим",
|
||||
"conversations": "Чаты",
|
||||
"newChat": "Новый чат",
|
||||
"chatModuleMissing": "Модуль чата ещё не загружен"
|
||||
},
|
||||
"common": {
|
||||
"ok": "ОК",
|
||||
"cancel": "Отмена",
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
"enUS": "English",
|
||||
"ruRU": "Русский"
|
||||
},
|
||||
"mobile": {
|
||||
"openNav": "打开导航菜单",
|
||||
"closeNav": "关闭导航菜单",
|
||||
"moreActions": "更多操作",
|
||||
"searchMenu": "搜索菜单…",
|
||||
"noMatch": "没有匹配的菜单",
|
||||
"backToTop": "返回顶部",
|
||||
"chatList": "会话列表",
|
||||
"closeChatList": "关闭会话列表",
|
||||
"mainNav": "主导航",
|
||||
"switchTheme": "切换主题",
|
||||
"themeNow": "当前主题:{{name}}",
|
||||
"switchLanguage": "切换语言",
|
||||
"refreshPage": "刷新页面",
|
||||
"fullscreen": "全屏",
|
||||
"fullscreenUnsupported": "当前浏览器不支持全屏",
|
||||
"conversations": "会话",
|
||||
"newChat": "新对话",
|
||||
"chatModuleMissing": "对话模块尚未加载"
|
||||
},
|
||||
"common": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消",
|
||||
|
||||
@@ -100,7 +100,9 @@ function setLoginPasswordVisible(visible) {
|
||||
button.setAttribute('aria-pressed', String(visible));
|
||||
const key = visible ? 'login.hidePassword' : 'login.showPassword';
|
||||
button.setAttribute('data-i18n', key);
|
||||
button.textContent = typeof window.t === 'function' ? window.t(key) : (visible ? '隐藏密码' : '显示密码');
|
||||
const label = typeof window.t === 'function' ? window.t(key) : (visible ? '隐藏密码' : '显示密码');
|
||||
button.setAttribute('aria-label', label);
|
||||
button.setAttribute('title', label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
/* 移动端注入层的文案键契约:mobile-ui.js 引用的每个 i18n 键必须在三种语言里都存在,
|
||||
否则手机上会出现「点开关却看到 mobile.openNav」这类内部键名裸露。
|
||||
遍历域 = 传播域:凡是能写出 data-i18n 的三种入口都要抽,缺一个入口就会漏检。 */
|
||||
|
||||
const LOCALES = ['zh-CN', 'en-US', 'ru-RU'];
|
||||
const js = fs.readFileSync('web/static/js/mobile-ui.js', 'utf8');
|
||||
|
||||
function lookup(dict, key) {
|
||||
return key.split('.').reduce((acc, part) => (acc && typeof acc === 'object' ? acc[part] : undefined), dict);
|
||||
}
|
||||
|
||||
function referencedKeys(source) {
|
||||
const patterns = [
|
||||
/label\(\s*[A-Za-z_$][\w$]*\s*,\s*'([^']+)'/g,
|
||||
/ariaLabel\(\s*[A-Za-z_$][\w$]*\s*,\s*'([^']+)'/g,
|
||||
/setAttrI18n\(\s*[A-Za-z_$][\w$]*\s*,\s*'[^']+'\s*,\s*'([^']+)'/g,
|
||||
/translate\(\s*'([^']+)'/g,
|
||||
/buildSheetItem\(\s*'([^']+)'/g,
|
||||
/labelKey:\s*'([^']+)'/g,
|
||||
];
|
||||
const keys = new Set();
|
||||
for (const re of patterns) {
|
||||
let m;
|
||||
while ((m = re.exec(source))) keys.add(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const keys = referencedKeys(js);
|
||||
|
||||
test('至少抽到一批键,防止正则整体失配后空集通过', () => {
|
||||
assert.ok(keys.size >= 16, `expected >=20 referenced keys, got ${keys.size}`);
|
||||
assert.ok(keys.has('mobile.conversations'), 'tab labels must go through i18n');
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
test(`${locale} 覆盖移动端引用的每一个键`, () => {
|
||||
const dict = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
|
||||
const missing = [...keys].filter(k => typeof lookup(dict, k) !== 'string' || !lookup(dict, k).trim());
|
||||
assert.deepEqual(missing, [], `${locale} 缺少可用译文: ${missing.join(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('三种语言的 mobile 命名空间键集完全一致', () => {
|
||||
const sets = LOCALES.map(l => Object.keys(JSON.parse(fs.readFileSync(`web/static/i18n/${l}.json`, 'utf8')).mobile).sort());
|
||||
assert.deepEqual(sets[1], sets[0], 'en-US 与 zh-CN 键集不同');
|
||||
assert.deepEqual(sets[2], sets[0], 'ru-RU 与 zh-CN 键集不同');
|
||||
});
|
||||
|
||||
test('带插值的文案在三种语言里都保留占位符', () => {
|
||||
for (const locale of LOCALES) {
|
||||
const dict = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
|
||||
assert.match(lookup(dict, 'mobile.themeNow'), /\{\{\s*name\s*\}\}/, `${locale} 的 themeNow 丢了 {{name}}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('语言与主题动作复用桌面端既有键,不另立一套', () => {
|
||||
assert.ok(keys.has('header.apiDocs'), 'API 文档应复用 header.apiDocs');
|
||||
assert.ok(keys.has('header.github'), 'GitHub 应复用 header.github 而不是另立键');
|
||||
assert.ok(keys.has('nav.dashboard'), '底部标签应复用 nav.* 分组名');
|
||||
});
|
||||
|
||||
test('注入层不再残留会直接显示给用户的硬编码中文', () => {
|
||||
/* 判据与函数名无关:helper 区之外不许出现任何"裸写用户可见文本"的 API。
|
||||
helper 区 = 从文案注释块起到 toast 定义前,四件套 label/translate/setAttrI18n/ariaLabel 都在里面。 */
|
||||
const from = js.indexOf('/* 注入节点的文案');
|
||||
const to = js.indexOf('function toast(msg, ms)');
|
||||
assert.ok(from !== -1 && to > from, '找不到文案 helper 区,说明 helper 被搬散或改名');
|
||||
const outside = js.slice(0, from) + js.slice(to);
|
||||
|
||||
const bypasses = [
|
||||
/toast\(\s*'[^']*[一-鿿][^']*'/,
|
||||
/setAttribute\(\s*'aria-label'\s*,/,
|
||||
/setAttribute\(\s*'placeholder'\s*,/,
|
||||
/\.placeholder\s*=\s*'[^']*[一-鿿]/,
|
||||
/\.textContent\s*=\s*'[^']*[一-鿿]/,
|
||||
/el\(\s*'[a-zA-Z]+'\s*,\s*'[^']*'\s*,\s*'[^']*[一-鿿]/,
|
||||
];
|
||||
for (const re of bypasses) {
|
||||
const hit = outside.match(re);
|
||||
assert.equal(hit, null, `绕过 i18n 通道的写法: ${hit && hit[0]}`);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -42,10 +42,18 @@ test('password preview toggles accessibly and can be reset without changing the
|
||||
h.ctx.toggleLoginPasswordVisibility();
|
||||
assert.equal(h.fields['login-password'].type,'text');
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('aria-pressed'),'true');
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('aria-label'),'login.hidePassword');
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('title'),'login.hidePassword');
|
||||
h.ctx.setLoginPasswordVisible(false);
|
||||
assert.equal(h.fields['login-password'].type,'password');
|
||||
assert.equal(h.fields['login-password'].value,'test-only-password');
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('data-i18n'),'login.showPassword');
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('aria-label'),'login.showPassword');
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('title'),'login.showPassword');
|
||||
assert.equal(h.fields['login-password-toggle'].textContent,undefined);
|
||||
h.ctx.window.t=undefined;
|
||||
h.ctx.toggleLoginPasswordVisibility();
|
||||
assert.equal(h.fields['login-password-toggle'].getAttribute('aria-label'),'隐藏密码');
|
||||
});
|
||||
test('password form has field-specific errors without requesting a save',async () => {
|
||||
const h=harness({'auth-new-password':'short','auth-confirm-password':'different'});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, 'workflows.js'), 'utf8');
|
||||
|
||||
function createEditor() {
|
||||
const elements = {
|
||||
'workflow-id': { value: '' },
|
||||
'workflow-name': { value: '' },
|
||||
'workflow-description': { value: '' },
|
||||
'workflow-enabled': { checked: true },
|
||||
'workflow-canvas': {}
|
||||
};
|
||||
const nodes = [
|
||||
{ id: 'start', type: 'start', config: {} },
|
||||
{ id: 'output', type: 'output', config: { output_key: 'result' } }
|
||||
].map(data => ({ id: () => data.id, data: key => data[key], position: () => ({ x: 0, y: 0 }) }));
|
||||
const state = { calls: [], notifications: [], workflows: [], modalOpen: false };
|
||||
const context = {
|
||||
document: { getElementById: id => elements[id] || null, querySelector: () => null, addEventListener() {} },
|
||||
cytoscape: () => ({
|
||||
nodes: () => nodes,
|
||||
edges: () => [{ id: () => 'edge', source: () => nodes[0], target: () => nodes[1], data: () => '' }],
|
||||
on() {}, resize() {}
|
||||
}),
|
||||
openAppModal: () => { state.modalOpen = true; },
|
||||
closeAppModal: () => { state.modalOpen = false; },
|
||||
showNotification: (message, type) => state.notifications.push({ message, type }),
|
||||
apiFetch: async (url, options = {}) => {
|
||||
const body = options.body ? JSON.parse(options.body) : undefined;
|
||||
state.calls.push({ url, method: options.method || 'GET', body });
|
||||
if (url === '/api/workflows/validate') {
|
||||
if (state.validationGate) await state.validationGate;
|
||||
return { ok: true };
|
||||
}
|
||||
if (options.method === 'POST' || options.method === 'PUT') {
|
||||
const workflow = { ...body, graph_json: JSON.stringify(body.graph), version: 1 };
|
||||
state.workflows = [workflow];
|
||||
return { ok: true, json: async () => ({ workflow }) };
|
||||
}
|
||||
if (url.startsWith('/api/workflows?')) return { ok: true, json: async () => ({ workflows: state.workflows }) };
|
||||
throw new Error('Unexpected API: ' + url);
|
||||
}
|
||||
};
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context, { filename: 'workflows.js' });
|
||||
const fillMeta = () => {
|
||||
elements['workflow-id'].value = 'save-regression';
|
||||
elements['workflow-name'].value = 'Save regression';
|
||||
};
|
||||
const saves = () => state.calls.filter(call => call.url === '/api/workflows' && call.method === 'POST');
|
||||
return { context, state, elements, fillMeta, saves };
|
||||
}
|
||||
|
||||
test('Save resumes after metadata confirmation and submits the graph once', async () => {
|
||||
const editor = createEditor();
|
||||
await editor.context.saveWorkflowDraft();
|
||||
assert.equal(editor.state.modalOpen, true);
|
||||
assert.equal(editor.state.calls.length, 0);
|
||||
editor.fillMeta();
|
||||
let finishValidation;
|
||||
editor.state.validationGate = new Promise(resolve => { finishValidation = resolve; });
|
||||
const saving = editor.context.applyWorkflowMetaModal();
|
||||
await editor.context.applyWorkflowMetaModal();
|
||||
assert.equal(editor.state.calls.filter(call => call.url === '/api/workflows/validate').length, 1);
|
||||
assert.equal(editor.saves().length, 0);
|
||||
finishValidation();
|
||||
await saving;
|
||||
assert.equal(editor.saves().length, 1);
|
||||
assert.equal(editor.saves()[0].body.name, 'Save regression');
|
||||
assert.deepEqual(editor.saves()[0].body.graph.nodes.map(node => node.id), ['start', 'output']);
|
||||
assert.equal(editor.state.modalOpen, false);
|
||||
assert.equal(editor.state.notifications.at(-1).message, 'workflows.saved');
|
||||
assert.ok(editor.state.calls.some(call => call.url.startsWith('/api/workflows?')));
|
||||
});
|
||||
|
||||
test('Cancel clears a pending save; reopening metadata only edits the draft', async () => {
|
||||
const editor = createEditor();
|
||||
await editor.context.saveWorkflowDraft();
|
||||
editor.context.closeWorkflowMetaModal();
|
||||
editor.fillMeta();
|
||||
await editor.context.applyWorkflowMetaModal();
|
||||
assert.equal(editor.state.calls.length, 0);
|
||||
await editor.context.saveWorkflowDraft();
|
||||
assert.equal(editor.saves().length, 1);
|
||||
const callCount = editor.state.calls.length;
|
||||
editor.context.openWorkflowMetaModal();
|
||||
await editor.context.applyWorkflowMetaModal();
|
||||
assert.equal(editor.state.calls.length, callCount);
|
||||
});
|
||||
|
||||
test('Opening metadata normally replaces pending save intent', async () => {
|
||||
const editor = createEditor();
|
||||
await editor.context.saveWorkflowDraft();
|
||||
editor.context.openWorkflowMetaModal();
|
||||
editor.fillMeta();
|
||||
await editor.context.applyWorkflowMetaModal();
|
||||
assert.equal(editor.state.calls.length, 0);
|
||||
});
|
||||
|
||||
test('Invalid metadata keeps the pending save until corrected', async () => {
|
||||
const editor = createEditor();
|
||||
await editor.context.saveWorkflowDraft();
|
||||
await editor.context.applyWorkflowMetaModal();
|
||||
assert.equal(editor.state.modalOpen, true);
|
||||
assert.equal(editor.state.calls.length, 0);
|
||||
editor.fillMeta();
|
||||
await editor.context.applyWorkflowMetaModal();
|
||||
assert.equal(editor.saves().length, 1);
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
let workflows = [];
|
||||
let currentWorkflowId = '';
|
||||
let workflowMetaSavePending = false;
|
||||
let cy = null;
|
||||
let nodeSeq = 1;
|
||||
let edgeSeq = 1;
|
||||
@@ -1993,7 +1994,8 @@
|
||||
if (wf) fillWorkflowForm(wf);
|
||||
};
|
||||
|
||||
window.openWorkflowMetaModal = function () {
|
||||
window.openWorkflowMetaModal = function (options) {
|
||||
workflowMetaSavePending = !!(options && options.saveOnApply);
|
||||
const nameEl = document.getElementById('workflow-name');
|
||||
const idEl = document.getElementById('workflow-id');
|
||||
if (currentWorkflowId) {
|
||||
@@ -2009,12 +2011,13 @@
|
||||
};
|
||||
|
||||
window.closeWorkflowMetaModal = function () {
|
||||
workflowMetaSavePending = false;
|
||||
if (typeof closeAppModal === 'function') {
|
||||
closeAppModal('workflow-meta-modal');
|
||||
}
|
||||
};
|
||||
|
||||
window.applyWorkflowMetaModal = function () {
|
||||
window.applyWorkflowMetaModal = async function () {
|
||||
const meta = readWorkflowMetaFromForm();
|
||||
if (!meta.id || !meta.name) {
|
||||
if (typeof showNotification === 'function') {
|
||||
@@ -2024,7 +2027,9 @@
|
||||
}
|
||||
updateWorkflowCanvasTitle();
|
||||
renderWorkflowList();
|
||||
const shouldSave = workflowMetaSavePending;
|
||||
closeWorkflowMetaModal();
|
||||
if (shouldSave) await saveWorkflowDraft();
|
||||
};
|
||||
|
||||
window.editWorkflowFromList = function (id) {
|
||||
@@ -2232,7 +2237,7 @@
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(_t('workflows.idNameRequired'), 'error');
|
||||
}
|
||||
openWorkflowMetaModal();
|
||||
openWorkflowMetaModal({ saveOnApply: true });
|
||||
return;
|
||||
}
|
||||
const graph = elementsToGraph();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "CyberStrikeAI",
|
||||
"short_name": "CyberStrikeAI",
|
||||
"description": "AI 安全测试平台 WebUI",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#f8fafc",
|
||||
"icons": [
|
||||
{ "src": "/static/logo.png", "sizes": "any", "type": "image/png", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0b1120" media="(prefers-color-scheme: dark)">
|
||||
<title data-i18n="apiDocs.pageTitle">API 文档 - CyberStrikeAI</title>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
@@ -947,6 +949,8 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- 移动端样式层:只覆盖 767.98px 以下,写在页内样式之后才能同特异性胜出 -->
|
||||
<link rel="stylesheet" href="/static/css/api-docs-mobile.css?v=20261006-1">
|
||||
</head>
|
||||
<body>
|
||||
<div class="api-docs-container">
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0b1120" media="(prefers-color-scheme: dark)">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<link rel="manifest" href="/static/site.webmanifest">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="CyberStrikeAI">
|
||||
<title>CyberStrikeAI</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||
@@ -31,11 +39,13 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260907-blocked-1">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20261008-password-eye-1">
|
||||
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
|
||||
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
|
||||
<link rel="stylesheet" href="/static/css/c2.css">
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=20261007-glass-1">
|
||||
<script src="/static/js/router.js?v=20260907-1"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -59,7 +69,15 @@
|
||||
<label for="login-password" data-i18n="login.passwordLabel">密码</label>
|
||||
<div class="login-password-field">
|
||||
<input type="password" id="login-password" placeholder="输入登录密码" required autocomplete="current-password" aria-describedby="login-error" data-i18n="login.passwordPlaceholder" data-i18n-attr="placeholder" />
|
||||
<button type="button" id="login-password-toggle" class="btn-secondary" aria-controls="login-password" aria-pressed="false" onclick="toggleLoginPasswordVisibility()" data-i18n="login.showPassword">显示密码</button>
|
||||
<button type="button" id="login-password-toggle" class="login-password-toggle" aria-label="显示密码" title="显示密码" aria-controls="login-password" aria-pressed="false" onclick="toggleLoginPasswordVisibility()" data-i18n="login.showPassword" data-i18n-attr="aria-label,title" data-i18n-skip-text="true">
|
||||
<svg class="login-password-icon login-password-icon--show" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<svg class="login-password-icon login-password-icon--hide" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="m3 3 18 18M10.6 5.1A12 12 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3 4M6.2 6.2A18 18 0 0 0 2 12s3.5 7 10 7a12 12 0 0 0 5.8-1.8M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="login-error" class="login-error" role="alert" style="display: none;"></div>
|
||||
@@ -1257,7 +1275,7 @@
|
||||
</div>
|
||||
<div class="chat-composer-footer">
|
||||
<div class="chat-composer-footer-leading">
|
||||
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" data-i18n="chat.selectFile" data-i18n-attr="title" title="选择文件">
|
||||
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*/*" data-i18n="chat.selectFile" data-i18n-attr="title" title="选择文件">
|
||||
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" data-i18n="chat.uploadFile" data-i18n-attr="title,aria-label" data-i18n-skip-text="true" title="上传文件(可多选或拖拽到此处)" aria-label="上传文件(可多选或拖拽到此处)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
@@ -5812,10 +5830,32 @@
|
||||
<script src="/static/js/sanitize-markdown.js"></script>
|
||||
<!-- Cytoscape.js for attack chain visualization -->
|
||||
<script src="/static/vendor/cytoscape.min.js"></script>
|
||||
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
|
||||
<script src="/static/vendor/elk.bundled.js"></script>
|
||||
<!-- SheetJS for XLSX export (info-collect) -->
|
||||
<script src="/static/vendor/xlsx.full.min.js"></script>
|
||||
<!-- ELK.js (1.5MB) + SheetJS (860KB):移动端延迟加载,首屏不再阻塞解析。
|
||||
桌面端走 document.write 分支,保持与静态 <script src> 完全一致的同步语义。
|
||||
安全依据:chat.js:7450 与 fact-graph.js:491 均有 typeof ELK 守卫并降级为默认布局;
|
||||
assets.js:127/234/1024 与 info-collect.js:1537 均有 XLSX 守卫。 -->
|
||||
<script>
|
||||
(function () {
|
||||
var LAZY_VENDOR = ['/static/vendor/elk.bundled.js', '/static/vendor/xlsx.full.min.js'];
|
||||
var mq = window.matchMedia && window.matchMedia('(max-width: 767.98px)').matches;
|
||||
if (!mq) {
|
||||
for (var i = 0; i < LAZY_VENDOR.length; i++) {
|
||||
document.write('<script src="' + LAZY_VENDOR[i] + '"><\/script>');
|
||||
}
|
||||
return;
|
||||
}
|
||||
var load = function () {
|
||||
LAZY_VENDOR.forEach(function (src) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = false;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
if (window.requestIdleCallback) { requestIdleCallback(load, { timeout: 2000 }); }
|
||||
else { setTimeout(load, 1200); }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// 确保ELK对象全局可用
|
||||
if (typeof ELK === 'undefined' && typeof elk !== 'undefined') {
|
||||
@@ -6927,7 +6967,7 @@
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/builtin-tools.js"></script>
|
||||
<script src="/static/js/auth.js?v=20260907-blocked-1"></script>
|
||||
<script src="/static/js/auth.js?v=20261008-password-eye-1"></script>
|
||||
<script src="/static/js/modal.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/info-collect.js?v=20260717-1"></script>
|
||||
@@ -6963,5 +7003,7 @@
|
||||
<script src="/static/js/rbac.js"></script>
|
||||
<script src="/static/js/c2.js"></script>
|
||||
<script src="/static/js/rbac-guards.js?v=20260907-1"></script>
|
||||
<!-- 移动端交互层:必须最后加载,它包装 window.switchPage 并注入移动端 DOM -->
|
||||
<script src="/static/js/mobile-ui.js?v=20261006-9"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user