AirShelf/core/qa/function-audit/verify-modals.mjs
zyc 890cb9ab67
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
chore(core/qa): function-audit toolchain + parity/audit reports + pixel-perfect skill
- qa/function-audit: playwright 行为审计工具(audit.mjs/verify-modals.mjs/pages.json)
  + 18 页审计产出(*.audit.md/json、summary、运行日志)
- qa/visual-parity: 调试/测量辅助脚本(_dbg*.mjs/_measure.mjs/_off.mjs)
- core/还原度核对报告.md: 18 页 pixelmatch 核对结果(含 vite 代理陈旧坑记录)
- core/还原与接口待办.md: 逐页还原度/真实数据/交互接入待办总表
- .claude/skills/pixel-perfect-react: 像素级还原 React 的 SKILL 文档
- frontend/public/_devlogin.html: 临时本地登录辅助页(可删)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:41:30 +08:00

109 lines
7.4 KiB
JavaScript

// 弹窗/抽屉内部控件验证(主审计只扫「当前可见」,扫不到默认关闭的弹窗内部)。
// 本脚本逐个:导到页面 → 点触发按钮 → 断言弹窗弹出(=触发已接)→ 数内部可交互控件 +
// 点「取消/关闭」断言弹窗关闭(=取消已接)→ 断言主操作按钮存在且未禁用(=已接,但不点,避免真删/真扣费/真改密码)。
//
// 用法:node verify-modals.mjs (需前后端在跑;自动用演示账号登录)
import { chromium } from "playwright";
const BASE = (process.argv.includes("--base") ? process.argv[process.argv.indexOf("--base") + 1] : "http://127.0.0.1:5173").replace(/\/+$/, "");
const EMAIL = "e2e-20260529-0806@airshelf.test";
const PASSWORD = "demo12345";
// 每个场景:在 route 上点 trigger 文案 → 期望浮层出现 → 点 cancel 文案关闭
const SCENARIOS = [
{ name: "settings · 修改密码弹窗", route: "/settings", pre: "安全", trigger: "修改", cancel: "取消", primary: /确认|保存|修改/ },
{ name: "settings · 上传头像弹窗", route: "/settings", trigger: "上传新头像", cancel: "取消", primary: /确认|上传|保存/ },
{ name: "settings · 退出登录确认", route: "/settings", trigger: "退出登录", cancel: "取消", primary: /退出|确认/ },
{ name: "team · 创建成员弹窗", route: "/team", trigger: "创建账户", cancel: "取消", primary: /邀请|确认|创建|添加/ },
{ name: "team · 团队充值弹窗", route: "/team", trigger: "充值", cancel: "取消", primary: /确认充值|充值/ },
{ name: "team · 设置月限额弹窗", route: "/team", trigger: "设置月限额", cancel: "取消", primary: /确认|保存/ },
{ name: "library · 上传资产抽屉", route: "/library", trigger: "上传资产", cancel: "取消", primary: /上传|确认/ },
{ name: "products · 新建商品抽屉", route: "/products", trigger: "新建商品", cancel: "取消", primary: /创建商品|创建/ }
];
const OVERLAY_SEL = ".modal,.drawer,[role=dialog],.team-modal,.modal-bg.show,.drawer-bg.show,.pc-drawer.show,.logout-confirm";
async function login() {
const res = await fetch(`${BASE}/api/auth/login/`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: EMAIL, password: PASSWORD })
});
if (!res.ok) throw new Error("登录失败:" + res.status);
return (await res.json()).token;
}
const HELPERS = `
window.__m = {
vis(el){ const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) return false; const s=getComputedStyle(el); if(s.display==='none'||s.visibility==='hidden'||+s.opacity===0) return false; if(r.right<=0||r.left>=innerWidth||r.bottom<=0||r.top>=innerHeight) return false; return true; },
clickText(txt){ const els=[...document.querySelectorAll('button,[role=button],a,[role=tab],.qa-item,.logout-pill,.nav-item,aside li,aside a,.seg-item,.tab')];
const q=txt.replace(/\\s+/g,'');
const hit=els.filter(e=>this.vis(e)&&(e.textContent||'').replace(/\\s+/g,'').includes(q)).sort((a,b)=>(a.textContent||'').length-(b.textContent||'').length)[0];
if(!hit) return false; hit.click(); return true; },
overlayCount(sel){ return [...document.querySelectorAll(sel)].filter(e=>this.vis(e)).length; },
// 浮层内部可交互控件数(按钮/输入/可点项),排除最终危险确认
innerControls(sel){ const ovs=[...document.querySelectorAll(sel)].filter(e=>this.vis(e)); let n=0; const labels=[];
for(const ov of ovs){ for(const el of ov.querySelectorAll('button,[role=button],input,select,textarea,.tab,.mi,.pay-method-btn')){ if(this.vis(el)){ n++; labels.push((el.textContent||el.getAttribute('placeholder')||el.type||'').replace(/\\s+/g,' ').trim().slice(0,16)); } } }
return { n, labels: labels.filter(Boolean).slice(0,12) }; },
primaryEnabled(sel, re){ const ovs=[...document.querySelectorAll(sel)].filter(e=>this.vis(e));
for(const ov of ovs){ for(const b of ov.querySelectorAll('button')){ if(re.test(b.textContent||'')) return !b.disabled; } } return null; }
};
`;
async function gotoReady(page, token, route) {
await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }).catch(() => {});
await page.evaluate((t) => localStorage.setItem("airshelf_token", t), token);
for (let i = 0; i < 5; i++) {
await page.goto(`${BASE}${route}`, { waitUntil: "networkidle" }).catch(() => {});
const ok = await page.waitForFunction(() => !document.querySelector(".auth-wrap") && document.querySelector("aside.sidebar"), null, { timeout: 9000 }).then(() => true).catch(() => false);
if (ok) { await page.waitForTimeout(500); return true; }
await page.evaluate((t) => localStorage.setItem("airshelf_token", t), token).catch(() => {});
await page.waitForTimeout(700 * (i + 1));
}
return false;
}
const token = await login();
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" });
const page = await ctx.newPage();
const results = [];
for (const s of SCENARIOS) {
const r = { name: s.name, opened: false, cancelClosed: false, inner: 0, innerLabels: [], primaryWired: null, note: "" };
try {
if (!(await gotoReady(page, token, s.route))) { r.note = "页面未就绪(后端?)"; results.push(r); continue; }
await page.evaluate(HELPERS);
if (s.pre) { await page.evaluate((t) => window.__m.clickText(t), s.pre); await page.waitForTimeout(300); }
const before = await page.evaluate((sel) => window.__m.overlayCount(sel), OVERLAY_SEL);
const clicked = await page.evaluate((t) => window.__m.clickText(t), s.trigger);
if (!clicked) { r.note = `没找到触发按钮「${s.trigger}`; results.push(r); continue; }
await page.waitForTimeout(500);
const after = await page.evaluate((sel) => window.__m.overlayCount(sel), OVERLAY_SEL);
r.opened = after > before;
if (r.opened) {
const ic = await page.evaluate((sel) => window.__m.innerControls(sel), OVERLAY_SEL);
r.inner = ic.n; r.innerLabels = ic.labels;
r.primaryWired = await page.evaluate(({ sel, src }) => window.__m.primaryEnabled(sel, new RegExp(src)), { sel: OVERLAY_SEL, src: s.primary.source });
// 点取消关闭(安全);取消文案找不到/没关掉则回退 Esc
await page.evaluate((t) => window.__m.clickText(t), s.cancel);
await page.waitForTimeout(400);
let closedN = await page.evaluate((sel) => window.__m.overlayCount(sel), OVERLAY_SEL);
if (closedN >= after) { await page.keyboard.press("Escape").catch(() => {}); await page.waitForTimeout(300); closedN = await page.evaluate((sel) => window.__m.overlayCount(sel), OVERLAY_SEL); }
r.cancelClosed = closedN < after;
}
} catch (e) { r.note = String(e).slice(0, 80); }
results.push(r);
}
await browser.close();
console.log("\n弹窗/抽屉内部验证(不点最终危险确认)\n");
console.log("| 弹窗 | 触发打开 | 内部控件 | 取消可关 | 主按钮已接 |");
console.log("|---|---|---|---|---|");
for (const r of results) {
console.log(`| ${r.name} | ${r.opened ? "✅" : "❌" + (r.note ? "(" + r.note + ")" : "")} | ${r.opened ? r.inner : "-"} | ${r.opened ? (r.cancelClosed ? "✅" : "❌") : "-"} | ${r.primaryWired === null ? "-" : r.primaryWired ? "✅可用" : "⚪禁用"} |`);
}
const okN = results.filter((r) => r.opened && r.cancelClosed).length;
console.log(`\n${okN}/${results.length} 个弹窗:触发→打开→取消关闭 全通;主操作按钮均存在(未点,避免真删/真扣费/真改密码)。`);