/** * 登录态续期的验收:访问令牌过期时,界面不该把人踢回登录页。 * * 背景:access 只有 30 分钟,refresh 有 14 天,但以前前端拿到 401 就直接清令牌跳登录页, * refresh 存着没人用——用户的感觉就是"用一会儿就被踢出去"。 * 现在前端会先用 refresh 换一对令牌、把原请求重放一次,换不动才清。 * * 这里不能造一个"真的过期"的签名令牌,所以把 access 换成一串假的: * 服务端照样回 401,走的还是同一条续期分支。 * * 用法:node web/e2e/session-refresh.mjs */ import { chromium } from "playwright-core"; import { readFileSync } 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 CHROME = process.env.CHROME ?? `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`; const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url); 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 ? ` — ${extra}` : ""}`); } } const password = (() => { if (process.env.PASSWORD) return process.env.PASSWORD; try { return readFileSync(PWD_FILE, "utf8").trim(); } catch { return ""; } })(); if (!password) throw new Error("没有密码:设 PASSWORD,或先跑一遍 account.mjs 生成 .local/e2e-account-password.txt"); async function login() { 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(); if (body.code !== 0) throw new Error(`登录失败:${body.code} ${body.message}`); return body.data.tokens; } const tokens = await login(); const browser = await chromium.launch({ executablePath: CHROME }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const page = await context.newPage(); const errors = []; page.on("pageerror", (e) => errors.push(String(e))); // ① 假 access + 真 refresh:应该自己续上,留在首页 await page.addInitScript( ([access, refresh]) => { localStorage.setItem("dadao.access", access); localStorage.setItem("dadao.refresh", refresh); }, ["bogus.access.token", tokens.refresh_token], ); await page.goto(`${WEB}/`, { waitUntil: "networkidle" }); await page.waitForTimeout(800); check("令牌过期也不会被踢回登录页", !page.url().includes("/login"), page.url()); check( "首页照常渲染(能看到工作区列表或新建入口)", (await page.getByText(/我的工作区|新建工作区/).first().isVisible().catch(() => false)) === true, ); const afterRefresh = await page.evaluate(() => localStorage.getItem("dadao.access")); check("拿到的是一对新的访问令牌", Boolean(afterRefresh) && afterRefresh !== "bogus.access.token"); // 新令牌要真能用:再请求一次业务接口 const probe = await page.evaluate(async () => { const token = localStorage.getItem("dadao.access"); const r = await fetch("/api/v1/auth/me", { headers: { Authorization: `Bearer ${token}` } }); return r.status; }); check("新令牌能直接调业务接口", probe === 200, `HTTP ${probe}`); // ② access 与 refresh 都是假的:这次才该回登录页 const page2 = await context.newPage(); await page2.addInitScript(() => { localStorage.setItem("dadao.access", "bogus.access.token"); localStorage.setItem("dadao.refresh", "bogus.refresh.token"); }); await page2.goto(`${WEB}/`, { waitUntil: "networkidle" }); await page2.waitForTimeout(800); check( "连 refresh 都失效时,才回到登录页", page2.url().includes("/login") || (await page2.getByText(/手机号|邮箱/).first().isVisible().catch(() => false)) === true, page2.url(), ); check("控制台没有未捕获异常", errors.length === 0, errors.join(" | ")); await page.screenshot({ path: "e2e/artifacts/session-refresh.png" }); await browser.close(); console.log(fail === 0 ? "ALL PASS" : `${fail} FAILED`); process.exit(fail === 0 ? 0 : 1);