Files
steven d7c9670880
CI / backend (push) Canceled after 0s
feat(design): 主色改靛蓝 #4F46E5,Web 与安卓及 Logo 同步
- primary 换成 Indigo 阶(600 即 #4F46E5),中性色转 Slate 轴、纸面更冷更白
- 实心主色面(主按钮、工作区选中)用 600,链接/浅底文字仍用 700 保对比度
- 信息色 sky 调深到 #16697C,与靛蓝同屏能分清
- Logo / favicon / PWA 图标 / 安卓自适应图标统一为靛蓝底 + 白线 + 淡紫点,tools/make-icons.py 重新生成
- 文档与 e2e 断言同步(主题色 #4f46e5)
2026-11-02 15:33:26 +08:00

181 lines
7.9 KiB
JavaScript
Raw Permalink 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 === "#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);
});