Files
DadaoTask/web/e2e/pwa.mjs
T
steven 684b9b2604
CI / backend (push) Canceled after 0s
fix(web): PWA manifest 主题色跟随新主色
安装到主屏后的启动背景与状态栏色此前仍是旧的墨绿/暖白;e2e 断言与文档同步更新。
2026-11-01 19:17:35 +08:00

181 lines
7.9 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.
/**
* PWA 验收脚本:装得上、离线开得开、有新版本提醒得了。
*
* 跑法(必须先有一个"生产同构"的站点:后端用 WEB_DIST_DIR 指向 web/dist):
* WEB_DIST_DIR=<repo>/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 === "#274b73", 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);
});