import { chromium } from 'playwright'; import { mkdirSync, writeFileSync } from 'fs'; const BASE = process.env.BASE || 'http://localhost:3000'; const OUT = process.env.OUT || '/tmp/iphone-se-evidence'; mkdirSync(OUT, { recursive: true }); const PAGES = [ ['home', '/'], ['news-detail', '/news/company-founded'], ['news-list', '/news'], ['contact', '/contact'], // 边界页:404 的 h1 由 opacity-20 改为 text-brand-ink/80,需在 375px 窄屏(`text-8xl` 档)复核 ['not-found', '/no-such-route-404-check'], ]; const MEASURE = () => { const footer = document.querySelector('footer, [data-testid="footer"]'); let lastTextBottom = 0; let label = ''; if (footer) { footer.querySelectorAll('*').forEach((el) => { if (el.children.length || !el.textContent.trim()) return; const b = el.getBoundingClientRect(); if (b.height > 0 && b.bottom > lastTextBottom) { lastTextBottom = b.bottom; label = el.textContent.trim().slice(0, 24); } }); } const fr = footer ? footer.getBoundingClientRect() : null; const banner = document.querySelector('[data-testid="cookie-consent-banner"]'); const br = banner ? banner.getBoundingClientRect() : null; return { theme: document.documentElement.getAttribute('data-theme'), footerBottomGap: fr && lastTextBottom ? Math.round(fr.bottom - lastTextBottom) : null, lastText: label, footerBottomVsViewport: fr ? Math.round(fr.bottom - innerHeight) : null, bannerTop: br ? Math.round(br.top) : null, bannerOverlapsFooter: br && fr ? br.top < fr.bottom : false, docScrollW: document.documentElement.scrollWidth, clientW: document.documentElement.clientWidth, }; }; const browser = await chromium.launch(); const rows = []; for (const theme of ['dark', 'light']) { for (const consent of ['dismissed', 'pending']) { const ctx = await browser.newContext({ viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, colorScheme: theme, ...(consent === 'dismissed' ? { storageState: { cookies: [], origins: [ { origin: BASE, localStorage: [ { name: 'novalon-cookie-preferences', value: JSON.stringify({ necessary: true, analytics: false, marketing: false, functionality: true, timestamp: 1784284000000, }), }, ], }, ], }, } : {}), }); const page = await ctx.newPage(); for (const [name, path] of PAGES) { await page.goto(BASE + path, { waitUntil: 'load', timeout: 45000 }); if (consent === 'pending') await page.waitForTimeout(2600); else await page.waitForTimeout(900); const tag = `${theme}-${consent}-${name}`; await page.screenshot({ path: `${OUT}/se-${tag}.png` }); // 逐帧滚到底:首页有入场分级动画,内容高度在滚动后才增长, // 单次 scrollTo(scrollHeight) 会停在半页,测出的 footer 位置无效。 await page.evaluate(async () => { let last = -1; for (let i = 0; i < 12; i += 1) { window.scrollTo(0, document.body.scrollHeight); await new Promise((r) => setTimeout(r, 220)); if (Math.round(window.scrollY) === last) break; last = Math.round(window.scrollY); } }); await page.waitForTimeout(700); await page.screenshot({ path: `${OUT}/se-${tag}-footer.png` }); const m = await page.evaluate(MEASURE); rows.push({ tag, ...m }); console.log(tag.padEnd(28), JSON.stringify(m)); } await ctx.close(); } } await browser.close(); writeFileSync( `${OUT}/measurements.json`, JSON.stringify( { generatedAt: new Date().toISOString(), target: BASE, device: 'iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制', caveat: 'Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收', thresholds: { noHorizontalOverflow: 'docScrollW <= clientW', consentClearsFooter: 'bannerOverlapsFooter === false' }, rows, }, null, 2, ), ); console.log(`WROTE ${OUT}/measurements.json (${rows.length} rows)`);