/** * # 脚本 verify-skeleton * 说明:验证 5 站的加载体验改造 —— * 1) /tools 过滤:旧内容变暗 → shimmer 骨架覆盖层淡入 → 新卡片错峰入场(交叉淡入淡出); * 2) 路由级骨架屏:各站按 data-theme 只显示自己的形态(.loading-variant)。 * 通过给导航请求加 1200ms 延迟让过渡可见;中断 prefetch 请求保证路由骨架能触发。 * 截图输出到 /tmp/skel/。 * 运行:bun packages/core/scripts/verify-skeleton.ts */ import { chromium } from "playwright"; import { mkdirSync } from "node:fs"; const OUT = "/tmp/skel"; mkdirSync(OUT, { recursive: true }); /** # 常量 SITES —— 站点 → 端口 → 期望的路由骨架形态 */ const SITES = [ { name: "ai-stack", port: 3001, variant: "lv-ai-stack" }, { name: "launchai", port: 3002, variant: "lv-launchai" }, { name: "the-ai-ledger", port: 3003, variant: "lv-the-ai-ledger" }, { name: "devai-tools", port: 3004, variant: "lv-devai-tools" }, { name: "findai", port: 3005, variant: "lv-findai" }, ]; const results: string[] = []; const browser = await chromium.launch({ executablePath: "/opt/brave.com/brave/brave", args: ["--no-sandbox", "--disable-dev-shm-usage"], }); for (const site of SITES) { const base = `http://localhost:${site.port}`; const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const r: string[] = []; r.push(`■ ${site.name} (:${site.port})`); try { // ── 1. 过滤交叉过渡 ───────────────────────────── await page.goto(`${base}/tools`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("a.card-enter", { timeout: 15000 }); // 过滤导航(带 query 的 /tools 请求)加延迟;prefetch 请求直接中断 await page.route("**/tools?*", async (route) => { if (route.request().headers()["next-router-prefetch"]) { return route.abort(); } await new Promise((s) => setTimeout(s, 1200)); return route.continue(); }); const chip = page .locator("button.rounded-lg") .filter({ hasText: /\(\d+\)/ }) .first(); await chip.click(); await page.waitForTimeout(600); // 骨架覆盖层应已挂载(延迟 200ms + 余量) const mid = await page.evaluate(() => ({ shimmer: document.querySelectorAll(".shimmer").length, overlay: !!document.querySelector(".skeleton-in"), dimmed: !!document.querySelector('[aria-busy="true"]'), })); r.push(` 过滤中: shimmer×${mid.shimmer} overlay=${mid.overlay} 变暗=${mid.dimmed}`); await page.screenshot({ path: `${OUT}/${site.name}-filter-mid.png` }); // 等待骨架层卸载(数据到达 + 340ms 退出窗口) await page.waitForFunction( () => !document.querySelector(".skeleton-in"), { timeout: 15000 } ); await page.waitForTimeout(500); const after = await page.evaluate(() => ({ shimmer: document.querySelectorAll(".shimmer").length, url: location.search, cards: document.querySelectorAll("a.card-enter").length, })); r.push(` 完成后: shimmer×${after.shimmer} 卡片×${after.cards} URL=${after.url}`); await page.screenshot({ path: `${OUT}/${site.name}-filter-after.png` }); // ── 2. 路由级骨架屏(首页 → /tools)───────────── // 说明:VT 浏览器由 View Transition 覆盖整个等待期;Next 16 在流式响应 // 开始后才挂载 loading 兜底。prefetch 全部中断后,用 CDP 限速拖慢 RSC // 流式响应,使兜底骨架窗口可持续数秒,便于校验与截图。 await page.goto(`${base}/`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(400); await page.route("**/tools*", async (route) => { if (route.request().headers()["next-router-prefetch"]) { return route.abort(); } return route.continue(); }); const cdp = await ctx.newCDPSession(page); await cdp.send("Network.enable"); await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: 30 * 1024, uploadThroughput: 30 * 1024, }); const nav = page.locator('header a[href*="/tools"]').first(); await nav.click(); const routeSkel = await page.evaluate( (expected) => new Promise<{ shell: boolean; shown: string[]; expected: string }>((resolve) => { let done = false; // 每 60ms 轮询一次,捕捉 .loading-shell 挂载窗口 const timer = setInterval(() => { const shell = document.querySelector(".loading-shell"); if (shell && !done) { done = true; const shown: string[] = []; document.querySelectorAll(".loading-variant").forEach((el) => { if (getComputedStyle(el).display !== "none") { shown.push(el.className); } }); clearInterval(timer); resolve({ shell: true, shown, expected }); } }, 60); // 8 秒仍未挂载则报告失败 setTimeout(() => { if (!done) { clearInterval(timer); resolve({ shell: false, shown: [], expected }); } }, 8000); }), site.variant ); const shown = routeSkel.shown.join(","); r.push( ` 路由骨架: shell=${routeSkel.shell} 可见类=${shown || "无"} (期望 ${site.variant})` ); if (routeSkel.shell) { // 等 .loading-shell 淡入完成(0.3s 动画 + 0.15s 延迟),保持限速让骨架窗口持续 await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/${site.name}-route-skeleton.png` }); } await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1, }); await page.waitForSelector("a.card-enter", { timeout: 30000 }); r.push(" 路由导航完成: /tools 已渲染"); const ok = routeSkel.shell && routeSkel.shown.length === 1 && routeSkel.shown[0].includes(site.variant); results.push(r.join("\n") + (ok ? "\n ✓ 通过" : "\n ✗ 校验不符")); } catch (e) { r.push(` ✗ 失败: ${e instanceof Error ? e.message.split("\n")[0] : e}`); results.push(r.join("\n")); } finally { await ctx.close(); } } await browser.close(); console.log(results.join("\n---\n"));