// 验证 Layer 3 空数据兜底(L3EmptyFallback):信号卡 + 共创占位渲染 // 用法:node scripts/audit/l3-fallback-verify.mjs // // 断言(solution 详情页,当前无 caseStudies/certifications → 兜底应渲染): // 1. section[aria-labelledby=l3-signals-heading] 存在且可见(opacity→1) // 2. 4 张信号卡齐全,其中 3 张为链接(/methodology /team /about/brand),1 张 disabled // 3. EarlyAccessNotice(首批客户共创中)存在 // 4. 深色模式下同样成立(截图目视 + token 反色由 globals.css 变量层保证) import { chromium } from 'playwright'; // 注意:必须用 localhost(非 127.0.0.1)。Next dev 的 allowedDevOrigins 默认白名单 // 不含 127.0.0.1,chunk 子资源请求带 Origin: http://127.0.0.1:3000 会被 403 拒掉, // 导致 JS 加载失败 → hydration 不发生 → 全站 whileInView 动画冻结(页面空白)。 // 代理问题由 chromium --no-proxy-server 解决(见 launch 参数),与 URL 无关。 const BASE = 'http://localhost:3000'; const OUT = 'dogfood-output/l3-fallback-verify'; const PAGE_URL = `${BASE}/solutions/manufacturing`; const results = []; async function probe(page, theme) { await page.goto(PAGE_URL, { waitUntil: 'networkidle' }); // dev 模式 hydration 较慢(Turbopack 按需编译):等待 framer-motion 生效的标志—— // 页面上任一 motion div 的 opacity 变为 '1'(说明 IO + rAF 循环已运行) let hydrated = true; try { await page.waitForFunction( () => [...document.querySelectorAll('div[style]')].some((d) => d.style.opacity === '1'), { timeout: 20000 } ); } catch { hydrated = false; } await page.waitForTimeout(500); const sec = page.locator('section[aria-labelledby="l3-signals-heading"]'); const secCount = await sec.count(); if (secCount === 0) { results.push({ theme, pass: false, reason: 'L3 signals section not found' }); return; } // 滚动触发 whileInView,等动画完成(轮询 opacity,上限 10s) await sec.scrollIntoViewIfNeeded(); let headingOpacity = '0'; try { await page.waitForFunction( () => { const h = document.getElementById('l3-signals-heading'); if (!h) return false; const o = Number(getComputedStyle(h.closest('div')).opacity); return o > 0.9; }, { timeout: 10000 } ); headingOpacity = '1'; } catch { headingOpacity = await sec .locator('#l3-signals-heading') .evaluate((el) => getComputedStyle(el.closest('div')).opacity); } const cards = await sec.locator('a, [aria-disabled="true"]').count(); const linkHrefs = await sec.locator('a').evaluateAll((as) => as.map((a) => a.getAttribute('href'))); const disabledCard = await sec.locator('[aria-disabled="true"]').count(); const notice = page.locator('section[aria-labelledby="early-access-notice-heading"]'); const noticeCount = await notice.count(); const noticeText = noticeCount > 0 ? await notice.locator('p').first().textContent() : null; await page.screenshot({ path: `${OUT}/l3-${theme}-full.png` }); await sec.screenshot({ path: `${OUT}/l3-${theme}-section.png` }); if (noticeCount > 0) { await notice.screenshot({ path: `${OUT}/notice-${theme}.png` }); } const pass = Number(headingOpacity) > 0.9 && cards === 4 && disabledCard === 1 && linkHrefs.includes('/methodology') && linkHrefs.includes('/team') && linkHrefs.includes('/about/brand') && noticeCount === 1 && (noticeText || '').includes('首批客户共创中'); results.push({ theme, pass, hydrated, headingOpacity, signalCards: cards, disabledCard, linkHrefs, noticeCount, noticeText: (noticeText || '').slice(0, 20), }); } async function run() { // --no-proxy-server:本机系统代理会 403 掉 _next/static chunk(JS 加载失败 → hydration 不发生) const browser = await chromium.launch({ args: ['--no-proxy-server'] }); const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await ctx.newPage(); await probe(page, 'light'); await page.evaluate(() => localStorage.setItem('novalon-theme', 'dark')); await probe(page, 'dark'); await browser.close(); console.log(JSON.stringify(results, null, 2)); const allPass = results.every((r) => r.pass); console.log(`\n[result] ${allPass ? 'PASS' : 'FAIL'} (light: ${results[0].pass}, dark: ${results[1]?.pass})`); if (!allPass) process.exit(1); } run().catch((e) => { console.error(e); process.exit(1); });