v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user