/** * 账号功能的验收脚本:首页外壳、账号设置、密码登录。 * * 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么", * 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。 * * 两个坑记在这里: * 1. 短信验证码有 60 秒节流,反复跑脚本会拿到 429,于是"发码"这一步要先看 * 是不是被拦了;登录态优先用密码直接从接口换,别每次都走验证码。 * 2. 改密码要先验证旧密码,而系统不会告诉你旧密码——上一轮的密码记在 * .local/e2e-account-password.txt 里,脚本自己维护。 */ import { chromium } from "playwright-core"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; const PHONE = process.env.PHONE ?? "13900000001"; const CODE = process.env.CODE ?? "123456"; const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url); const OUT = "e2e/artifacts"; let pass = 0; let fail = 0; function check(name, ok, extra = "") { if (ok) { pass += 1; console.log(`PASS ${name}`); } else { fail += 1; console.log(`FAIL ${name} ${extra}`); } } function savedPassword() { try { return readFileSync(PWD_FILE, "utf8").trim() || null; } catch { return null; } } function randomPassword() { return `dadao${Math.floor(Math.random() * 900000 + 100000)}`; } async function apiLogin(password) { if (!password) return null; const r = await fetch(`${API}/auth/login/password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: PHONE, password }), }); const body = await r.json(); return body?.code === 0 ? body.data.tokens : null; } async function main() { mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch({ executablePath: process.env.CHROME ?? `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, }); const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); const errors = []; page.on("console", (m) => m.type() === "error" && errors.push(m.text())); page.on("pageerror", (e) => errors.push(String(e))); // ① 先拿到登录态:有密码就直接换令牌,省掉一次发码 let tokens = await apiLogin(savedPassword()); if (tokens) { await page.addInitScript( ([access, refresh]) => { localStorage.setItem("dadao.access", access); localStorage.setItem("dadao.refresh", refresh); }, [tokens.access_token, tokens.refresh_token], ); } await page.goto(WEB); if (!tokens) { // 第一次跑(还没有密码)只能走验证码;被节流就等一会儿再发 await page.goto(`${WEB}/login`); await page.getByLabel("手机号").fill(PHONE); for (let i = 0; i < 2; i += 1) { await page.getByRole("button", { name: "获取验证码" }).click(); await page.waitForTimeout(1500); const throttled = await page.locator("text=/秒后再试|稍后/").count(); if (!throttled) break; await page.waitForTimeout(61_000); } await page.getByLabel("验证码").fill(CODE); await page.getByRole("button", { name: "登录" }).click(); await page.waitForURL(`${WEB}/`); } await page.waitForSelector("text=我的工作区"); check("登录后进入首页", true); // ② 首页与工作区页是同一套外壳:侧栏都在,宽度不变 const homeRail = await page.locator("aside").first().boundingBox(); const homeMain = await page.locator("main").first().boundingBox(); check("首页有左侧栏", Boolean(homeRail && homeRail.width > 200), JSON.stringify(homeRail)); check("首页内容区贴着侧栏", Boolean(homeMain && homeMain.x >= (homeRail?.x ?? 0))); await page.screenshot({ path: `${OUT}/account-home.png` }); await page.locator("main button", { hasText: "我的备忘" }).first().click(); await page.waitForURL(/\/w\//); await page.waitForSelector("aside"); const wsRail = await page.locator("aside").first().boundingBox(); check( "进了工作区侧栏宽度不变", Boolean(wsRail && homeRail && Math.abs(wsRail.width - homeRail.width) < 1), `home=${homeRail?.width} ws=${wsRail?.width}`, ); await page.screenshot({ path: `${OUT}/account-workspace.png` }); // ③ 账号设置:侧栏有入口,能改昵称、能设/改密码 await page.goto(`${WEB}/account`); await page.waitForSelector("text=登录密码"); const accountRail = await page.locator("aside").first().boundingBox(); check( "账号页复用了同一条侧栏", Boolean(accountRail && Math.abs(accountRail.width - (homeRail?.width ?? 0)) < 1), ); const nickname = `用户${Math.floor(Math.random() * 9000 + 1000)}`; // 等 /auth/me 回填完再改,否则 backfill 会把刚输入的值盖回去 await page.waitForFunction( () => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0, ); await page.getByLabel("昵称").fill(nickname); await page.getByRole("button", { name: "保存昵称" }).click(); await page.waitForSelector("text=昵称已更新"); check("昵称保存成功", true); const currentField = page.getByLabel("当前密码"); const hadPassword = (await currentField.count()) > 0; if (hadPassword) await currentField.fill(savedPassword()); const password = randomPassword(); await page.getByLabel(hadPassword ? "新密码" : "密码", { exact: true }).fill(password); await page.getByLabel("确认密码").fill(password); await page.getByRole("button", { name: /设置密码|修改密码/ }).click(); await page.waitForSelector("text=/密码已(设置|修改)/"); writeFileSync(PWD_FILE, password, "utf8"); await page.screenshot({ path: `${OUT}/account-settings.png` }); check(hadPassword ? "密码修改成功" : "密码设置成功", true); // 刷新后仍是"已设置密码",说明后端真的存住了 await page.reload(); await page.waitForSelector("text=已设置密码", { timeout: 15_000 }); check("刷新后仍显示已设置密码", true); // ④ 退出,改用密码登录 await page.goto(`${WEB}/account`); await page.getByRole("button", { name: "退出登录" }).last().click(); await page.waitForURL(/\/login/); await page.getByRole("button", { name: "密码", exact: true }).click(); await page.getByLabel("手机号").fill(PHONE); await page.getByLabel("密码").fill("wrong-pass1"); await page.getByRole("button", { name: "登录" }).click(); await page.waitForSelector("text=手机号或密码不对"); check("错误密码被拒绝", true); await page.getByLabel("密码").fill(password); await page.getByRole("button", { name: "登录" }).click(); await page.waitForURL(`${WEB}/`, { timeout: 15_000 }); check("密码登录成功", true); const me = await page.evaluate(async (api) => { const token = localStorage.getItem("dadao.access"); const r = await fetch(`${api}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }); return r.json(); }, API); check( "接口里 has_password=true、昵称是刚改的", me?.data?.user?.has_password === true && me?.data?.user?.nickname === nickname, JSON.stringify(me?.data?.user ?? null), ); // ⑤ 退出后用验证码也能进来(登录页两种模式都得通) await page.goto(`${WEB}/account`); await page.getByRole("button", { name: "退出登录" }).last().click(); await page.waitForURL(/\/login/); await page.getByRole("button", { name: "验证码", exact: true }).click(); await page.getByLabel("手机号").fill(PHONE); await page.getByRole("button", { name: /获取验证码|重新获取/ }).click(); await page.waitForTimeout(1200); await page.getByLabel("验证码").fill(CODE); await page.getByRole("button", { name: "登录" }).click(); const codeLoginOk = await page .waitForURL(`${WEB}/`, { timeout: 15_000 }) .then(() => true) .catch(() => false); if (codeLoginOk) check("验证码登录也照旧能用", true); else console.log("SKIP 验证码登录(本次发码被 60 秒节流挡住)"); const realErrors = errors.filter((e) => !e.includes("429") && !e.includes("400")); check("控制台零报错", realErrors.length === 0, realErrors.slice(0, 3).join(" | ")); // ⑥ 窄屏:侧栏收成顶条,别把整页挤成一列 await page.setViewportSize({ width: 390, height: 844 }); await page.goto(WEB); await page.waitForSelector("main h1"); check("窄屏首页是顶条而不是侧栏", (await page.locator("header").count()) > 0); await page.screenshot({ path: `${OUT}/account-home-mobile.png`, fullPage: false }); await page.goto(`${WEB}/account`); await page.waitForSelector("text=登录密码"); await page.screenshot({ path: `${OUT}/account-settings-mobile.png`, fullPage: false }); await browser.close(); console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — ${pass} passed, ${fail} failed`); if (fail > 0) process.exit(1); } main().catch((err) => { console.log(`FAIL 脚本异常 — ${err?.message ?? err}`); process.exit(1); });