// Phase 0 e2e:平台超管后台基础壳 + 路由 gating。 // 沿用 _wave35-auth.mjs 模式:headless 启动 → token 注入 → 真点击走查 → locator 断言 + 截图 + 抓 console/pageerror。 // 跑法:cd core/qa/visual-parity && node _admin-p0.mjs (需 vite 5173 + backend 8010 在跑) import { chromium } from "playwright"; import fs from "node:fs"; import path from "node:path"; const BASE = process.env.BASE || "http://localhost:5173"; const API = process.env.API || "http://127.0.0.1:8010"; const OUT = path.resolve("output/admin"); fs.mkdirSync(OUT, { recursive: true }); async function apiLogin(username, password) { const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!res.ok) throw new Error(`login ${username} -> ${res.status}`); return res.json(); } const r = { realLogin: {}, adminShell: {}, gating: {}, consoleErrors: [], pass: false }; const browser = await chromium.launch({ headless: true }); // 取 token(超管 / 普通) const admin = await apiLogin("admin", "admin123"); const normal = await apiLogin("airshelf", "Restraint2026"); r.tokens = { adminFlag: admin.user.is_platform_admin, adminTeam: admin.team, normalFlag: normal.user.is_platform_admin }; // ── A. 真登录:admin/admin123 → 应跳 /admin ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const p = await ctx.newPage(); p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("realLogin:" + m.text()); }); p.on("pageerror", (e) => r.consoleErrors.push("realLogin:PAGEERR:" + e.message)); await p.goto(BASE + "/login", { waitUntil: "load" }); await p.waitForTimeout(400); await p.fill("#auth-username", "admin"); await p.fill("#auth-pwd", "admin123"); await p.click("button.btn-cta"); try { await p.waitForFunction(() => location.pathname.startsWith("/admin"), { timeout: 12000 }); r.realLogin.landedOnAdmin = true; } catch { r.realLogin.landedOnAdmin = false; } await p.waitForTimeout(500); r.realLogin.url = p.url(); r.realLogin.adminAppShown = (await p.locator(".admin-app").count()) === 1; await p.screenshot({ path: path.join(OUT, "p0-real-login-admin.png") }); await ctx.close(); } // ── B. token 注入超管 → /admin 后台壳走查 ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token); const p = await ctx.newPage(); p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("adminShell:" + m.text()); }); p.on("pageerror", (e) => r.consoleErrors.push("adminShell:PAGEERR:" + e.message)); await p.goto(BASE + "/admin", { waitUntil: "load" }); await p.waitForSelector(".admin-app", { timeout: 12000 }); await p.waitForTimeout(400); r.adminShell.appShown = (await p.locator(".admin-app").count()) === 1; r.adminShell.sidebarShown = (await p.locator(".admin-sidebar").count()) === 1; r.adminShell.badge = (await p.locator(".admin-badge").innerText().catch(() => "")).trim(); r.adminShell.navGroups = await p.locator(".admin-nav-group").count(); r.adminShell.navLinks = await p.locator(".admin-sidebar nav a").count(); r.adminShell.overviewTitle = (await p.locator(".content h1").first().innerText().catch(() => "")).trim(); r.adminShell.shortcuts = await p.locator(".admin-shortcut-grid .shortcut").count(); await p.screenshot({ path: path.join(OUT, "p0-admin-overview.png"), fullPage: true }); // 点「邀请码」section → 应到 /admin/invites,占位「模块即将上线」 await p.locator('.admin-sidebar nav a[href="/admin/invites"]').click(); await p.waitForFunction(() => location.pathname === "/admin/invites", { timeout: 6000 }).catch(() => {}); await p.waitForTimeout(300); r.adminShell.sectionUrl = new URL(p.url()).pathname; r.adminShell.placeholderShown = (await p.locator(".empty-state.show").count()) >= 1; r.adminShell.placeholderText = (await p.locator(".empty-state h3").innerText().catch(() => "")).trim(); await p.screenshot({ path: path.join(OUT, "p0-admin-section-placeholder.png") }); await ctx.close(); } // ── C. token 注入普通用户 → /admin 应被纠回工作台 ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), normal.token); const p = await ctx.newPage(); p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("gating:" + m.text()); }); p.on("pageerror", (e) => r.consoleErrors.push("gating:PAGEERR:" + e.message)); await p.goto(BASE + "/admin", { waitUntil: "load" }); await p.waitForTimeout(1500); r.gating.url = p.url(); r.gating.pathname = new URL(p.url()).pathname; r.gating.redirectedAway = !new URL(p.url()).pathname.startsWith("/admin"); r.gating.adminAppAbsent = (await p.locator(".admin-app").count()) === 0; await p.screenshot({ path: path.join(OUT, "p0-gating-normal-user.png") }); await ctx.close(); } await browser.close(); // 判定 const checks = { adminFlagTrue: r.tokens.adminFlag === true, adminTeamNull: r.tokens.adminTeam === null, normalFlagNotTrue: !r.tokens.normalFlag, realLoginLandedAdmin: r.realLogin.landedOnAdmin === true && r.realLogin.adminAppShown === true, shellShown: r.adminShell.appShown && r.adminShell.sidebarShown, navComplete: r.adminShell.navGroups === 6 && r.adminShell.navLinks === 11, overviewTitle: r.adminShell.overviewTitle === "平台概览", placeholderWorks: r.adminShell.sectionUrl === "/admin/invites" && r.adminShell.placeholderShown, gatingRedirect: r.gating.redirectedAway === true && r.gating.adminAppAbsent === true, zeroConsoleErrors: r.consoleErrors.length === 0, }; r.checks = checks; r.pass = Object.values(checks).every(Boolean); console.log(JSON.stringify(r, null, 2)); fs.writeFileSync(path.join(OUT, "p0-summary.json"), JSON.stringify(r, null, 2)); process.exit(r.pass ? 0 : 1);