// Exact-head review probe for facebook/astryx #5516. // Expectations are fixed before observation: // - Button: simultaneous hover+active is hover at base, pressed at head. // - ClickableCard control: its pseudo-element selector already lets active win // at both base and head, proving the distinct pseudo path remains stable. const fs = require('fs'); const path = require('path'); const { openStory, captureWithSensors, row, } = require(process.env.HOME + '/astryx/probe-kit/lib.cjs'); const ARM = process.env.ARM; const PORT = Number(process.env.SB_PORT || 6351); const BUILD_ROOT = process.env.BUILD_ROOT; const SHA = process.env.SHA; const OUT = process.env.OUT; if (!['before', 'after'].includes(ARM) || !BUILD_ROOT || !SHA || !OUT) { throw new Error('ARM=before|after BUILD_ROOT= SHA= OUT= are required'); } fs.mkdirSync(OUT, {recursive: true}); async function forceValues(page, target, property, pseudo = null) { const session = await page.context().newCDPSession(page); try { await session.send('DOM.enable'); await session.send('CSS.enable'); const {root} = await session.send('DOM.getDocument', {depth: -1}); const {nodeId} = await session.send('DOM.querySelector', { nodeId: root.nodeId, selector: target, }); const read = async states => { await session.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: states, }); await page.waitForTimeout(220); return page.$eval( target, (element, {property, pseudo}) => getComputedStyle(element, pseudo).getPropertyValue(property), {property, pseudo}, ); }; const hover = await read(['hover']); const activeOnly = await read(['active']); await read([]); return {hover, activeOnly}; } finally { await session.detach(); } } function winner({hover, activeOnly, simultaneous}) { if (simultaneous === activeOnly && simultaneous !== hover) return 'pressed'; if (simultaneous === hover && simultaneous !== activeOnly) return 'hover'; if (simultaneous === hover && simultaneous === activeOnly) return 'indistinguishable'; return 'other'; } async function clipFor(page, target) { const box = await page.locator(target).boundingBox(); if (!box) throw new Error(`No box for ${target}`); const pad = 18; return { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.width + 2 * pad, height: box.height + 2 * pad, }; } async function capturePressed({ story, target, property, pseudo, label, expectedWinner, expectedSurfaceClass, readState, }) { const opened = await openStory('chromium', story, { port: PORT, viewport: {width: 640, height: 300}, waitUntil: 'domcontentloaded', gotoTimeout: 90000, ready: () => document.querySelectorAll('[data-astryx-theme]').length > 0, }); const {browser, page, sensorErrors} = opened; page.on('dialog', dialog => dialog.dismiss()); try { await page.waitForFunction( ({target, count}) => document.querySelectorAll(target).length === count, {target, count: 1}, {timeout: 30000}, ); const forced = await forceValues(page, target, property, pseudo); const box = await page.locator(target).boundingBox(); if (!box) throw new Error(`No visible target for ${label}`); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.waitForTimeout(220); const physicalHover = await page.$eval( target, (element, {property, pseudo}) => getComputedStyle(element, pseudo).getPropertyValue(property), {property, pseudo}, ); await page.mouse.down(); await page.waitForTimeout(220); const simultaneous = await page.$eval( target, (element, {property, pseudo}) => getComputedStyle(element, pseudo).getPropertyValue(property), {property, pseudo}, ); const result = winner({ hover: physicalHover, activeOnly: forced.activeOnly, simultaneous, }); if (result !== expectedWinner) { throw new Error( `${label}: expected ${expectedWinner}, observed ${result}: ` + JSON.stringify({forced, physicalHover, simultaneous}), ); } const imagePath = path.join(OUT, `${ARM}__${label}.png`); const state = await readState(page, target); await captureWithSensors(page, imagePath, { label: `${ARM} · ${label} · simultaneous hover+active`, buildRoot: BUILD_ROOT, expectedSha: SHA, story, target, surface: target, expected: { globals: {}, themeAttr: 'neutral', colorMode: 'light', direction: 'ltr', viewport: {width: 640, height: 300, dpr: 1}, media: { forcedColors: false, reducedMotion: false, coarsePointer: false, hover: true, }, targetCount: 1, state, runningAnimations: 0, surfaceClass: expectedSurfaceClass, }, readState: p => readState(p, target), sensorErrors, screenshot: {clip: await clipFor(page, target)}, }); await page.mouse.up(); row(`${ARM}:${label}`, { winner: result, hover: physicalHover, activeOnly: forced.activeOnly, both: simultaneous, }); return { arm: ARM, label, expectedWinner, observedWinner: result, property, pseudo, forcedHover: forced.hover, physicalHover, activeOnly: forced.activeOnly, simultaneous, imagePath, receiptPath: `${imagePath}.sensors.json`, }; } finally { await browser.close(); } } async function main() { const button = await capturePressed({ story: 'core-button--primary', target: 'button.astryx-button', property: 'background-image', pseudo: null, label: 'button-primary-pressed', expectedWinner: ARM === 'before' ? 'hover' : 'pressed', expectedSurfaceClass: 'dark', readState: (page, target) => page.$eval(target, button => ({ name: button.innerText.trim(), hover: button.matches(':hover'), active: button.matches(':active'), disabled: button.disabled, ariaDisabled: button.getAttribute('aria-disabled'), })), }); const clickableCard = await capturePressed({ story: 'core-clickablecard--with-on-click', target: '.astryx-clickable-card', property: 'background-color', pseudo: '::after', label: 'clickable-card-pressed-control', expectedWinner: 'pressed', expectedSurfaceClass: 'light', readState: (page, target) => page.$eval(target, card => ({ hiddenButtonName: card.querySelector('button[aria-label]')?.getAttribute('aria-label') ?? null, hover: card.matches(':hover'), active: card.matches(':active'), ariaDisabled: card.querySelector('[aria-disabled]')?.getAttribute('aria-disabled') ?? null, })), }); const metricsPath = path.join(OUT, `${ARM}__overlay-metrics.json`); fs.writeFileSync(metricsPath, `${JSON.stringify({button, clickableCard}, null, 2)}\n`); console.log(`metrics=${metricsPath}`); } main().catch(error => { console.error(error.stack || error.message); process.exit(1); });