// Build and start apps/docsite, then set ASTRYX_ROOT, BUILD_ROOT, SHA, PORT and OUT. // Exact-head revalidation probe for facebook/astryx PR #5548. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const ASTRYX_ROOT=process.env.ASTRYX_ROOT;if(!ASTRYX_ROOT)throw new Error('ASTRYX_ROOT required');const {chromium}=require(require('path').join(ASTRYX_ROOT,'node_modules/playwright')); const {captureWithSensors}=require(require('path').join(ASTRYX_ROOT,'probe-kit/lib.cjs')); const ROOT=process.env.BUILD_ROOT||process.cwd(); const SHA=process.env.SHA||'1bd9da727745cf014cfd44df19b81f081dcc39bc'; const PORT=Number(process.env.PORT||62455); const BASE = `http://127.0.0.1:${PORT}`; const OUT=process.env.OUT||'.'; fs.mkdirSync(OUT, {recursive: true}); const viewports = { mobile: {width: 390, height: 844, isMobile: true, hasTouch: true}, desktop: {width: 1440, height: 900, isMobile: false, hasTouch: false}, }; function errorsFor(page) { const errors = []; page.on('pageerror', e => errors.push(`pageerror: ${e.message}`)); page.on('console', m => { if (m.type() === 'error') errors.push(`console: ${m.text()}`); }); return errors; } async function blockLocalAnalytics(page) { await page.route('**/_vercel/**', r => r.fulfill({status: 204, body: ''})); } function expectedMedia(v) { return {forcedColors: false, reducedMotion: true, coarsePointer: v.hasTouch, hover: !v.hasTouch}; } function augmentReceipt(imagePath, extra) { const p = `${imagePath}.sensors.json`; const d = JSON.parse(fs.readFileSync(p, 'utf8')); const bytes = fs.readFileSync(imagePath); d.image.sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); d.image.pixelDimensions = `${d.observed.viewport.width}x${d.observed.viewport.height}`; Object.assign(d, extra); fs.writeFileSync(p, `${JSON.stringify(d, null, 2)}\n`); } async function axStatuses(cdp) { await cdp.send('Accessibility.enable'); const {nodes} = await cdp.send('Accessibility.getFullAXTree'); return nodes.filter(n => n.role?.value === 'status').map(n => ({name: n.name?.value ?? '', ignored: !!n.ignored})); } async function staticCapture(kind, route, vpName) { const v = viewports[vpName]; const browser = await chromium.launch({headless: true}); try { const context = await browser.newContext({ viewport: {width: v.width, height: v.height}, deviceScaleFactor: 1, isMobile: v.isMobile, hasTouch: v.hasTouch, colorScheme: 'light', reducedMotion: 'reduce', }); const page = await context.newPage(); await blockLocalAnalytics(page); const errors = errorsFor(page); const cdp = await context.newCDPSession(page); await cdp.send('Emulation.setScriptExecutionDisabled', {value: true}); await page.goto(`${BASE}${route}`, {waitUntil: 'domcontentloaded', timeout: 60000}); await page.waitForTimeout(500); await cdp.send('Emulation.setScriptExecutionDisabled', {value: false}); const label = kind === 'component' ? 'Loading component documentation' : 'Loading theme explorer'; const selector = `[role="status"][aria-label="${label}"]`; const expectedState = kind === 'component' ? {loadingName: label, heading: 'Date Input', skeletonTotal: 6, skeletonVisible: 6, footerBelowViewport: true} : {loadingName: label, heading: null, skeletonTotal: 6, skeletonVisible: vpName === 'mobile' ? 5 : 2, footerBelowViewport: true}; const imagePath = path.join(OUT, `current__${kind}__${vpName}__fallback.png`); const receipt = await captureWithSensors(page, imagePath, { label: `current ${kind} ${vpName} PPR fallback`, buildRoot: ROOT, expectedSha: SHA, story: route, target: selector, surface: 'body', sensorErrors: errors, expected: { globals: {}, themeAttr: 'astryx', colorMode: 'light dark', direction: 'ltr', viewport: {width: v.width, height: v.height, dpr: 1}, media: expectedMedia(v), targetCount: 1, state: expectedState, runningAnimations: 0, }, readState: async p => p.evaluate(({kind, label}) => { const visible = e => { const r=e.getBoundingClientRect(),s=getComputedStyle(e); return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'; }; const status = document.querySelector(`[role="status"][aria-label="${label}"]`); const skeletons = status ? [...status.querySelectorAll('[aria-hidden="true"]')] : []; const footer = document.querySelector('[role="contentinfo"]'); const heading = kind === 'component' ? [...document.querySelectorAll('*')].find(e => e.children.length===0 && e.textContent?.trim()==='Date Input' && visible(e))?.textContent?.trim() ?? null : null; return {loadingName: status?.getAttribute('aria-label') ?? null, heading, skeletonTotal: skeletons.length, skeletonVisible: skeletons.filter(visible).length, footerBelowViewport: !!footer && footer.getBoundingClientRect().top >= window.innerHeight}; }, {kind, label}), }); const ax = await axStatuses(cdp); const geometry = await page.evaluate(selector => { const s=document.querySelector(selector),f=document.querySelector('[role="contentinfo"]'); const box=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom}}; return {fallback:box(s),footer:box(f),viewport:{width:innerWidth,height:innerHeight},documentHeight:document.documentElement.scrollHeight}; }, selector); augmentReceipt(imagePath, {accessibility: {statuses: ax}, decisiveGeometry: geometry}); await context.close(); return {kind, route, viewport: vpName, imagePath, state: receipt.observed.state, ax, geometry}; } finally { await browser.close(); } } async function waitSettled(page, route) { if (route.startsWith('/components/')) { const wanted = new URL(`${BASE}${route}`).searchParams.get('tab') || 'overview'; await page.waitForFunction(value => { const e=document.querySelector(`[data-tab-value="${value}"]`); return e?.getAttribute('aria-current')==='true' && Object.keys(e).some(k=>k.startsWith('__reactProps$')); }, wanted, {timeout: 120000}); } else { const wanted = new URL(`${BASE}${route}`).searchParams.get('theme') === 'butter' ? 'Butter' : 'Neutral'; await page.waitForFunction(value => { const visible = e => { const r=e.getBoundingClientRect(),s=getComputedStyle(e); return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'; }; return [...document.querySelectorAll('button[aria-expanded]')].some(e=>visible(e) && e.textContent.trim()===value) || [...document.querySelectorAll('[role="option"][aria-selected="true"]')].some(e=>e.textContent.trim()===value); }, wanted, {timeout: 120000}); } } async function loadedCapture(kind, route) { const v = viewports.mobile; const browser = await chromium.launch({headless: true}); try { const context = await browser.newContext({viewport:{width:v.width,height:v.height},deviceScaleFactor:1,isMobile:true,hasTouch:true,colorScheme:'light',reducedMotion:'reduce'}); const page = await context.newPage(); await blockLocalAnalytics(page); const errors=errorsFor(page); await page.goto(`${BASE}${route}`, {waitUntil:'domcontentloaded',timeout:60000}); await waitSettled(page, route); await page.waitForTimeout(500); const expectedState = kind === 'component' ? {heading:'Date Input', tab:'properties', loadingStatusCount:0, extraQuery:'keep'} : {heading:'Themes', selectedTheme:'Butter', loadingStatusCount:0}; const imagePath=path.join(OUT,`current__${kind}__mobile__loaded.png`); await captureWithSensors(page,imagePath,{ label:`current ${kind} mobile loaded`,buildRoot:ROOT,expectedSha:SHA,story:route.split('?')[0],target:'body',surface:'body',sensorErrors:errors, expected:{globals:{},themeAttr:'astryx',colorMode:'light',direction:'ltr',viewport:{width:v.width,height:v.height,dpr:1},media:expectedMedia(v),targetCount:1,state:expectedState,runningAnimations:0}, readState: async p => p.evaluate(kind=>{ const visible=e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'}; if(kind==='component') return {heading:document.body.innerText.includes('Date Input')?'Date Input':null, tab:document.querySelector('[data-tab-value][aria-current="true"]')?.getAttribute('data-tab-value')??null, loadingStatusCount:document.querySelectorAll('[role="status"][aria-label="Loading component documentation"]').length, extraQuery:new URL(location.href).searchParams.get('foo')}; const h=[...document.querySelectorAll('h1')].find(visible); const trigger=[...document.querySelectorAll('button[aria-expanded]')].find(e=>visible(e)&&['Neutral','Butter'].includes(e.textContent.trim())); return {heading:h?.textContent.trim()??null,selectedTheme:trigger?.textContent.trim()??null, loadingStatusCount:document.querySelectorAll('[role="status"][aria-label="Loading theme explorer"]').length}; },kind), }); augmentReceipt(imagePath,{}); await context.close(); return {kind,route,imagePath}; } finally {await browser.close();} } function maxWindow(entries, includeRecent=false) { let max=0,sum=0,start=0,prev=0; for(const e of entries){if(e.hadRecentInput&&!includeRecent)continue;if(e.startTime-prev>1000||e.startTime-start>5000){start=e.startTime;sum=0}sum+=e.value;max=Math.max(max,sum);prev=e.startTime}return max; } async function measureCLS(route, viewport) { const browser=await chromium.launch({headless:true}); try { const context=await browser.newContext({viewport:{width:viewport.width,height:viewport.height},deviceScaleFactor:1,isMobile:!!viewport.isMobile,hasTouch:!!viewport.hasTouch,colorScheme:'light',reducedMotion:'no-preference'}); const page=await context.newPage(); await blockLocalAnalytics(page); const errors=errorsFor(page); await page.addInitScript(()=>{window.__shifts=[];new PerformanceObserver(list=>{for(const e of list.getEntries())window.__shifts.push({value:e.value,startTime:e.startTime,hadRecentInput:e.hadRecentInput,sources:(e.sources||[]).map(s=>({node:s.node?.tagName??null,previousRect:s.previousRect,currentRect:s.currentRect}))})}).observe({type:'layout-shift',buffered:true})}); const cdp=await context.newCDPSession(page);await cdp.send('Network.enable');await cdp.send('Network.setCacheDisabled',{cacheDisabled:true});await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:40,downloadThroughput:187500,uploadThroughput:93750,connectionType:'cellular3g'});await cdp.send('Emulation.setCPUThrottlingRate',{rate:6}); const started=Date.now();await page.goto(`${BASE}${route}`,{waitUntil:'domcontentloaded',timeout:90000});await waitSettled(page,route);await page.waitForTimeout(1000); const observed=await page.evaluate(()=>({height:document.documentElement.scrollHeight,shifts:window.__shifts, footerTop:document.querySelector('[role="contentinfo"]')?.getBoundingClientRect().top??null, componentStatus:document.querySelectorAll('[role="status"][aria-label="Loading component documentation"]').length, themeStatus:document.querySelectorAll('[role="status"][aria-label="Loading theme explorer"]').length})); observed.standardsCLS=maxWindow(observed.shifts);observed.inclusiveSessionWindow=maxWindow(observed.shifts,true);observed.elapsedMs=Date.now()-started;observed.errors=errors;observed.route=route;observed.viewport=viewport; await context.close();return observed; } finally {await browser.close();} } async function responseMetric(route){const r=await fetch(`${BASE}${route}`);const b=Buffer.from(await r.arrayBuffer());const z=require('zlib');const s=b.toString();return{route,status:r.status,cacheControl:r.headers.get('cache-control'),rawBytes:b.length,gzipBytes:z.gzipSync(b,{level:9}).length,title:s.match(/(.*?)<\/title>/s)?.[1]??null,canonical:s.match(/<link rel="canonical" href="([^"]+)"/)?.[1]??null,componentFallback:s.includes('Loading component documentation'),themeFallback:s.includes('Loading theme explorer')};} (async()=>{ const staticFrames=[]; const loadedFrames=[]; if (!process.env.CLS_ONLY) { for(const kind of ['component','themes']) for(const vp of ['mobile','desktop']) staticFrames.push(await staticCapture(kind,kind==='component'?'/components/DateInput':'/themes',vp)); loadedFrames.push(await loadedCapture('component','/components/DateInput?foo=keep&tab=properties')); loadedFrames.push(await loadedCapture('themes','/themes?theme=butter')); } const clss=[];for(const vp of [{name:'mobile',width:390,height:844,isMobile:true,hasTouch:true},{name:'desktop',width:1440,height:900},{name:'tall',width:1440,height:1200},{name:'tall1280',width:1440,height:1280}])for(const route of ['/components/DateInput','/components/DateInput?tab=properties','/themes','/themes?theme=butter']) { console.log(`CLS_START ${vp.name} ${route}`); const measured=await measureCLS(route,vp); clss.push(measured); console.log(`CLS_DONE ${vp.name} ${route} standards=${measured.standardsCLS} inclusive=${measured.inclusiveSessionWindow}`); } const responses=[];for(const r of ['/components/DateInput','/components/DateInput?tab=properties','/themes','/themes?theme=butter'])responses.push(await responseMetric(r)); const result={sha:SHA,staticFrames,loadedFrames,clss,responses};fs.writeFileSync(path.join(OUT,'current-evidence.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({staticFrames:staticFrames.map(x=>({kind:x.kind,viewport:x.viewport,state:x.state,geometry:x.geometry,ax:x.ax})),clss:clss.map(x=>({route:x.route,viewport:x.viewport.name,standardsCLS:x.standardsCLS,inclusive:x.inclusiveSessionWindow,footerTop:x.footerTop,errors:x.errors})),responses},null,2)); })().catch(e=>{console.error(e);process.exit(1)});