181 lines
7.9 KiB
JavaScript
181 lines
7.9 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|