Files
Imhotep/packages/imhotep-fixtures/src/harness.ts
T

374 lines
10 KiB
TypeScript

// 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<string> {
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<string>()
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<string, unknown> = {}
): Promise<any> {
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<void> {
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<Record<string, string>> {
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<string, string> = {}
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<string, string> = {}
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<Array<{ x: number; y: number; width: number; height: number } | null>> {
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<void> {
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
}