/** * PWA 验收脚本:装得上、离线开得开、有新版本提醒得了。 * * 跑法(必须先有一个"生产同构"的站点:后端用 WEB_DIST_DIR 指向 web/dist): * WEB_DIST_DIR=/web/dist ../backend/.venv/Scripts/python.exe -m uvicorn app.main:app --port 8101 * node web/e2e/pwa.mjs # 默认 BASE=http://127.0.0.1:8101 * * 两个坑: * 1. Service Worker 只在安全上下文(https / localhost)注册,所以这里必须走 127.0.0.1, * 不能换成内网 IP——那台机器上要真的装上,得先有 https。 * 2. 无头 Chromium 不会触发 beforeinstallprompt,所以"安装"那段用 iPhone 的 UA 走 * iOS 分支(分享 → 添加到主屏幕),这是唯一能稳定断言到的安装引导。 */ import { chromium } from "playwright-core"; import { mkdirSync, readFileSync } from "node:fs"; const BASE = process.env.BASE ?? "http://127.0.0.1:8101"; 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"; const CHROME = process.env.CHROME ?? `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`; const IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; 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}`); } } async function token() { // 先试密码(快、不撞短信节流),不行再发验证码 try { const saved = readFileSync(PWD_FILE, "utf8").trim(); const r = await fetch(`${BASE}/api/v1/auth/login/password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: PHONE, password: saved }), }); const body = await r.json(); if (body?.code === 0) return body.data.tokens; } catch { // 没存过密码:往下走验证码 } await fetch(`${BASE}/api/v1/auth/sms/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: PHONE }), }); const r = await fetch(`${BASE}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: PHONE, code: CODE }), }); const body = await r.json(); if (body?.code !== 0) throw new Error(`登录失败:${JSON.stringify(body)}`); return body.data.tokens; } async function main() { mkdirSync(OUT, { recursive: true }); const tokens = await token(); const browser = await chromium.launch({ executablePath: CHROME }); // ── 一、桌面尺寸:Service Worker 与 manifest ───────────────────── const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 } }); await desktop.addInitScript( ([access, refresh]) => { localStorage.setItem("dadao.access", access); localStorage.setItem("dadao.refresh", refresh); }, [tokens.access_token, tokens.refresh_token], ); const page = await desktop.newPage(); const errors = []; page.on("pageerror", (e) => errors.push(String(e))); await page.goto(`${BASE}/`, { waitUntil: "networkidle" }); const manifestHref = await page.getAttribute('link[rel="manifest"]', "href"); check("页面挂了 manifest", Boolean(manifestHref), String(manifestHref)); const manifest = await (await fetch(`${BASE}${manifestHref}`)).json(); check("manifest 名字是「达道Task」", manifest.short_name === "达道Task", JSON.stringify(manifest.name)); check("独立窗口打开(display=standalone)", manifest.display === "standalone", String(manifest.display)); check("三个图标齐全", (manifest.icons ?? []).length === 3, JSON.stringify(manifest.icons)); check( "有一个 maskable 图标", (manifest.icons ?? []).some((i) => String(i.purpose).includes("maskable")), ); for (const icon of manifest.icons ?? []) { const res = await fetch(`${BASE}/${String(icon.src).replace(/^\//, "")}`); check(`图标可下载 ${icon.sizes}`, res.ok && res.headers.get("content-type") === "image/png"); } const sw = await page.evaluate(async () => { const reg = await navigator.serviceWorker.ready; return { scope: reg.scope, state: reg.active?.state ?? "none" }; }); check("Service Worker 已激活", sw.state === "activated", JSON.stringify(sw)); check("作用域是整站", sw.scope.endsWith("/"), sw.scope); const shellCached = await page.evaluate(async () => { const cache = await caches.open("workbox-precache-v2-" + location.origin + "/"); const keys = await cache.keys(); return keys.some((k) => k.url.includes("index.html")); }); check("页面骨架已预缓存", shellCached); const themeColor = await page.getAttribute('meta[name="theme-color"]', "content"); check("主题色是品牌靛蓝", themeColor === "#4f46e5", String(themeColor)); check("桌面尺寸没有控制台报错", errors.length === 0, errors.join(" | ")); await desktop.close(); // ── 二、iPhone 尺寸:安装引导(iOS 只能靠「分享 → 添加到主屏幕」)───── const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, userAgent: IOS_UA, deviceScaleFactor: 2, }); await phone.addInitScript( ([access, refresh]) => { localStorage.setItem("dadao.access", access); localStorage.setItem("dadao.refresh", refresh); }, [tokens.access_token, tokens.refresh_token], ); const m = await phone.newPage(); await m.goto(`${BASE}/`, { waitUntil: "networkidle" }); await m.waitForSelector("text=把达道Task装到桌面", { timeout: 10000 }); const hint = await m.locator("text=把达道Task装到桌面").first().isVisible(); check("窄屏首页有安装引导", hint); const iosText = await m.locator("text=添加到主屏幕").count(); check("iOS 给出的是「分享 → 添加到主屏幕」", iosText > 0); await m.screenshot({ path: `${OUT}/pwa-install-hint.png` }); await m.getByRole("button", { name: "不再提示安装" }).click(); await m.reload({ waitUntil: "networkidle" }); const stillThere = await m.locator("text=把达道Task装到桌面").count(); check("关掉之后不再打扰(刷新也不回来)", stillThere === 0); // ── 三、有新版时提醒,点「稍后」收起 ───────────────────────────── await m.evaluate(() => window.dispatchEvent(new Event("dadao:pwa-update-ready"))); await m.waitForSelector("text=有新版了", { timeout: 5000 }); check("检测到新版本会提示刷新", (await m.locator("text=有新版了").count()) > 0); await m.screenshot({ path: `${OUT}/pwa-update-prompt.png` }); await m.getByRole("button", { name: "稍后" }).click(); check("点「稍后」会收起", (await m.locator("text=有新版了").count()) === 0); // ── 四、断网还能打开(这是装到桌面的实际价值)───────────────────── await m.evaluate(() => localStorage.clear()); await m.reload({ waitUntil: "networkidle" }); await phone.setOffline(true); await m.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); const body = await m.locator("body").innerText(); check("断网后页面骨架仍能打开", body.length > 0 && /登录|达道/.test(body), body.slice(0, 80)); await phone.setOffline(false); await m.screenshot({ path: `${OUT}/pwa-offline.png` }); await phone.close(); await browser.close(); console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`); process.exit(fail === 0 ? 0 : 1); } main().catch((err) => { console.error("脚本异常 —", err?.message ?? err); process.exit(1); });