v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
{
"name": "imhotep-state",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./supported-matrix": {
"types": "./dist/supported-matrix.d.ts",
"default": "./dist/supported-matrix.js"
}
},
"dependencies": {
"imhotep-core": "^1.0.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
// imhotep-state — State snapshot system for Imhotep
//
// Materializes geometry snapshots for UI states (default, hover, focus,
// focus-visible, active) and supports transition sampling for animation
// assertions. All backend dependencies are injected via adapter interfaces.
export * from './snapshots.js'
export * from './states.js'
export * from './transitions.js'
export * from './materializer.js'
export * from './supported-matrix.js'
+177
View File
@@ -0,0 +1,177 @@
import type { Environment } from 'imhotep-core'
import type {
SnapshotId,
GeometrySnapshot,
SnapshotStore,
SnapshotMetadata,
} from './snapshots.js'
import { createSnapshotStore, createSnapshotId } from './snapshots.js'
import type { StateMaterializer } from './states.js'
import type { TransitionSampleConfig, TransitionSampler } from './transitions.js'
import { createTransitionSampler } from './transitions.js'
import {
getStateSupportInfo,
isSupportedState,
type SupportedStateKind,
} from './supported-matrix.js'
// ---------------------------------------------------------------------------
// Extractor Adapter
// ---------------------------------------------------------------------------
/**
* Minimal adapter for capturing a geometry world.
*
* The actual extractor implementation lives in `imhotep-extractor`.
* imhotep-state only needs a callback that returns the current world
* so it can package it into a tagged snapshot.
*/
export interface ExtractorAdapter {
/** Capture the current geometry world from the runtime. */
extract(): Promise<unknown>
}
// ---------------------------------------------------------------------------
// Materializer Configuration
// ---------------------------------------------------------------------------
/**
* Dependencies injected into the snapshot materializer.
*
* All external concerns (browser control, geometry extraction) are
* supplied by the caller so the materializer remains backend-agnostic.
*/
export interface SnapshotMaterializerConfig {
/** Adapter for driving the UI to a specific state. */
stateMaterializer: StateMaterializer
/** Adapter for capturing the geometry world after state is reached. */
extractor: ExtractorAdapter
/** Optional custom transition sampler (defaults to createTransitionSampler). */
transitionSampler?: TransitionSampler
}
// ---------------------------------------------------------------------------
// Snapshot Materializer
// ---------------------------------------------------------------------------
/**
* Orchestrates state materialization and geometry snapshot capture.
*
* This is the main entry point for the imhotep-state package.
* It combines a state materializer (interaction or CSS pseudo) with
* an extractor to produce tagged GeometrySnapshot instances.
*/
export class SnapshotMaterializer {
private config: SnapshotMaterializerConfig
private store: SnapshotStore
constructor(config: SnapshotMaterializerConfig) {
this.config = config
this.store = createSnapshotStore()
}
/**
* Materialize a single UI state and capture a geometry snapshot.
*
* @param state - The state to materialize (default, hover, focus, etc).
* @param selector - The element selector to target.
* @param env - Optional environment case active for this snapshot.
* @returns The captured geometry snapshot with state metadata.
* @throws Error when state is unsupported in V1.0.
*/
async materializeState(
state: SupportedStateKind,
selector: string,
env?: Environment
): Promise<GeometrySnapshot> {
// Fail closed: unsupported states must error, not pass silently.
if (!isSupportedState(state)) {
const info = getStateSupportInfo(state)
throw new Error(info.note ?? `State '${state}' is not supported in V1.0`)
}
const result = await this.config.stateMaterializer.materialize(state, selector)
// Add approximation note to diagnostics if state is approximate.
const supportInfo = getStateSupportInfo(state)
const approximationNote =
supportInfo.status === 'approximate' ? supportInfo.note : undefined
const world = await this.config.extractor.extract()
const metadata: SnapshotMetadata = {
snapshotId: createSnapshotId(),
stateKind: state,
stateSource: result.source,
selector,
timestamp: Date.now(),
env,
approximationNote,
}
const snapshot: GeometrySnapshot = {
id: metadata.snapshotId,
metadata,
world,
}
this.store.add(snapshot)
return snapshot
}
/**
* Sample a transition across multiple time slices.
*
* Each sample is captured as a separate geometry snapshot and stored.
*
* @param config - Transition sampling configuration.
* @param env - Optional environment case active for the transition.
* @returns Array of captured snapshots in chronological order.
*/
async materializeTransition(
config: TransitionSampleConfig,
env?: Environment
): Promise<GeometrySnapshot[]> {
const sampler = this.config.transitionSampler ?? createTransitionSampler()
return sampler.sample(
config,
this.config.stateMaterializer,
async () => {
const world = await this.config.extractor.extract()
const metadata: SnapshotMetadata = {
snapshotId: createSnapshotId(),
stateKind: 'default',
stateSource: 'synthetic',
selector: config.selector,
timestamp: Date.now(),
env,
}
const snapshot: GeometrySnapshot = {
id: metadata.snapshotId,
metadata,
world,
}
this.store.add(snapshot)
return snapshot
}
)
}
/** Returns the snapshot store populated by this materializer. */
getStore(): SnapshotStore {
return this.store
}
/** Convenience accessor: list all captured snapshots. */
getSnapshots(): GeometrySnapshot[] {
return this.store.list()
}
/** Convenience accessor: retrieve a snapshot by id. */
getSnapshot(id: SnapshotId): GeometrySnapshot | undefined {
return this.store.get(id)
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { ImhotepId, StateKind, Environment, ExecutionContext } from 'imhotep-core'
import { getDefaultContext } from 'imhotep-core'
// ---------------------------------------------------------------------------
// Snapshot Identity
// ---------------------------------------------------------------------------
/** Stable identifier for a geometry snapshot. */
export type SnapshotId = ImhotepId
// Module-level monotonic counter ensures unique ids even when deterministic
// test contexts return identical clock/idGenerator values per call.
let _snapshotCounter = 0
/** Generates a unique snapshot id with an optional prefix.
* When no context is provided, uses the global default context.
* A module-level counter guarantees uniqueness across consecutive calls.
*/
export function createSnapshotId(prefix = 'snap', ctx?: ExecutionContext): SnapshotId {
const context = ctx ?? getDefaultContext()
const counter = ++_snapshotCounter
return `${prefix}_${context.clock()}_${context.idGenerator()}_${counter}`
}
// ---------------------------------------------------------------------------
// State Source Classification
// ---------------------------------------------------------------------------
/**
* Describes how a state was materialized.
*
* - interaction: Real Playwright interaction (hover, focus, mousedown).
* - css-pseudo: Synthetic emulation via forced CSS pseudo-class styles.
* - synthetic: Computed projection without browser interaction.
* - component-hook: Materialized via a component adapter hook.
*/
export type StateSource = 'interaction' | 'css-pseudo' | 'synthetic' | 'component-hook'
// ---------------------------------------------------------------------------
// Snapshot Metadata
// ---------------------------------------------------------------------------
/**
* Metadata attached to every geometry snapshot so diagnostics can
* trace which state, source, and environment produced it.
*/
export interface SnapshotMetadata {
/** Unique id for this snapshot instance. */
snapshotId: SnapshotId
/** The UI state kind that was materialized (hover, focus, etc). */
stateKind: StateKind
/** How the state was produced (real interaction vs synthetic). */
stateSource: StateSource
/** Selector of the element that received the state, if any. */
selector?: string
/** Milliseconds since epoch when the snapshot was captured. */
timestamp: number
/** Environment case active when the snapshot was taken. */
env?: Environment
/** Duration of the transition sample in ms, if applicable. */
durationMs?: number
/** Approximation note for diagnostics when state is not fully determinate. */
approximationNote?: string
}
// ---------------------------------------------------------------------------
// Geometry Snapshot
// ---------------------------------------------------------------------------
/**
* A captured geometry world for a specific UI state.
*
* The `world` property holds the extractor output (geometry world schema).
* It is typed as `unknown` here because the exact schema is owned by the
* extractor package; imhotep-state materializes and tags snapshots without
* needing to inspect world internals.
*/
export interface GeometrySnapshot {
/** Snapshot identifier used by the solver and diagnostics. */
id: SnapshotId
/** State metadata for traceability and comparison. */
metadata: SnapshotMetadata
/** The materialized geometry world produced by the extractor. */
world: unknown
}
// ---------------------------------------------------------------------------
// Snapshot Comparison
// ---------------------------------------------------------------------------
/** Result of comparing two geometry snapshots. */
export interface SnapshotDiff {
/** True when both snapshots have identical world and metadata. */
identical: boolean
/** Human-readable list of detected differences. */
differences: string[]
}
/**
* Deep-compares two geometry snapshots for equality.
*
* Used by tests and diagnostics to detect unexpected changes between
* states (e.g. hover caused the element to shift).
*/
export function compareSnapshots(a: GeometrySnapshot, b: GeometrySnapshot): SnapshotDiff {
const differences: string[] = []
if (a.id !== b.id) {
differences.push(`id changed from "${a.id}" to "${b.id}"`)
}
if (a.metadata.stateKind !== b.metadata.stateKind) {
differences.push(
`stateKind changed from "${a.metadata.stateKind}" to "${b.metadata.stateKind}"`
)
}
if (a.metadata.stateSource !== b.metadata.stateSource) {
differences.push(
`stateSource changed from "${a.metadata.stateSource}" to "${b.metadata.stateSource}"`
)
}
if (a.metadata.selector !== b.metadata.selector) {
differences.push(
`selector changed from "${a.metadata.selector}" to "${b.metadata.selector}"`
)
}
// Compare world via JSON serialization for a stable deep equality check.
// This is sufficient for unit tests and diagnostic diffs.
const worldA = JSON.stringify(a.world)
const worldB = JSON.stringify(b.world)
if (worldA !== worldB) {
differences.push('world content differs')
}
return {
identical: differences.length === 0,
differences,
}
}
// ---------------------------------------------------------------------------
// Snapshot Registry
// ---------------------------------------------------------------------------
/**
* In-memory store for geometry snapshots during a test run.
*
* The materializer populates this store; the solver reads from it.
*/
export interface SnapshotStore {
/** Add a snapshot to the store. */
add(snapshot: GeometrySnapshot): void
/** Retrieve a snapshot by id. */
get(id: SnapshotId): GeometrySnapshot | undefined
/** List all snapshots in insertion order. */
list(): GeometrySnapshot[]
/** Remove all snapshots. */
clear(): void
}
/** Factory for a default Map-backed snapshot store. */
export function createSnapshotStore(): SnapshotStore {
const store = new Map<SnapshotId, GeometrySnapshot>()
return {
add(snapshot) {
store.set(snapshot.id, snapshot)
},
get(id) {
return store.get(id)
},
list() {
return Array.from(store.values())
},
clear() {
store.clear()
},
}
}
+651
View File
@@ -0,0 +1,651 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
createSnapshotId,
createSnapshotStore,
compareSnapshots,
type GeometrySnapshot,
} from './snapshots.js'
import {
createStateMaterializer,
createCssPseudoStateMaterializer,
createAriaStateMaterializer,
createFormStateMaterializer,
createPseudoClassStateMaterializer,
type StateMaterializationAdapter,
type CssPseudoEmulationAdapter,
type AriaStateMaterializationAdapter,
type FormStateMaterializationAdapter,
type PseudoClassMaterializationAdapter,
} from './states.js'
import { SUPPORTED_STATE_KINDS } from './supported-matrix.js'
import {
createTransitionSampler,
resolveSampleTimes,
type TransitionSampleConfig,
} from './transitions.js'
import {
SnapshotMaterializer,
type ExtractorAdapter,
} from './materializer.js'
// ---------------------------------------------------------------------------
// Mock Adapters
// ---------------------------------------------------------------------------
function createMockAdapter(log: string[] = []): StateMaterializationAdapter {
return {
async resetState() { log.push('resetState') },
async hover(selector) { log.push(`hover:${selector}`) },
async focus(selector) { log.push(`focus:${selector}`) },
async focusVisible(selector) { log.push(`focusVisible:${selector}`) },
async active(selector) { log.push(`active:${selector}`) },
}
}
function createMockExtractor(worlds: unknown[]): ExtractorAdapter {
let index = 0
return {
async extract() {
const world = worlds[index % worlds.length]
index += 1
return world
},
}
}
function createMockCssAdapter(log: string[] = []): CssPseudoEmulationAdapter {
return {
async injectPseudoClassStyles(selector, pseudoClass) {
log.push(`inject:${selector}${pseudoClass}`)
},
async removePseudoClassStyles() {
log.push('removeStyles')
},
}
}
function createMockAriaAdapter(log: string[] = []): AriaStateMaterializationAdapter {
return {
async expand(selector) { log.push(`expand:${selector}`) },
async collapse(selector) { log.push(`collapse:${selector}`) },
async select(selector) { log.push(`select:${selector}`) },
async press(selector) { log.push(`press:${selector}`) },
}
}
function createMockFormAdapter(log: string[] = []): FormStateMaterializationAdapter {
return {
async disable(selector) { log.push(`disable:${selector}`) },
async check(selector) { log.push(`check:${selector}`) },
}
}
function createMockPseudoAdapter(log: string[] = []): PseudoClassMaterializationAdapter {
return {
async simulateVisited(selector) { log.push(`simulateVisited:${selector}`) },
async removeVisitedStyles() { log.push('removeVisitedStyles') },
}
}
// ---------------------------------------------------------------------------
// Snapshot Types
// ---------------------------------------------------------------------------
describe('snapshots', () => {
it('createSnapshotId generates unique ids', () => {
const a = createSnapshotId()
const b = createSnapshotId()
assert.notStrictEqual(a, b)
assert.ok(a.startsWith('snap_'))
})
it('createSnapshotId respects prefix', () => {
const id = createSnapshotId('test')
assert.ok(id.startsWith('test_'))
})
it('snapshot store adds and retrieves snapshots', () => {
const store = createSnapshotStore()
const snap: GeometrySnapshot = {
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'default',
stateSource: 'interaction',
timestamp: 0,
},
world: { rects: [] },
}
store.add(snap)
assert.strictEqual(store.get('s1')?.id, 's1')
assert.strictEqual(store.list().length, 1)
})
it('snapshot store clears all snapshots', () => {
const store = createSnapshotStore()
store.add({
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'default',
stateSource: 'interaction',
timestamp: 0,
},
world: {},
})
store.clear()
assert.strictEqual(store.list().length, 0)
})
it('compareSnapshots reports identical snapshots', () => {
const snap: GeometrySnapshot = {
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'default',
stateSource: 'interaction',
timestamp: 0,
},
world: { a: 1 },
}
const diff = compareSnapshots(snap, snap)
assert.strictEqual(diff.identical, true)
assert.deepStrictEqual(diff.differences, [])
})
it('compareSnapshots detects stateKind change', () => {
const a: GeometrySnapshot = {
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'default',
stateSource: 'interaction',
timestamp: 0,
},
world: {},
}
const b: GeometrySnapshot = {
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'hover',
stateSource: 'interaction',
timestamp: 0,
},
world: {},
}
const diff = compareSnapshots(a, b)
assert.strictEqual(diff.identical, false)
assert.ok(diff.differences.some((d) => d.includes('stateKind')))
})
it('compareSnapshots detects world content change', () => {
const a: GeometrySnapshot = {
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'default',
stateSource: 'interaction',
timestamp: 0,
},
world: { x: 1 },
}
const b: GeometrySnapshot = {
id: 's1',
metadata: {
snapshotId: 's1',
stateKind: 'default',
stateSource: 'interaction',
timestamp: 0,
},
world: { x: 2 },
}
const diff = compareSnapshots(a, b)
assert.strictEqual(diff.identical, false)
assert.ok(diff.differences.some((d) => d.includes('world content')))
})
})
// ---------------------------------------------------------------------------
// State Materialization
// ---------------------------------------------------------------------------
describe('states', () => {
it('SUPPORTED_STATE_KINDS contains expected states', () => {
assert.deepStrictEqual(SUPPORTED_STATE_KINDS, [
'default',
'hover',
'focus',
'focusVisible',
'active',
'disabled',
'checked',
'expanded',
'collapsed',
'selected',
'pressed',
'visited',
])
})
it('createStateMaterializer drives all interaction states', async () => {
const log: string[] = []
const adapter = createMockAdapter(log)
const materializer = createStateMaterializer(adapter)
const interactionStates = ['default', 'hover', 'focus', 'focusVisible', 'active']
for (const state of interactionStates) {
const result = await materializer.materialize(state as any, '.btn')
assert.strictEqual(result.success, true)
assert.strictEqual(result.stateKind, state)
assert.strictEqual(result.selector, '.btn')
assert.strictEqual(result.source, 'interaction')
}
assert.deepStrictEqual(log, [
'resetState',
'hover:.btn',
'focus:.btn',
'focusVisible:.btn',
'active:.btn',
])
})
it('createStateMaterializer accepts kebab-case state names', async () => {
const log: string[] = []
const adapter = createMockAdapter(log)
const materializer = createStateMaterializer(adapter)
const result = await materializer.materialize('focus-visible' as any, '.btn')
assert.strictEqual(result.success, true)
assert.strictEqual(result.stateKind, 'focusVisible')
assert.strictEqual(result.selector, '.btn')
assert.ok(log.includes('focusVisible:.btn'))
})
it('createStateMaterializer propagates adapter errors', async () => {
const failingAdapter: StateMaterializationAdapter = {
async resetState() { throw new Error('reset failed') },
async hover() { throw new Error('hover failed') },
async focus() { throw new Error('focus failed') },
async focusVisible() { throw new Error('focusVisible failed') },
async active() { throw new Error('active failed') },
}
const materializer = createStateMaterializer(failingAdapter)
const interactionStates = ['default', 'hover', 'focus', 'focusVisible', 'active']
for (const state of interactionStates) {
const result = await materializer.materialize(state as any, '.btn')
assert.strictEqual(result.success, false)
assert.ok(result.error)
assert.ok(result.error?.message.includes(state === 'default' ? 'reset' : state))
}
})
it('createCssPseudoStateMaterializer injects pseudo-classes', async () => {
const log: string[] = []
const adapter = createMockCssAdapter(log)
const materializer = createCssPseudoStateMaterializer(adapter)
const hoverResult = await materializer.materialize('hover', '.btn')
assert.strictEqual(hoverResult.success, true)
assert.strictEqual(hoverResult.source, 'css-pseudo')
assert.ok(log.includes('inject:.btn:hover'))
const defaultResult = await materializer.materialize('default', '.btn')
assert.strictEqual(defaultResult.success, true)
assert.ok(log.includes('removeStyles'))
})
it('createCssPseudoStateMaterializer handles injection errors', async () => {
const failingAdapter: CssPseudoEmulationAdapter = {
async injectPseudoClassStyles() { throw new Error('inject failed') },
async removePseudoClassStyles() { throw new Error('remove failed') },
}
const materializer = createCssPseudoStateMaterializer(failingAdapter)
const result = await materializer.materialize('hover', '.btn')
assert.strictEqual(result.success, false)
assert.ok(result.error?.message.includes('inject failed'))
})
// ---------------------------------------------------------------------------
// ARIA State Materialization
// ---------------------------------------------------------------------------
it('createAriaStateMaterializer drives all ARIA states', async () => {
const log: string[] = []
const adapter = createMockAriaAdapter(log)
const materializer = createAriaStateMaterializer(adapter)
const expandedResult = await materializer.materialize('expanded', '.panel')
assert.strictEqual(expandedResult.success, true)
assert.strictEqual(expandedResult.stateKind, 'expanded')
assert.strictEqual(expandedResult.selector, '.panel')
assert.strictEqual(expandedResult.source, 'synthetic')
assert.ok(log.includes('expand:.panel'))
const collapsedResult = await materializer.materialize('collapsed', '.panel')
assert.strictEqual(collapsedResult.success, true)
assert.strictEqual(collapsedResult.stateKind, 'collapsed')
assert.ok(log.includes('collapse:.panel'))
const selectedResult = await materializer.materialize('selected', '.option')
assert.strictEqual(selectedResult.success, true)
assert.strictEqual(selectedResult.stateKind, 'selected')
assert.ok(log.includes('select:.option'))
const pressedResult = await materializer.materialize('pressed', '.toggle')
assert.strictEqual(pressedResult.success, true)
assert.strictEqual(pressedResult.stateKind, 'pressed')
assert.ok(log.includes('press:.toggle'))
})
it('createAriaStateMaterializer rejects unsupported states', async () => {
const adapter = createMockAriaAdapter()
const materializer = createAriaStateMaterializer(adapter)
const result = await materializer.materialize('hover' as any, '.btn')
assert.strictEqual(result.success, false)
assert.ok(result.error?.message.includes('Unsupported ARIA state kind'))
})
it('createAriaStateMaterializer propagates adapter errors', async () => {
const failingAdapter: AriaStateMaterializationAdapter = {
async expand() { throw new Error('expand failed') },
async collapse() { throw new Error('collapse failed') },
async select() { throw new Error('select failed') },
async press() { throw new Error('press failed') },
}
const materializer = createAriaStateMaterializer(failingAdapter)
const expandedResult = await materializer.materialize('expanded', '.panel')
assert.strictEqual(expandedResult.success, false)
assert.ok(expandedResult.error?.message.includes('expand failed'))
const selectedResult = await materializer.materialize('selected', '.option')
assert.strictEqual(selectedResult.success, false)
assert.ok(selectedResult.error?.message.includes('select failed'))
})
// ---------------------------------------------------------------------------
// Form State Materialization
// ---------------------------------------------------------------------------
it('createFormStateMaterializer drives all form states', async () => {
const log: string[] = []
const adapter = createMockFormAdapter(log)
const materializer = createFormStateMaterializer(adapter)
const disabledResult = await materializer.materialize('disabled', '.input')
assert.strictEqual(disabledResult.success, true)
assert.strictEqual(disabledResult.stateKind, 'disabled')
assert.strictEqual(disabledResult.selector, '.input')
assert.strictEqual(disabledResult.source, 'synthetic')
assert.ok(log.includes('disable:.input'))
const checkedResult = await materializer.materialize('checked', '.checkbox')
assert.strictEqual(checkedResult.success, true)
assert.strictEqual(checkedResult.stateKind, 'checked')
assert.strictEqual(checkedResult.selector, '.checkbox')
assert.ok(log.includes('check:.checkbox'))
})
it('createFormStateMaterializer rejects unsupported states', async () => {
const adapter = createMockFormAdapter()
const materializer = createFormStateMaterializer(adapter)
const result = await materializer.materialize('hover' as any, '.btn')
assert.strictEqual(result.success, false)
assert.ok(result.error?.message.includes('Unsupported form state kind'))
})
it('createFormStateMaterializer propagates adapter errors', async () => {
const failingAdapter: FormStateMaterializationAdapter = {
async disable() { throw new Error('disable failed') },
async check() { throw new Error('check failed') },
}
const materializer = createFormStateMaterializer(failingAdapter)
const disabledResult = await materializer.materialize('disabled', '.input')
assert.strictEqual(disabledResult.success, false)
assert.ok(disabledResult.error?.message.includes('disable failed'))
const checkedResult = await materializer.materialize('checked', '.checkbox')
assert.strictEqual(checkedResult.success, false)
assert.ok(checkedResult.error?.message.includes('check failed'))
})
// ---------------------------------------------------------------------------
// Pseudo-Class State Materialization
// ---------------------------------------------------------------------------
it('createPseudoClassStateMaterializer drives visited state', async () => {
const log: string[] = []
const adapter = createMockPseudoAdapter(log)
const materializer = createPseudoClassStateMaterializer(adapter)
const visitedResult = await materializer.materialize('visited', '.link')
assert.strictEqual(visitedResult.success, true)
assert.strictEqual(visitedResult.stateKind, 'visited')
assert.strictEqual(visitedResult.selector, '.link')
assert.strictEqual(visitedResult.source, 'css-pseudo')
assert.ok(log.includes('simulateVisited:.link'))
})
it('createPseudoClassStateMaterializer resets on default', async () => {
const log: string[] = []
const adapter = createMockPseudoAdapter(log)
const materializer = createPseudoClassStateMaterializer(adapter)
const defaultResult = await materializer.materialize('default', '.link')
assert.strictEqual(defaultResult.success, true)
assert.strictEqual(defaultResult.stateKind, 'default')
assert.strictEqual(defaultResult.source, 'css-pseudo')
assert.ok(log.includes('removeVisitedStyles'))
})
it('createPseudoClassStateMaterializer rejects unsupported states', async () => {
const adapter = createMockPseudoAdapter()
const materializer = createPseudoClassStateMaterializer(adapter)
const result = await materializer.materialize('hover' as any, '.btn')
assert.strictEqual(result.success, false)
assert.ok(result.error?.message.includes('Unsupported pseudo-class state kind'))
})
it('createPseudoClassStateMaterializer propagates adapter errors', async () => {
const failingAdapter: PseudoClassMaterializationAdapter = {
async simulateVisited() { throw new Error('simulateVisited failed') },
async removeVisitedStyles() { throw new Error('removeVisitedStyles failed') },
}
const materializer = createPseudoClassStateMaterializer(failingAdapter)
const visitedResult = await materializer.materialize('visited', '.link')
assert.strictEqual(visitedResult.success, false)
assert.ok(visitedResult.error?.message.includes('simulateVisited failed'))
const defaultResult = await materializer.materialize('default', '.link')
assert.strictEqual(defaultResult.success, false)
assert.ok(defaultResult.error?.message.includes('removeVisitedStyles failed'))
})
})
// ---------------------------------------------------------------------------
// Transition Sampling
// ---------------------------------------------------------------------------
describe('transitions', () => {
it('resolveSampleTimes for keyframes mode', () => {
const config: TransitionSampleConfig = {
mode: 'keyframes',
selector: '.x',
keyframes: [0, 80, 160],
}
assert.deepStrictEqual(resolveSampleTimes(config), [0, 80, 160])
})
it('resolveSampleTimes defaults to [0] for empty keyframes', () => {
const config: TransitionSampleConfig = {
mode: 'keyframes',
selector: '.x',
}
assert.deepStrictEqual(resolveSampleTimes(config), [0])
})
it('resolveSampleTimes for range mode', () => {
const config: TransitionSampleConfig = {
mode: 'range',
selector: '.x',
from: 100,
to: 400,
}
assert.deepStrictEqual(resolveSampleTimes(config), [100, 400])
})
it('resolveSampleTimes for transition mode', () => {
const config: TransitionSampleConfig = {
mode: 'transition',
selector: '.x',
duration: 300,
sampleCount: 4,
}
assert.deepStrictEqual(resolveSampleTimes(config), [0, 100, 200, 300])
})
it('createTransitionSampler captures snapshots at each sample time', async () => {
const sampler = createTransitionSampler()
const captured: GeometrySnapshot[] = []
const mockCapture = async (): Promise<GeometrySnapshot> => ({
id: createSnapshotId(),
metadata: {
snapshotId: createSnapshotId(),
stateKind: 'default',
stateSource: 'synthetic',
selector: '.x',
timestamp: Date.now(),
},
world: {},
})
const config: TransitionSampleConfig = {
mode: 'keyframes',
selector: '.x',
keyframes: [0, 50, 100],
}
const snapshots = await sampler.sample(
config,
createStateMaterializer(createMockAdapter()),
mockCapture
)
assert.strictEqual(snapshots.length, 3)
assert.deepStrictEqual(
snapshots.map((s) => s.metadata.durationMs),
[0, 50, 100]
)
})
})
// ---------------------------------------------------------------------------
// Snapshot Materializer Integration
// ---------------------------------------------------------------------------
describe('materializer', () => {
it('materializes a state and stores the snapshot', async () => {
const stateMat = createStateMaterializer(createMockAdapter())
const extractor = createMockExtractor([{ rects: [{ x: 0 }] }])
const mat = new SnapshotMaterializer({
stateMaterializer: stateMat,
extractor,
})
const snapshot = await mat.materializeState('hover', '.btn')
assert.strictEqual(snapshot.metadata.stateKind, 'hover')
assert.strictEqual(snapshot.metadata.selector, '.btn')
assert.strictEqual(snapshot.metadata.stateSource, 'interaction')
assert.deepStrictEqual(snapshot.world, { rects: [{ x: 0 }] })
const fromStore = mat.getSnapshot(snapshot.id)
assert.ok(fromStore)
assert.strictEqual(fromStore!.id, snapshot.id)
})
it('materializes multiple states independently', async () => {
const stateMat = createStateMaterializer(createMockAdapter())
const extractor = createMockExtractor([
{ state: 'default' },
{ state: 'hover' },
{ state: 'focus' },
])
const mat = new SnapshotMaterializer({
stateMaterializer: stateMat,
extractor,
})
const s1 = await mat.materializeState('default', '.btn')
const s2 = await mat.materializeState('hover', '.btn')
const s3 = await mat.materializeState('focus', '.btn')
assert.strictEqual(mat.getSnapshots().length, 3)
assert.deepStrictEqual(s1.world, { state: 'default' })
assert.deepStrictEqual(s2.world, { state: 'hover' })
assert.deepStrictEqual(s3.world, { state: 'focus' })
})
it('materializes a transition with multiple samples', async () => {
const stateMat = createStateMaterializer(createMockAdapter())
const extractor = createMockExtractor([
{ t: 0 },
{ t: 50 },
{ t: 100 },
])
const mat = new SnapshotMaterializer({
stateMaterializer: stateMat,
extractor,
})
const snapshots = await mat.materializeTransition({
mode: 'keyframes',
selector: '.panel',
keyframes: [0, 50, 100],
})
assert.strictEqual(snapshots.length, 3)
assert.strictEqual(mat.getSnapshots().length, 3)
})
it('distinguishes synthetic vs interaction sources', async () => {
const interactionMat = createStateMaterializer(createMockAdapter())
const cssMat = createCssPseudoStateMaterializer(createMockCssAdapter())
const extractor = createMockExtractor([{}])
const interactionSnapshot = await new SnapshotMaterializer({
stateMaterializer: interactionMat,
extractor,
}).materializeState('hover', '.btn')
const cssSnapshot = await new SnapshotMaterializer({
stateMaterializer: cssMat,
extractor,
}).materializeState('hover', '.btn')
assert.strictEqual(interactionSnapshot.metadata.stateSource, 'interaction')
assert.strictEqual(cssSnapshot.metadata.stateSource, 'css-pseudo')
})
})
+456
View File
@@ -0,0 +1,456 @@
import type { StateSource } from './snapshots.js'
import {
SUPPORTED_STATE_KINDS,
getStateSupportInfo,
isSupportedState,
normalizeStateName,
type SupportedStateKind,
} from './supported-matrix.js'
// ---------------------------------------------------------------------------
// State Materialization Adapter
// ---------------------------------------------------------------------------
/**
* Minimal adapter interface for driving a browser to a specific state.
*
* Implementations are injected by the caller (e.g. a Playwright adapter).
* This keeps imhotep-state free of direct Playwright imports.
*/
export interface StateMaterializationAdapter {
/** Reset to default state (blur active element, move mouse away). */
resetState(): Promise<void>
/** Hover over the element matching `selector`. */
hover(selector: string): Promise<void>
/** Focus the element matching `selector`. */
focus(selector: string): Promise<void>
/**
* Focus the element and trigger focus-visible heuristics.
*
* Some browsers require a keyboard event to match :focus-visible.
*/
focusVisible(selector: string): Promise<void>
/** Trigger active state (mousedown without mouseup) on the element. */
active(selector: string): Promise<void>
}
// ---------------------------------------------------------------------------
// State Materialization Result
// ---------------------------------------------------------------------------
/** Outcome of a single state materialization attempt. */
export interface StateMaterializationResult {
/** Whether the browser reached the requested state. */
success: boolean
/** The state that was requested. */
stateKind: SupportedStateKind
/** The selector that was targeted. */
selector: string
/** How the state was produced (interaction vs synthetic). */
source: StateSource
/** Error details when success is false. */
error?: Error
/** Approximation note for diagnostics when state is not fully determinate. */
approximationNote?: string
}
// ---------------------------------------------------------------------------
// State Materializer
// ---------------------------------------------------------------------------
/**
* Contract for materializing a UI state on a target element.
*
* The implementation delegates to an injected adapter so the same
* core logic can run against Playwright, WebDriver, or a mock.
*/
export interface StateMaterializer {
materialize(
state: SupportedStateKind,
selector: string
): Promise<StateMaterializationResult>
}
/**
* Creates a real-interaction state materializer backed by the given adapter.
*
* All produced snapshots are tagged with `source: 'interaction'` so the
* reporter can distinguish them from CSS-emulated states.
*/
export function createStateMaterializer(
adapter: StateMaterializationAdapter
): StateMaterializer {
return {
async materialize(state, selector) {
// Fail closed: unsupported states must error, not pass silently.
if (!isSupportedState(state)) {
const info = getStateSupportInfo(state)
return {
success: false,
stateKind: state,
selector,
source: 'synthetic',
error: new Error(info.note ?? `State '${state}' is not supported in V1.0`),
}
}
const normalized = normalizeStateName(state) as SupportedStateKind
try {
switch (normalized) {
case 'default':
await adapter.resetState()
return {
success: true,
stateKind: normalized,
selector,
source: 'interaction',
}
case 'hover':
await adapter.hover(selector)
return {
success: true,
stateKind: normalized,
selector,
source: 'interaction',
}
case 'focus':
await adapter.focus(selector)
return {
success: true,
stateKind: normalized,
selector,
source: 'interaction',
}
case 'focusVisible':
await adapter.focusVisible(selector)
return {
success: true,
stateKind: normalized,
selector,
source: 'interaction',
// Approximation honesty: flag focus-visible as heuristic-based
approximationNote:
'focus-visible state is approximated: Shift+Tab heuristic may not match ' +
'true browser :focus-visible matching.',
}
case 'active':
await adapter.active(selector)
return {
success: true,
stateKind: normalized,
selector,
source: 'interaction',
}
default:
// Exhaustiveness guard — TypeScript narrows this away in practice.
return {
success: false,
stateKind: normalized,
selector,
source: 'synthetic',
error: new Error(`Unsupported state kind: ${state}`),
}
}
} catch (err) {
return {
success: false,
stateKind: normalized as SupportedStateKind,
selector,
source: 'synthetic',
error: err instanceof Error ? err : new Error(String(err)),
}
}
},
}
}
// ---------------------------------------------------------------------------
// ARIA State Materialization Adapter
// ---------------------------------------------------------------------------
/**
* Adapter for materializing generic ARIA states via DOM attribute
* manipulation and optional CSS class toggling.
*
* These states are inherently synthetic because ARIA attributes
* describe semantics that may not have a native DOM property.
*/
export interface AriaStateMaterializationAdapter {
/** Set aria-expanded="true" on the element matching `selector`. */
expand(selector: string): Promise<void>
/** Set aria-expanded="false" on the element matching `selector`. */
collapse(selector: string): Promise<void>
/** Set aria-selected="true" and add "selected" class on the element. */
select(selector: string): Promise<void>
/** Set aria-pressed="true" and add "pressed" class on the element. */
press(selector: string): Promise<void>
}
/** Mapping from ARIA state kinds to their adapter methods. */
const ARIA_STATE_MAP: Record<string, keyof AriaStateMaterializationAdapter> = {
expanded: 'expand',
collapsed: 'collapse',
selected: 'select',
pressed: 'press',
}
/**
* Creates a synthetic state materializer for generic ARIA states.
*
* Snapshots produced by this materializer are tagged with
* `source: 'synthetic'` because they manipulate DOM attributes
* directly rather than driving native browser behavior.
*/
export function createAriaStateMaterializer(
adapter: AriaStateMaterializationAdapter
): StateMaterializer {
return {
async materialize(state, selector) {
try {
const normalized = normalizeStateName(state)
const method = ARIA_STATE_MAP[normalized]
if (!method) {
return {
success: false,
stateKind: normalized as SupportedStateKind,
selector,
source: 'synthetic',
error: new Error(`Unsupported ARIA state kind: ${state}`),
}
}
await adapter[method](selector)
return {
success: true,
stateKind: normalized as SupportedStateKind,
selector,
source: 'synthetic',
}
} catch (err) {
return {
success: false,
stateKind: normalizeStateName(state) as SupportedStateKind,
selector,
source: 'synthetic',
error: err instanceof Error ? err : new Error(String(err)),
}
}
},
}
}
// ---------------------------------------------------------------------------
// Form State Materialization Adapter
// ---------------------------------------------------------------------------
/**
* Adapter for materializing form control states (disabled, checked).
*
* These states affect both DOM attributes and element properties,
* and may require event dispatch to trigger reactive updates.
*/
export interface FormStateMaterializationAdapter {
/** Set the disabled attribute/property on the element matching `selector`. */
disable(selector: string): Promise<void>
/** Check the checkbox or radio input matching `selector`. */
check(selector: string): Promise<void>
}
/** Mapping from form state kinds to their adapter methods. */
const FORM_STATE_MAP: Record<string, keyof FormStateMaterializationAdapter> = {
disabled: 'disable',
checked: 'check',
}
/**
* Creates a synthetic state materializer for form control states.
*
* Snapshots produced by this materializer are tagged with
* `source: 'synthetic'` because they manipulate DOM properties
* directly rather than simulating user input.
*/
export function createFormStateMaterializer(
adapter: FormStateMaterializationAdapter
): StateMaterializer {
return {
async materialize(state, selector) {
try {
const normalized = normalizeStateName(state)
const method = FORM_STATE_MAP[normalized]
if (!method) {
return {
success: false,
stateKind: normalized as SupportedStateKind,
selector,
source: 'synthetic',
error: new Error(`Unsupported form state kind: ${state}`),
}
}
await adapter[method](selector)
return {
success: true,
stateKind: normalized as SupportedStateKind,
selector,
source: 'synthetic',
}
} catch (err) {
return {
success: false,
stateKind: normalizeStateName(state) as SupportedStateKind,
selector,
source: 'synthetic',
error: err instanceof Error ? err : new Error(String(err)),
}
}
},
}
}
// ---------------------------------------------------------------------------
// Pseudo-Class State Materialization Adapter
// ---------------------------------------------------------------------------
/**
* Adapter for materializing pseudo-class states that cannot be
* triggered via real interaction (e.g. :visited).
*
* Because :visited is privacy-protected by browsers, the only
* reliable way to test it is via CSS injection.
*/
export interface PseudoClassMaterializationAdapter {
/**
* Inject a temporary stylesheet that simulates :visited styles
* for anchor elements matching `selector`.
*/
simulateVisited(selector: string): Promise<void>
/** Remove any previously injected :visited stylesheet. */
removeVisitedStyles(): Promise<void>
}
/**
* Creates a synthetic state materializer for pseudo-class states.
*
* Snapshots produced by this materializer are tagged with
* `source: 'css-pseudo'` because they rely on forced CSS rules.
*/
export function createPseudoClassStateMaterializer(
adapter: PseudoClassMaterializationAdapter
): StateMaterializer {
return {
async materialize(state, selector) {
try {
const normalized = normalizeStateName(state)
if (normalized === 'default') {
await adapter.removeVisitedStyles()
return {
success: true,
stateKind: normalized as SupportedStateKind,
selector,
source: 'css-pseudo',
}
}
if (normalized === 'visited') {
await adapter.simulateVisited(selector)
return {
success: true,
stateKind: normalized as SupportedStateKind,
selector,
source: 'css-pseudo',
}
}
return {
success: false,
stateKind: normalized as SupportedStateKind,
selector,
source: 'css-pseudo',
error: new Error(`Unsupported pseudo-class state kind: ${state}`),
}
} catch (err) {
return {
success: false,
stateKind: normalizeStateName(state) as SupportedStateKind,
selector,
source: 'css-pseudo',
error: err instanceof Error ? err : new Error(String(err)),
}
}
},
}
}
// ---------------------------------------------------------------------------
// CSS Pseudo-Class Emulation Materializer
// ---------------------------------------------------------------------------
/**
* Adapter for synthetic state materialization via forced CSS pseudo-classes.
*
* This is useful when real interactions are impossible (e.g. headless
* environments with unreliable input events) or when you want to isolate
* the visual effect of a pseudo-class from its input trigger.
*/
export interface CssPseudoEmulationAdapter {
/** Inject a stylesheet that forces `selector:pseudoClass` styles. */
injectPseudoClassStyles(selector: string, pseudoClass: string): Promise<void>
/** Remove any previously injected pseudo-class stylesheet. */
removePseudoClassStyles(): Promise<void>
}
/** Mapping from supported state kinds to their CSS pseudo-class names. */
const PSEUDO_MAP: Record<SupportedStateKind, string> = {
default: '',
hover: ':hover',
focus: ':focus',
focusVisible: ':focus-visible',
active: ':active',
disabled: ':disabled',
checked: ':checked',
expanded: '',
collapsed: '',
selected: '',
pressed: '',
visited: ':visited',
}
/**
* Creates a synthetic state materializer that forces CSS pseudo-classes.
*
* Snapshots produced by this materializer are tagged with
* `source: 'css-pseudo'` so diagnostics can flag them as emulated.
*/
export function createCssPseudoStateMaterializer(
adapter: CssPseudoEmulationAdapter
): StateMaterializer {
return {
async materialize(state, selector) {
try {
const normalized = normalizeStateName(state) as SupportedStateKind
if (normalized === 'default') {
await adapter.removePseudoClassStyles()
return {
success: true,
stateKind: normalized,
selector,
source: 'css-pseudo',
}
}
const pseudo = PSEUDO_MAP[normalized as SupportedStateKind]
await adapter.injectPseudoClassStyles(selector, pseudo)
return {
success: true,
stateKind: normalized,
selector,
source: 'css-pseudo',
}
} catch (err) {
return {
success: false,
stateKind: normalizeStateName(state) as SupportedStateKind,
selector,
source: 'css-pseudo',
error: err instanceof Error ? err : new Error(String(err)),
}
}
},
}
}
@@ -0,0 +1,248 @@
/** Supported state/environment matrix for Imhotep V1.0.
*
* This module defines the V1.0 contract: what states and environments
* can be honestly materialized, which are approximate, and which are
* explicitly unsupported.
*
* Every approximation MUST be visible in diagnostics. Native Playwright
* APIs are preferred over CSS injection.
*/
// ---------------------------------------------------------------------------
// Supported States
// ---------------------------------------------------------------------------
/** States that have native Playwright support and are determinate. */
export const SUPPORTED_STATES = ['default', 'hover', 'focus', 'active'] as const
/** States that are supported but rely on approximation/heuristics. */
export const APPROXIMATE_STATES = [
'focusVisible',
'disabled',
'checked',
'expanded',
'collapsed',
'selected',
'pressed',
'visited',
] as const
/** All states the V1.0 materializer will accept. */
export const SUPPORTED_STATE_KINDS = [
'default',
'hover',
'focus',
'focusVisible',
'active',
'disabled',
'checked',
'expanded',
'collapsed',
'selected',
'pressed',
'visited',
] as const
export type SupportedState = (typeof SUPPORTED_STATES)[number]
export type ApproximateState = (typeof APPROXIMATE_STATES)[number]
export type SupportedStateKind = SupportedState | ApproximateState
/** Human-readable support status for diagnostics. */
export type SupportStatus = 'native' | 'approximate' | 'ignored' | 'unsupported'
/** Metadata describing how honest a state's materialization is. */
export interface StateSupportInfo {
state: string
status: SupportStatus
note?: string
}
/** V1.0 state support matrix with honesty annotations. */
export const STATE_SUPPORT_MATRIX: Record<string, StateSupportInfo> = {
default: { state: 'default', status: 'native', note: 'Reset to baseline' },
hover: {
state: 'hover',
status: 'native',
note: 'Playwright locator.hover()',
},
focus: {
state: 'focus',
status: 'native',
note: 'Playwright locator.focus()',
},
focusVisible: {
state: 'focusVisible',
status: 'approximate',
note:
'focus-visible state is approximated: locator.focus() + keyboard.press("Shift"). ' +
'True :focus-visible matching requires browser heuristics we cannot fully control.',
},
active: {
state: 'active',
status: 'native',
note: 'Playwright locator.click() or mouse down',
},
disabled: {
state: 'disabled',
status: 'approximate',
note:
'disabled state is approximated: sets the disabled attribute and adds ' +
'a :disabled CSS class. True :disabled pseudo-class matching requires ' +
'native browser attribute semantics.',
},
checked: {
state: 'checked',
status: 'approximate',
note:
'checked state is approximated: sets element.checked = true and ' +
'dispatches a change event. True :checked matching depends on native ' +
'form control state.',
},
expanded: {
state: 'expanded',
status: 'approximate',
note:
'expanded state is approximated: sets aria-expanded="true". ' +
'Associated display toggling is left to the caller.',
},
collapsed: {
state: 'collapsed',
status: 'approximate',
note:
'collapsed state is approximated: sets aria-expanded="false". ' +
'Associated display toggling is left to the caller.',
},
selected: {
state: 'selected',
status: 'approximate',
note:
'selected state is approximated: sets aria-selected="true" and ' +
'adds a "selected" CSS class.',
},
pressed: {
state: 'pressed',
status: 'approximate',
note:
'pressed state is approximated: sets aria-pressed="true" and ' +
'adds a "pressed" CSS class.',
},
visited: {
state: 'visited',
status: 'approximate',
note:
'visited state is approximated: injects a temporary <style> tag ' +
'with a:visited rules. True :visited pseudo-class is privacy-protected ' +
'and cannot be read or set via JavaScript.',
},
custom: {
state: 'custom',
status: 'unsupported',
note: "State 'custom' is not supported in V1.0",
},
}
/** Convert kebab-case or other hyphenated forms to camelCase. */
export function normalizeStateName(state: string): string {
return state.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
/** Lookup support info for a state kind. */
export function getStateSupportInfo(state: string): StateSupportInfo {
const normalized = normalizeStateName(state)
return (
STATE_SUPPORT_MATRIX[normalized] ?? {
state: normalized,
status: 'unsupported',
note: `State '${state}' is not supported in V1.0`,
}
)
}
/** Check if a state is supported (native or approximate). */
export function isSupportedState(state: string): boolean {
const info = getStateSupportInfo(state)
return info.status === 'native' || info.status === 'approximate'
}
// ---------------------------------------------------------------------------
// Supported Environments
// ---------------------------------------------------------------------------
/** Environment axes with native Playwright support. */
export const SUPPORTED_ENVIRONMENTS = [
'viewport',
'colorScheme',
] as const
/** Environment axes that are supported but approximate. */
export const APPROXIMATE_ENVIRONMENTS = ['reducedMotion', 'pointer'] as const
/** Environment axes that are present in the type system but ignored at runtime. */
export const IGNORED_ENVIRONMENTS = ['locale', 'deviceScaleFactor'] as const
/** All environment axes the V1.0 materializer will accept. */
export const SUPPORTED_ENVIRONMENT_KINDS = [
...SUPPORTED_ENVIRONMENTS,
...APPROXIMATE_ENVIRONMENTS,
...IGNORED_ENVIRONMENTS,
] as const
export type SupportedEnvironment = (typeof SUPPORTED_ENVIRONMENTS)[number]
export type ApproximateEnvironment = (typeof APPROXIMATE_ENVIRONMENTS)[number]
export type SupportedEnvironmentKind = SupportedEnvironment | ApproximateEnvironment
/** V1.0 environment support matrix with honesty annotations. */
export const ENV_SUPPORT_MATRIX: Record<string, StateSupportInfo> = {
viewport: {
state: 'viewport',
status: 'native',
note: 'Playwright page.setViewportSize()',
},
colorScheme: {
state: 'colorScheme',
status: 'native',
note: 'Playwright page.emulateMedia({ colorScheme })',
},
reducedMotion: {
state: 'reducedMotion',
status: 'approximate',
note:
'reducedMotion state is approximated: uses Playwright page.emulateMedia({ reducedMotion }) ' +
'when available, otherwise falls back to CSS injection. True media-query matching ' +
'requires browser-level emulation.',
},
pointer: {
state: 'pointer',
status: 'approximate',
note:
'pointer state is approximated: uses CSS custom property or Playwright viewport.hasTouch. ' +
'True pointer-type media queries require browser-level emulation not exposed by Playwright.',
},
locale: {
state: 'locale',
status: 'ignored',
note: 'Locale is set on document.documentElement.lang but is not part of the V1.0 environment matrix.',
},
deviceScaleFactor: {
state: 'deviceScaleFactor',
status: 'ignored',
note: 'Device scale factor cannot be changed on an existing Playwright page context.',
},
}
/** Lookup support info for an environment kind. */
export function getEnvSupportInfo(env: string): StateSupportInfo {
return (
ENV_SUPPORT_MATRIX[env] ?? {
state: env,
status: 'unsupported',
note: `Environment '${env}' is not supported in V1.0`,
}
)
}
/** Check if an environment is supported (native, approximate, or ignored). */
export function isSupportedEnvironment(env: string): boolean {
const info = getEnvSupportInfo(env)
return info.status === 'native' || info.status === 'approximate' || info.status === 'ignored'
}
+127
View File
@@ -0,0 +1,127 @@
import type { GeometrySnapshot } from './snapshots.js'
import type { StateMaterializer } from './states.js'
// ---------------------------------------------------------------------------
// Transition Sampling Configuration
// ---------------------------------------------------------------------------
/** Mode that determines how a transition is sampled. */
export type TransitionMode = 'keyframes' | 'range' | 'transition'
/**
* Configuration for sampling a transition or animation.
*
* The materializer uses this to decide which time slices to capture.
*/
export interface TransitionSampleConfig {
/** Sampling strategy. */
mode: TransitionMode
/** Target element selector for the transition. */
selector: string
/** Explicit keyframe timestamps in ms (keyframes mode). */
keyframes?: number[]
/** Start of the sampled range in ms (range mode). */
from?: number
/** End of the sampled range in ms (range mode). */
to?: number
/** Total duration of the transition in ms (transition mode). */
duration?: number
/** Number of evenly-spaced samples (transition mode). */
sampleCount?: number
}
// ---------------------------------------------------------------------------
// Transition Sampler
// ---------------------------------------------------------------------------
/**
* Contract for sampling snapshots across a temporal range.
*
* The sampler receives a state materializer and a capture callback.
* It is responsible for scrubbing or waiting to each sample time and
* invoking the callback to produce a geometry snapshot.
*/
export interface TransitionSampler {
/**
* Sample the transition according to `config`.
*
* @param config - Sampling configuration.
* @param materializer - State materializer used to set up the initial state.
* @param capture - Callback that captures a single geometry snapshot.
* @returns Array of snapshots in chronological order.
*/
sample(
config: TransitionSampleConfig,
materializer: StateMaterializer,
capture: () => Promise<GeometrySnapshot>
): Promise<GeometrySnapshot[]>
}
// ---------------------------------------------------------------------------
// Sampling Helpers
// ---------------------------------------------------------------------------
/**
* Resolve the list of sample times (in ms) from a configuration.
*
* - keyframes: returns the provided keyframe array.
* - range: returns [from, to].
* - transition: returns evenly-spaced samples across duration.
*/
export function resolveSampleTimes(config: TransitionSampleConfig): number[] {
switch (config.mode) {
case 'keyframes': {
return config.keyframes?.length ? config.keyframes : [0]
}
case 'range': {
const from = config.from ?? 0
const to = config.to ?? from
return from === to ? [from] : [from, to]
}
case 'transition': {
const duration = config.duration ?? 300
const count = Math.max(2, config.sampleCount ?? 3)
const step = duration / (count - 1)
return Array.from({ length: count }, (_, i) => Math.round(i * step))
}
default: {
// Exhaustiveness fallback.
return [0]
}
}
}
// ---------------------------------------------------------------------------
// Default Transition Sampler
// ---------------------------------------------------------------------------
/**
* Creates a transition sampler that captures a snapshot at each sample time.
*
* This is a simplified implementation suitable for browser-backed
* measurement. A production sampler may use CDP animation scrubbing
* or requestAnimationFrame timing for higher precision.
*/
export function createTransitionSampler(): TransitionSampler {
return {
async sample(config, _materializer, capture) {
const times = resolveSampleTimes(config)
const snapshots: GeometrySnapshot[] = []
for (const time of times) {
// In a full implementation this would seek the animation to `time`
// before capturing. Here we capture sequentially and annotate the
// snapshot metadata with the intended sample time.
const snapshot = await capture()
if (typeof snapshot.metadata.durationMs === 'number') {
snapshot.metadata.durationMs = time
} else {
snapshot.metadata.durationMs = time
}
snapshots.push(snapshot)
}
return snapshots
},
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}