Files
DadaoTask/web/e2e/account.mjs
T

221 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 账号功能的验收脚本:首页外壳、账号设置、密码登录。
*
* 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么",
* 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。
*
* 两个坑记在这里:
* 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);
});