221 lines
9.1 KiB
JavaScript
221 lines
9.1 KiB
JavaScript
/**
|
||
* 账号功能的验收脚本:首页外壳、账号设置、密码登录。
|
||
*
|
||
* 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么",
|
||
* 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。
|
||
*
|
||
* 两个坑记在这里:
|
||
* 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);
|
||
});
|