All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
- 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>
109 lines
7.4 KiB
JavaScript
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} 个弹窗:触发→打开→取消关闭 全通;主操作按钮均存在(未点,避免真删/真扣费/真改密码)。`);
|