374 lines
10 KiB
TypeScript
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
|
|
}
|