/** * PWA 安装性验证脚本 * 检查: * 1. manifest.webmanifest 合法 * 2. Service Worker 注册成功 * 3. 图标全部能加载 * 4. PWA 必需字段(name, icons[192/512], start_url, display, theme_color, background_color) * 5. 离线 fallback(/offline 或 navigateFallback 命中) */ import puppeteer from 'puppeteer'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const URL_TO_TEST = process.env.PWA_URL || 'http://localhost:4173/login'; const CHROME_PATH = process.env.CHROME_PATH || '/Users/yabozi/.cache/puppeteer/chrome/mac-148.0.7778.97/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'; const checks = []; let pass = 0; let fail = 0; function ok(name, detail) { checks.push({ status: '✅', name, detail }); pass++; } function ko(name, detail) { checks.push({ status: '❌', name, detail }); fail++; } async function fetchStatus(url) { try { const r = await fetch(url, { redirect: 'follow' }); return r.status; } catch (e) { return 0; } } async function main() { console.log(`🔍 PWA check: ${URL_TO_TEST}\n`); // 1. manifest 文件可访问 + 合法 JSON const manifestUrl = new URL('/manifest.webmanifest', URL_TO_TEST).toString(); const manifestStatus = await fetchStatus(manifestUrl); if (manifestStatus === 200) { ok('manifest 200', manifestUrl); const r = await fetch(manifestUrl); const m = await r.json(); const required = ['name', 'short_name', 'start_url', 'display', 'icons']; const missing = required.filter((k) => !m[k]); if (missing.length === 0) { ok('manifest 必备字段', Object.keys(m).join(', ')); } else { ko('manifest 必备字段缺失', missing.join(', ')); } // 图标尺寸 const sizes = (m.icons || []).map((i) => i.sizes).filter(Boolean); const has192 = sizes.some((s) => s.includes('192')); const has512 = sizes.some((s) => s.includes('512')); const hasMaskable = (m.icons || []).some((i) => i.purpose === 'maskable'); const hasApple = (m.icons || []).some((i) => (i.src || '').includes('apple-touch') ); if (has192 && has512) ok('icons 192+512', sizes.join(', ')); else ko('icons 192/512 缺失', sizes.join(', ')); if (hasMaskable) ok('maskable icon', 'present'); else ko('maskable icon', 'missing'); if (hasApple) ok('apple-touch icon', 'present'); else ko('apple-touch icon', 'missing'); } else { ko('manifest 不可访问', `status=${manifestStatus}, url=${manifestUrl}`); } // 2. Service Worker 注册 const browser = await puppeteer.launch({ executablePath: CHROME_PATH, headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', `--user-data-dir=/tmp/lh-cache-pwa-check-${Date.now()}`, ], }); const page = await browser.newPage(); const swLogs = []; page.on('console', (m) => { const t = m.text(); if (t.includes('[PWA]') || t.includes('ServiceWorker')) swLogs.push(t); }); await page.goto(URL_TO_TEST, { waitUntil: 'networkidle0', timeout: 30000 }); // 等几秒给 SW 机会注册 await new Promise((r) => setTimeout(r, 3000)); const swInfo = await page.evaluate(async () => { if (!('serviceWorker' in navigator)) return { supported: false }; const regs = await navigator.serviceWorker.getRegistrations(); return { supported: true, count: regs.length, scopes: regs.map((r) => r.scope), active: regs.map((r) => !!r.active), scripts: regs.map((r) => r.active && r.active.scriptURL), }; }); if (swInfo.supported && swInfo.count > 0 && swInfo.active.every(Boolean)) { ok('Service Worker 已注册', `scope=${swInfo.scopes.join(',')}`); } else if (swInfo.supported && swInfo.count > 0) { ko('Service Worker 未激活', JSON.stringify(swInfo)); } else { ko('Service Worker 未注册', 'getRegistrations() 为空'); } // 3. 关键 meta 标签 const meta = await page.evaluate(() => { const get = (sel) => document.querySelector(sel)?.getAttribute('content') || null; return { themeColor: get('meta[name="theme-color"]'), appleCapable: get('meta[name="apple-mobile-web-app-capable"]'), appleTitle: get('meta[name="apple-mobile-web-app-title"]'), viewport: get('meta[name="viewport"]'), manifestLink: !!document.querySelector('link[rel="manifest"]'), appleTouchIcon: !!document.querySelector('link[rel="apple-touch-icon"]'), }; }); if (meta.themeColor) ok('theme-color', meta.themeColor); else ko('theme-color', 'missing'); if (meta.appleCapable === 'yes') ok('apple-mobile-web-app-capable', 'yes'); else ko('apple-mobile-web-app-capable', meta.appleCapable || 'missing'); if (meta.manifestLink) ok('manifest link', 'present'); else ko('manifest link', 'missing'); if (meta.appleTouchIcon) ok('apple-touch-icon link', 'present'); else ko('apple-touch-icon link', 'missing'); // 4. SW 日志确认 offline ready const offlineReady = swLogs.some((l) => l.includes('离线缓存就绪') || l.includes('offline')); if (offlineReady) ok('PWA offline log', '检测到 offline ready'); // 离线就绪不是强校验,标 warn 即可 if (!offlineReady) { checks.push({ status: '⚠️', name: 'PWA offline 日志', detail: '运行后无离线 ready 日志(可能 SW 还没预缓存完,可忽略)', }); } await browser.close(); // 输出 for (const c of checks) { console.log(`${c.status} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); } console.log(`\n总计: ✅ ${pass} ❌ ${fail}`); process.exit(fail > 0 ? 1 : 0); } main().catch((e) => { console.error('PWA check 失败:', e); process.exit(2); });