// Phase 1 e2e:注册码优先流程 —— 验码才展开、create_team 开团队 / join_team 加入(只读团队名)、错码拦截、URL 自动验码。 // 沿用 _wave35-auth.mjs 模式:headless + 真点击 + locator 断言 + 截图 + 0 console error。 // create_team 码经 wrapper 用 manage.py shell 铸好,传 CT1/CT2 env;join_team 码经 airshelf owner API 发。 // 跑法:见 _admin-p1.run.sh(wrapper 负责铸码 + 传 env)。 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 CT1 = process.env.CT1; const CT2 = process.env.CT2; const OUT = path.resolve("output/admin"); fs.mkdirSync(OUT, { recursive: true }); const stamp = Date.now(); 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 }), }); if (!res.ok) throw new Error(`login ${u} -> ${res.status}`); return res.json(); } const r = { createTeam: {}, joinTeam: {}, invalid: {}, urlAuto: {}, consoleErrors: [], pass: false }; if (!CT1 || !CT2) { console.error("缺 CT1/CT2(create_team 码),请用 wrapper 跑"); process.exit(2); } // 用 airshelf owner 发一个 join_team 码(返回含 team_name) const owner = await apiLogin("airshelf", "Restraint2026"); const genRes = await fetch(`${API}/api/auth/team/invitations/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Token ${owner.token}` }, body: JSON.stringify({ role: "member" }), }); const gen = await genRes.json(); const JT = gen.code; r.joinTeam.expectedTeam = gen.team_name || owner.team?.name || ""; 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}`)); }; // ── A. create_team:验码 → 展开团队名输入 → 注册 → 进应用 ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const p = await ctx.newPage(); hook(p, "createTeam"); await p.goto(BASE + "/register", { waitUntil: "load" }); await p.waitForTimeout(400); r.createTeam.usernameHiddenBeforeVerify = (await p.locator("#reg-username").count()) === 0; await p.fill("#reg-invite", CT1); await p.click(".invite-verify-btn"); await p.waitForSelector("#reg-team", { timeout: 8000 }).catch(() => {}); await p.waitForTimeout(300); r.createTeam.teamInputShown = (await p.locator("#reg-team").count()) === 1; r.createTeam.okNote = (await p.locator(".field-note.ok").count()) >= 1; await p.screenshot({ path: path.join(OUT, "p1-createteam-verified.png") }); await p.fill("#reg-team", `E2E团队${stamp}`); await p.fill("#reg-username", `p1-ct-${stamp}`); await p.fill("#reg-pwd", "strong-pass-1"); await p.fill("#reg-pwd2", "strong-pass-1"); await p.click("button.btn-cta"); try { await p.waitForFunction(() => !location.pathname.startsWith("/register"), { timeout: 15000 }); r.createTeam.registered = true; } catch { r.createTeam.registered = false; } await p.waitForTimeout(400); r.createTeam.url = p.url(); await ctx.close(); } // ── B. join_team:验码 → 只读团队名(不让填团队名)→ 注册 → 进应用 ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const p = await ctx.newPage(); hook(p, "joinTeam"); await p.goto(BASE + "/register", { waitUntil: "load" }); await p.waitForTimeout(400); await p.fill("#reg-invite", JT); await p.click(".invite-verify-btn"); await p.waitForSelector("#reg-username", { timeout: 8000 }).catch(() => {}); await p.waitForTimeout(300); r.joinTeam.readonlyTeam = await p.locator(".field-input-wrap.is-readonly input").inputValue().catch(() => ""); r.joinTeam.teamInputAbsent = (await p.locator("#reg-team").count()) === 0; await p.screenshot({ path: path.join(OUT, "p1-jointeam-verified.png") }); await p.fill("#reg-username", `p1-jt-${stamp}`); await p.fill("#reg-pwd", "strong-pass-1"); await p.fill("#reg-pwd2", "strong-pass-1"); await p.click("button.btn-cta"); try { await p.waitForFunction(() => !location.pathname.startsWith("/register"), { timeout: 15000 }); r.joinTeam.registered = true; } catch { r.joinTeam.registered = false; } await p.waitForTimeout(400); await ctx.close(); } // ── C. 错码:不展开表单 + 报错 ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const p = await ctx.newPage(); hook(p, "invalid"); await p.goto(BASE + "/register", { waitUntil: "load" }); await p.waitForTimeout(400); await p.fill("#reg-invite", "TEAM-ZZZZ-ZZZZ"); await p.click(".invite-verify-btn"); await p.waitForTimeout(900); r.invalid.errNote = (await p.locator(".field-note.err").count()) >= 1; r.invalid.usernameHidden = (await p.locator("#reg-username").count()) === 0; await p.screenshot({ path: path.join(OUT, "p1-invalid-code.png") }); await ctx.close(); } // ── D. URL ?invite= 自动验码 ── { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const p = await ctx.newPage(); hook(p, "urlAuto"); await p.goto(BASE + "/register?invite=" + CT2, { waitUntil: "load" }); await p.waitForSelector(".field-note.ok", { timeout: 8000 }).catch(() => {}); await p.waitForTimeout(400); r.urlAuto.autoVerified = (await p.locator(".field-note.ok").count()) >= 1; r.urlAuto.codePrefilled = (await p.locator("#reg-invite").inputValue().catch(() => "")) === CT2; await p.screenshot({ path: path.join(OUT, "p1-url-autovalidate.png") }); await ctx.close(); } await browser.close(); const checks = { ctVerifyRevealsTeam: r.createTeam.usernameHiddenBeforeVerify && r.createTeam.teamInputShown && r.createTeam.okNote, ctRegistered: r.createTeam.registered === true, jtReadonlyTeamShown: !!r.joinTeam.readonlyTeam && r.joinTeam.teamInputAbsent, jtRegistered: r.joinTeam.registered === true, invalidBlocks: r.invalid.errNote === true && r.invalid.usernameHidden === true, urlAutoValidate: r.urlAuto.autoVerified === true && r.urlAuto.codePrefilled === 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, "p1-summary.json"), JSON.stringify(r, null, 2)); process.exit(r.pass ? 0 : 1);