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