// Phase 3 e2e:平台团队 + 用户管理 —— 列表/详情/启停(团队停用→成员登录被拒)/强制改密。 // 用一次性 P3 团队做启停与改密,绝不动 demo 团队。headless + token 注入 + 真点击 + 0 console error。 import { chromium } from "playwright"; import fs from "node:fs"; import path from "node:path"; const BASE = process.env.BASE || "http://127.0.0.1:5188"; const API = process.env.API || "http://127.0.0.1:8010"; const OUT = path.resolve("output/admin"); fs.mkdirSync(OUT, { recursive: true }); const stamp = Date.now(); const OWNER = `p3o-${stamp}`; const TEAM = `P3Team${stamp}`; async function apiLogin(u, p) { const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) }); return { status: res.status, body: res.ok ? await res.json() : null }; } const admin = (await apiLogin("admin", "admin123")).body; // 一次性团队:超管发开团队码 → 注册 P3 owner 开团队 const inviteRes = await fetch(`${API}/api/admin/invitations/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Token ${admin.token}` }, body: JSON.stringify({}) }); const code = (await inviteRes.json()).code; await fetch(`${API}/api/auth/register/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: OWNER, password: "ownerpass1", team_name: TEAM, invite_code: code }) }); const r = { teams: {}, toggle: {}, users: {}, reset: {}, consoleErrors: [], pass: false }; const browser = await chromium.launch({ headless: true }); const hook = (p, tag) => { p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); }); p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`)); }; 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(); hook(p, "teams"); await p.goto(BASE + "/admin/teams", { waitUntil: "load" }); await p.waitForSelector(".admin-app", { timeout: 12000 }); await p.waitForSelector(".admin-table, .empty-state.show", { timeout: 8000 }).catch(() => {}); r.teams.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim(); await p.screenshot({ path: path.join(OUT, "p3-teams-list.png"), fullPage: true }); // 搜索我们的一次性团队 await p.fill(".admin-search", TEAM); await p.waitForTimeout(800); r.teams.foundOurTeam = (await p.locator(".admin-table tbody tr").count()) === 1; // 详情弹窗 await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click(); await p.waitForSelector(".modal", { timeout: 6000 }); await p.waitForTimeout(400); r.teams.detailMembers = await p.locator(".modal .admin-table tbody tr").count(); await p.screenshot({ path: path.join(OUT, "p3-team-detail.png") }); await p.locator(".modal-f .btn").click(); await p.waitForTimeout(300); // 停用 → 成员登录应被拒 await p.locator(".admin-table tbody tr:first-child .btn-ghost.danger").click(); await p.waitForTimeout(900); r.toggle.afterDisableLogin = (await apiLogin(OWNER, "ownerpass1")).status; // 期望 400 // 启用 → 恢复可登 await p.locator(".admin-table tbody tr:first-child .btn-ghost").last().click(); await p.waitForTimeout(900); r.toggle.afterEnableLogin = (await apiLogin(OWNER, "ownerpass1")).status; // 期望 200 } // ── 用户页 ── { const p = await ctx.newPage(); hook(p, "users"); await p.goto(BASE + "/admin/users", { waitUntil: "load" }); await p.waitForSelector(".admin-app", { timeout: 12000 }); await p.waitForSelector(".admin-table, .empty-state.show", { timeout: 8000 }).catch(() => {}); r.users.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim(); await p.screenshot({ path: path.join(OUT, "p3-users-list.png"), fullPage: true }); // admin 行应有「超管」标记且不可启停(搜 admin) await p.fill(".admin-search", "admin"); await p.waitForTimeout(700); r.users.adminHasBadge = (await p.locator(".admin-table tbody tr:first-child .admin-inline-pill").count()) >= 1; // 改密一次性 owner await p.fill(".admin-search", OWNER); await p.waitForTimeout(700); r.users.foundOwner = (await p.locator(".admin-table tbody tr").count()) === 1; await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click(); // 改密 await p.waitForSelector(".modal", { timeout: 6000 }); await p.fill("#reset-pwd", "p3newpass1"); await p.locator(".modal-f .btn-primary").click(); await p.waitForTimeout(900); r.reset.newPwdLogin = (await apiLogin(OWNER, "p3newpass1")).status; // 期望 200 await p.screenshot({ path: path.join(OUT, "p3-after-reset.png") }); } await ctx.close(); await browser.close(); const checks = { teamsPage: r.teams.title === "团队" && r.teams.foundOurTeam === true, teamDetail: r.teams.detailMembers >= 1, disableBlocksLogin: r.toggle.afterDisableLogin === 400, enableRestoresLogin: r.toggle.afterEnableLogin === 200, usersPage: r.users.title === "用户" && r.users.adminHasBadge === true && r.users.foundOwner === true, resetPasswordWorks: r.reset.newPwdLogin === 200, 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, "p3-summary.json"), JSON.stringify(r, null, 2)); process.exit(r.pass ? 0 : 1);