// Test harness helpers for loading and asserting against Imhotep fixture pages. // Uses dependency injection for all external dependencies. import { readFile } from 'node:fs/promises' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) // Resolve a fixture page path by category name. // Returns the absolute path to the HTML file. export function resolveFixturePage(category: string): string { const pagePath = resolve(__dirname, 'pages', `${category}.html`) return pagePath } // Load a fixture page as an HTML string. // Accepts fs-like dependency for testability. export async function loadFixturePage( category: string, deps: { fs?: { readFile: typeof readFile } } = {} ): Promise { const fs = deps.fs || { readFile } const path = resolveFixturePage(category) const content = await fs.readFile(path, 'utf-8') return content } interface TestIdMatch { testId: string tag: string raw: string } // Simple HTML parser helper to extract elements by data-testid. // Pure function for unit testing without a browser. export function findByTestId(html: string, testId: string): TestIdMatch[] { const regex = new RegExp(`data-testid="${testId}"`, 'g') const matches: TestIdMatch[] = [] let match: RegExpExecArray | null while ((match = regex.exec(html)) !== null) { // Walk backward to find the opening tag let start = match.index while (start > 0 && html[start] !== '<') { start-- } // Walk forward to find the closing > let end = match.index while (end < html.length && html[end] !== '>') { end++ } matches.push({ testId, tag: html.slice(start, end + 1), raw: html.slice(start, end + 1), }) } return matches } // Extract all data-testid values from fixture HTML. // Useful for verifying fixture completeness. export function extractAllTestIds(html: string): string[] { const regex = /data-testid="([^"]+)"/g const ids = new Set() let match: RegExpExecArray | null while ((match = regex.exec(html)) !== null) { ids.add(match[1]) } return Array.from(ids).sort() } // Assert that a fixture page contains expected test IDs. // Pure assertion helper for use in tests. export function assertFixtureStructure(html: string, expectedTestIds: string[]): boolean { const actualIds = extractAllTestIds(html) const missing = expectedTestIds.filter(id => !actualIds.includes(id)) const unexpected = actualIds.filter(id => !expectedTestIds.includes(id)) if (missing.length > 0 || unexpected.length > 0) { const errors = [] if (missing.length > 0) { errors.push(`Missing testIds: ${missing.join(', ')}`) } if (unexpected.length > 0) { errors.push(`Unexpected testIds: ${unexpected.join(', ')}`) } throw new Error(`Fixture structure mismatch:\n${errors.join('\n')}`) } return true } // Build a Playwright page object helper for fixture loading. // Accepts playwright page via dependency injection. export async function loadFixtureInPage( page: any, category: string, deps: Record = {} ): Promise { const path = resolveFixturePage(category) const fileUrl = 'file://' + path await page.goto(fileUrl) return page } // Wait for a fixture page to be fully ready. // Useful for ensuring CSS has applied before asserting. export async function waitForFixtureReady( page: any, deps: { timeout?: number } = {} ): Promise { const timeout = deps.timeout || 5000 await page.waitForLoadState('networkidle', { timeout }) // Give CSS transitions a moment to settle await page.waitForTimeout(100) } // Get computed styles for an element by testId. // Returns a plain object of style properties. export async function getComputedStyles( page: any, testId: string, properties: string[] = [] ): Promise> { const selector = `[data-testid="${testId}"]` const element = await page.locator(selector).first() if (properties.length === 0) { return await element.evaluate((el: Element) => { const styles = window.getComputedStyle(el) const result: Record = {} for (let i = 0; i < styles.length; i++) { const prop = styles[i] result[prop] = styles.getPropertyValue(prop) } return result }) } return await element.evaluate( (el: Element, props: string[]) => { const styles = window.getComputedStyle(el) const result: Record = {} for (const prop of props) { result[prop] = styles.getPropertyValue(prop) } return result }, properties ) } // Get bounding box for an element by testId. // Returns { x, y, width, height } in viewport coordinates. export async function getBoundingBox( page: any, testId: string ): Promise<{ x: number; y: number; width: number; height: number } | null> { const selector = `[data-testid="${testId}"]` const element = await page.locator(selector).first() return await element.boundingBox() } // Get all bounding boxes for elements matching a testId. // Useful for ambiguous selector testing. export async function getAllBoundingBoxes( page: any, testId: string ): Promise> { const selector = `[data-testid="${testId}"]` const elements = await page.locator(selector).all() const boxes = [] for (const element of elements) { const box = await element.boundingBox() boxes.push(box) } return boxes } // Materialize a state snapshot for an element. // Supported states: 'default', 'hover', 'focus-visible', 'active' export async function materializeState( page: any, testId: string, stateName: string, deps: { settleTime?: number } = {} ): Promise { const selector = `[data-testid="${testId}"]` const element = await page.locator(selector).first() switch (stateName) { case 'default': // Ensure no special state is active await page.mouse.move(0, 0) await page.evaluate(() => (document.activeElement as HTMLElement)?.blur()) break case 'hover': await element.hover() break case 'focus-visible': await element.focus() break case 'active': await element.dispatchEvent('mousedown') break default: throw new Error(`Unknown state: ${stateName}`) } // Allow CSS transitions to apply const settleTime = deps.settleTime || 150 await page.waitForTimeout(settleTime) } // Fixture metadata registry. // Maps category names to expected testIds and descriptions. export const fixtureRegistry: Record< string, { description: string; expectedTestIds: string[] } > = { relations: { description: 'Basic spatial relations: leftOf, rightOf, above, below, centeredWithin, alignedWith', expectedTestIds: [ 'box-left', 'box-right', 'box-top', 'box-bottom', 'container', 'centered', 'align-ref', 'align-subject', 'align-center-ref', 'align-center-subject', 'align-bottom-ref', 'align-bottom-subject', 'gap-a', 'gap-b', 'gap-c', 'leftOf-rightOf', 'above-below', 'centeredWithin', 'alignedWith-top', 'alignedWith-center', 'alignedWith-bottom', 'gap-assertion', ], }, frames: { description: 'Frame resolution: viewport, containing block, positioned ancestor, scroll container, named grid area', expectedTestIds: [ 'fixed-box', 'containing-block', 'absolute-child', 'positioned-ancestor', 'nested-absolute', 'scroll-container', 'scroll-item', 'grid-container', 'grid-header', 'grid-sidebar', 'grid-content', 'grid-footer', 'viewport-frame', 'containing-block-frame', 'positioned-ancestor-frame', 'scroll-container-frame', 'named-grid-area-frame', ], }, responsive: { description: 'Responsive layouts: breakpoints, touch targets, color scheme, container queries', expectedTestIds: [ 'responsive-sidebar', 'responsive-content', 'touch-target', 'theme-aware', 'cq-container', 'cq-item', 'breakpoint-layout', 'touch-target-responsive', 'color-scheme-responsive', 'container-query-responsive', ], }, topology: { description: 'Topology: clipping, scroll, stacking contexts, formatting contexts', expectedTestIds: [ 'overflow-clip-container', 'overflow-clipped-item', 'clip-path-container', 'clip-path-item', 'scroll-port', 'sticky-item', 'stacking-root', 'stack-bottom', 'stack-top', 'stack-peer-a', 'stack-peer-b', 'nested-stack-parent', 'nested-stack-child', 'nested-stack-grandchild', 'fc-container', 'float-box', 'bfc-box', 'overflow-clipping', 'clip-path-clipping', 'scroll-container', 'stacking-context', 'nested-stacking-context', 'formatting-context', ], }, states: { description: 'UI states: hover, focus-visible, active, disabled, checked, expanded, selected, pressed, visited', expectedTestIds: [ 'hover-btn', 'hover-card-el', 'focus-input', 'focus-btn', 'state-box-a', 'state-box-b', 'active-btn', 'hover-button', 'hover-card', 'focus-visible-input', 'focus-visible-button', 'state-comparison', 'active-state', 'disabled-btn', 'disabled-state', 'checked-input', 'checked-state', 'expand-btn', 'expand-panel', 'expanded-state', 'selected-option', 'selected-state', 'pressed-btn', 'pressed-state', 'visited-link', 'visited-state', ], }, errors: { description: 'Invalid authoring: ambiguous selectors, missing subjects', expectedTestIds: [ 'ambiguous-1', 'ambiguous-2', 'ambiguous-3', 'existing-el', 'empty-target-el', 'nested-ambiguous-1', 'nested-ambiguous-2', 'ambiguous-selector', 'missing-subject', 'empty-selector', 'nested-ambiguous', ], }, } // List all available fixture categories. export function listFixtureCategories(): string[] { return Object.keys(fixtureRegistry) } // Get metadata for a specific fixture category. export function getFixtureMetadata( category: string ): { description: string; expectedTestIds: string[] } | null { return fixtureRegistry[category] || null }