Files
DadaoTask/web/e2e/pwa.mjs
T
Steven 483b554c3c
CI / backend (push) Canceled after 0s
feat(web): Web 装到桌面(PWA),带安装引导与发版提示
用 vite-plugin-pwa 生成 manifest 与 Service Worker,不自己写缓存逻辑。

- 装到桌面:manifest 名字/主题色/三个图标(any + maskable + apple-touch-icon),
  图标和页头、安卓桌面是同一枚;首页底部给安装引导,能装就弹「安装」,
  iOS 给「分享 → 添加到主屏幕」,关掉记在 localStorage
- 发版提示:registerType=prompt,收到新版本只在底部提示,点「刷新」才换。
  故意不自动刷新——Vditor、图表是懒加载分片,后台换 chunk 会让面板 404
- 缓存边界:只预缓存页面骨架(约 0.8MB),/api/ 一律 NetworkOnly,
  离线宁可报错也不端旧数据;/vditor/ 走 CacheFirst,不拖慢首次安装
- 顺手修:UpdatePrompt 居中改用 flex 包一层,animate-slide-up 的 keyframes
  自带 transform,会和 -translate-x-1/2 抢属性,手机窄屏会把卡片推出屏幕

验证:web/e2e/pwa.mjs 19 项全绿(manifest/SW 激活/预缓存/安装引导/iOS 文案/
发版提示/断网仍能打开);tsc 通过、vite build 通过。

已知限制:Service Worker 只在 https 或 localhost 注册,生产入口还是 http,
所以内网 IP 上装不了——需要域名 + 证书反代或隧道,已写进 DEPLOY.md 第 6 节。
2026-10-31 04:33:43 +08:00

181 lines
7.8 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 === "#2f6054", 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);
});