// 弹窗/抽屉内部控件验证(主审计只扫「当前可见」,扫不到默认关闭的弹窗内部)。 // 本脚本逐个:导到页面 → 点触发按钮 → 断言弹窗弹出(=触发已接)→ 数内部可交互控件 + // 点「取消/关闭」断言弹窗关闭(=取消已接)→ 断言主操作按钮存在且未禁用(=已接,但不点,避免真删/真扣费/真改密码)。 // // 用法: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} 个弹窗:触发→打开→取消关闭 全通;主操作按钮均存在(未点,避免真删/真扣费/真改密码)。`);