v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "imhotep-bench",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"imhotep-core": "^1.0.0",
|
||||
"imhotep-solver": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { createEmptyWorld, adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
console.log('=== World Adaptation Benchmark ===\n')
|
||||
|
||||
for (const count of [10, 50, 100, 500, 1000, 5000]) {
|
||||
const canonical = createEmptyWorld('scene', 'snap')
|
||||
canonical.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
canonical.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
|
||||
canonical.subjects.subjectKind = new Uint8Array(Array.from({length: count}, () => 1))
|
||||
canonical.subjects.primaryBoxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
|
||||
canonical.subjects.firstFragmentId = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.subjects.fragmentCount = new Uint8Array(Array.from({length: count}, () => 1))
|
||||
|
||||
canonical.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
|
||||
canonical.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
canonical.boxes.frameId = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
|
||||
canonical.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
|
||||
canonical.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
|
||||
canonical.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
|
||||
canonical.boxes.paddingLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 2))
|
||||
canonical.boxes.paddingTop = new Float64Array(Array.from({length: count}, () => 2))
|
||||
canonical.boxes.paddingRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 48))
|
||||
canonical.boxes.paddingBottom = new Float64Array(Array.from({length: count}, () => 48))
|
||||
canonical.boxes.contentLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 5))
|
||||
canonical.boxes.contentTop = new Float64Array(Array.from({length: count}, () => 5))
|
||||
canonical.boxes.contentRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 45))
|
||||
canonical.boxes.contentBottom = new Float64Array(Array.from({length: count}, () => 45))
|
||||
|
||||
canonical.dom.nodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
|
||||
canonical.dom.parentNodeId = new Uint32Array(Array.from({length: count}, () => 1))
|
||||
canonical.dom.childCount = new Uint8Array(Array.from({length: count}, () => 0))
|
||||
canonical.dom.tagNameStringId = new Uint16Array(Array.from({length: count}, () => 0))
|
||||
|
||||
canonical.topology.containingBlockOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.nearestPositionedAncestorOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.scrollContainerOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.stackingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.formattingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.clippingRootOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.paintOrderBucket = new Uint8Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.paintOrderIndex = new Uint32Array(Array.from({length: count}, (_, i) => i))
|
||||
|
||||
const times = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now()
|
||||
adaptCanonicalWorldToSolver(canonical)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} elements: ${mean.toFixed(2)}ms (avg of 100 runs)`)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// bench.test.ts - Tests for imhotep-bench harness
|
||||
// Validates benchmark execution and cache correctness.
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
MemoryCache,
|
||||
CompilationCache,
|
||||
ExtractionCache,
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
profileRun,
|
||||
DEFAULT_PROFILES,
|
||||
checkBudget,
|
||||
} from './index.js'
|
||||
|
||||
describe('Cache correctness', () => {
|
||||
it('MemoryCache stores and retrieves values', () => {
|
||||
const cache = new MemoryCache<string>()
|
||||
const key = { hash: 'abc', version: '1' }
|
||||
cache.set(key, 'hello')
|
||||
assert.strictEqual(cache.get(key), 'hello')
|
||||
assert.strictEqual(cache.size(), 1)
|
||||
})
|
||||
|
||||
it('MemoryCache returns undefined for missing keys', () => {
|
||||
const cache = new MemoryCache<number>()
|
||||
assert.strictEqual(cache.get({ hash: 'missing', version: '1' }), undefined)
|
||||
})
|
||||
|
||||
it('MemoryCache invalidates entries', () => {
|
||||
const cache = new MemoryCache<number>()
|
||||
const key = { hash: 'del', version: '1' }
|
||||
cache.set(key, 42)
|
||||
assert.strictEqual(cache.invalidate(key), true)
|
||||
assert.strictEqual(cache.get(key), undefined)
|
||||
assert.strictEqual(cache.invalidate(key), false)
|
||||
})
|
||||
|
||||
it('MemoryCache respects TTL expiration', async () => {
|
||||
const cache = new MemoryCache<string>()
|
||||
const key = { hash: 'ttl', version: '1' }
|
||||
cache.set(key, 'temp', 10)
|
||||
assert.strictEqual(cache.get(key), 'temp')
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
assert.strictEqual(cache.get(key), undefined)
|
||||
assert.strictEqual(cache.size(), 0)
|
||||
})
|
||||
|
||||
it('MemoryCache clear removes all entries', () => {
|
||||
const cache = new MemoryCache<number>()
|
||||
cache.set({ hash: 'a', version: '1' }, 1)
|
||||
cache.set({ hash: 'b', version: '1' }, 2)
|
||||
cache.clear()
|
||||
assert.strictEqual(cache.size(), 0)
|
||||
})
|
||||
|
||||
it('CompilationCache caches compile results by source', () => {
|
||||
const inner = new MemoryCache<{ code: string }>()
|
||||
const cache = new CompilationCache(inner, 'v1')
|
||||
const result = { code: 'compiled' }
|
||||
cache.set('source-a', result)
|
||||
assert.deepStrictEqual(cache.get('source-a'), result)
|
||||
assert.strictEqual(cache.get('source-b'), undefined)
|
||||
})
|
||||
|
||||
it('CompilationCache invalidation targets specific source', () => {
|
||||
const inner = new MemoryCache<number>()
|
||||
const cache = new CompilationCache(inner, 'v1')
|
||||
cache.set('src1', 1)
|
||||
cache.set('src2', 2)
|
||||
cache.invalidate('src1')
|
||||
assert.strictEqual(cache.get('src1'), undefined)
|
||||
assert.strictEqual(cache.get('src2'), 2)
|
||||
})
|
||||
|
||||
it('ExtractionCache caches by selector and facts', () => {
|
||||
const inner = new MemoryCache<{ rects: number[] }>()
|
||||
const cache = new ExtractionCache(inner, 'v1')
|
||||
const result = { rects: [0, 0, 10, 10] }
|
||||
cache.set('#app', ['box', 'style'], result)
|
||||
assert.deepStrictEqual(cache.get('#app', ['box', 'style']), result)
|
||||
assert.strictEqual(cache.get('#app', ['box']), undefined)
|
||||
})
|
||||
|
||||
it('ExtractionCache invalidation targets specific selector+facts', () => {
|
||||
const inner = new MemoryCache<number>()
|
||||
const cache = new ExtractionCache(inner, 'v1')
|
||||
cache.set('sel1', ['a'], 1)
|
||||
cache.set('sel1', ['b'], 2)
|
||||
cache.set('sel2', ['a'], 3)
|
||||
cache.invalidate('sel1', ['a'])
|
||||
assert.strictEqual(cache.get('sel1', ['a']), undefined)
|
||||
assert.strictEqual(cache.get('sel1', ['b']), 2)
|
||||
assert.strictEqual(cache.get('sel2', ['a']), 3)
|
||||
})
|
||||
|
||||
it('Cache version isolates entries', () => {
|
||||
const cache = new MemoryCache<string>()
|
||||
cache.set({ hash: 'x', version: 'v1' }, 'old')
|
||||
cache.set({ hash: 'x', version: 'v2' }, 'new')
|
||||
assert.strictEqual(cache.get({ hash: 'x', version: 'v1' }), 'old')
|
||||
assert.strictEqual(cache.get({ hash: 'x', version: 'v2' }), 'new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Benchmark execution', () => {
|
||||
it('profileRun measures duration and memory', async () => {
|
||||
const { durationMs, memoryDeltaBytes, result } = await profileRun(() => {
|
||||
const arr = new Array(1000).fill(0)
|
||||
return arr.length
|
||||
})
|
||||
assert.strictEqual(typeof durationMs, 'number')
|
||||
assert.strictEqual(durationMs >= 0, true)
|
||||
assert.strictEqual(typeof memoryDeltaBytes, 'number')
|
||||
assert.strictEqual(result, 1000)
|
||||
})
|
||||
|
||||
it('runBenchmark returns aggregated stats', async () => {
|
||||
let counter = 0
|
||||
const result = await runBenchmark('inc', () => {
|
||||
counter++
|
||||
return counter
|
||||
})
|
||||
assert.strictEqual(result.name, 'inc')
|
||||
assert.strictEqual(typeof result.meanDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.minDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.maxDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.stdDevDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.meanMemoryDeltaBytes, 'number')
|
||||
assert.strictEqual(result.runs.length, 5)
|
||||
assert.strictEqual(result.minDurationMs <= result.meanDurationMs, true)
|
||||
assert.strictEqual(result.meanDurationMs <= result.maxDurationMs, true)
|
||||
})
|
||||
|
||||
it('runBenchmark applies budget check', async () => {
|
||||
const result = await runBenchmark('slow', () => {}, {
|
||||
budgetName: 'compile',
|
||||
profile: DEFAULT_PROFILES.benchmark,
|
||||
})
|
||||
assert.ok(result.budgetCheck)
|
||||
assert.strictEqual(result.budgetCheck!.budgetName, 'compile')
|
||||
assert.strictEqual(typeof result.budgetCheck!.passed, 'boolean')
|
||||
})
|
||||
|
||||
it('runBenchmark uses custom run counts', async () => {
|
||||
let calls = 0
|
||||
await runBenchmark(
|
||||
'count',
|
||||
() => {
|
||||
calls++
|
||||
},
|
||||
{ warmupRuns: 2, measurementRuns: 3 }
|
||||
)
|
||||
assert.strictEqual(calls, 5)
|
||||
})
|
||||
|
||||
it('BenchmarkSuite runs sequentially', async () => {
|
||||
const suite = new BenchmarkSuite('seq')
|
||||
const order: number[] = []
|
||||
suite.add('a', () => order.push(1), { warmupRuns: 0, measurementRuns: 1 })
|
||||
suite.add('b', () => order.push(2), { warmupRuns: 0, measurementRuns: 1 })
|
||||
const result = await suite.runSequential()
|
||||
assert.strictEqual(result.suiteName, 'seq')
|
||||
assert.strictEqual(result.results.length, 2)
|
||||
assert.deepStrictEqual(order, [1, 2])
|
||||
assert.strictEqual(typeof result.allBudgetsPassed, 'boolean')
|
||||
assert.strictEqual(typeof result.totalDurationMs, 'number')
|
||||
})
|
||||
|
||||
it('BenchmarkSuite runs in parallel', async () => {
|
||||
const suite = new BenchmarkSuite('par', {
|
||||
poolOptions: { maxConcurrency: 2, taskTimeoutMs: 5000 },
|
||||
})
|
||||
suite.add('x', async () => {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}, { warmupRuns: 0, measurementRuns: 1 })
|
||||
suite.add('y', async () => {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}, { warmupRuns: 0, measurementRuns: 1 })
|
||||
const result = await suite.runParallel()
|
||||
assert.strictEqual(result.results.length, 2)
|
||||
assert.strictEqual(typeof result.totalDurationMs, 'number')
|
||||
// Parallel total should be less than sequential sum of sleeps (20ms) plus generous overhead
|
||||
assert.ok(result.totalDurationMs < 150)
|
||||
})
|
||||
|
||||
it('Budget check passes when under budget', () => {
|
||||
const check = checkBudget('compile', 10, undefined, DEFAULT_PROFILES.dev)
|
||||
assert.strictEqual(check.passed, true)
|
||||
assert.strictEqual(check.maxDurationMs, 50)
|
||||
})
|
||||
|
||||
it('Budget check fails when over budget', () => {
|
||||
const check = checkBudget('compile', 1000, undefined, DEFAULT_PROFILES.dev)
|
||||
assert.strictEqual(check.passed, false)
|
||||
})
|
||||
|
||||
it('Missing budget returns passed=true with Infinity', () => {
|
||||
const check = checkBudget('unknown', 99999, undefined, DEFAULT_PROFILES.dev)
|
||||
assert.strictEqual(check.passed, true)
|
||||
assert.strictEqual(check.maxDurationMs, Infinity)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
// benchmark.ts - Benchmark runner for Imhotep bench harness
|
||||
// Measures execution time, memory, and CPU for extraction and solving paths.
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
type PerformanceProfile,
|
||||
type BudgetCheck,
|
||||
DEFAULT_PROFILES,
|
||||
checkBudget,
|
||||
} from './profiles.js'
|
||||
import { type CompilationCache, type ExtractionCache } from './cache.js'
|
||||
import { PromisePool, type PromisePoolOptions } from './parallel.js'
|
||||
|
||||
export interface BenchmarkOptions {
|
||||
/** Number of warm-up runs before measurement */
|
||||
warmupRuns?: number
|
||||
/** Number of measured runs */
|
||||
measurementRuns?: number
|
||||
/** Performance profile to check budgets against */
|
||||
profile?: PerformanceProfile
|
||||
/** Budget category name (compile | extract | solve | e2e) */
|
||||
budgetName?: string
|
||||
/** Optional compilation cache injected by caller */
|
||||
compilationCache?: CompilationCache<unknown>
|
||||
/** Optional extraction cache injected by caller */
|
||||
extractionCache?: ExtractionCache<unknown>
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
/** Benchmark name */
|
||||
name: string
|
||||
/** Mean duration across measured runs in milliseconds */
|
||||
meanDurationMs: number
|
||||
/** Minimum duration in milliseconds */
|
||||
minDurationMs: number
|
||||
/** Maximum duration in milliseconds */
|
||||
maxDurationMs: number
|
||||
/** Standard deviation of duration */
|
||||
stdDevDurationMs: number
|
||||
/** Mean memory delta in bytes */
|
||||
meanMemoryDeltaBytes: number
|
||||
/** Budget check result, if a budget name was provided */
|
||||
budgetCheck?: BudgetCheck
|
||||
/** Per-run raw measurements */
|
||||
runs: RunMeasurement[]
|
||||
}
|
||||
|
||||
export interface RunMeasurement {
|
||||
durationMs: number
|
||||
memoryDeltaBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile a single function execution.
|
||||
* Returns duration and memory delta.
|
||||
*/
|
||||
export async function profileRun<T>(
|
||||
fn: () => T | Promise<T>
|
||||
): Promise<{ durationMs: number; memoryDeltaBytes: number; result: T }> {
|
||||
const memBefore = process.memoryUsage().heapUsed
|
||||
const start = performance.now()
|
||||
const result = await fn()
|
||||
const end = performance.now()
|
||||
const memAfter = process.memoryUsage().heapUsed
|
||||
|
||||
return {
|
||||
durationMs: end - start,
|
||||
memoryDeltaBytes: memAfter - memBefore,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a benchmark with warm-up and measurement phases.
|
||||
*/
|
||||
export async function runBenchmark<T>(
|
||||
name: string,
|
||||
fn: () => T | Promise<T>,
|
||||
options: BenchmarkOptions = {}
|
||||
): Promise<BenchmarkResult> {
|
||||
const warmupRuns = options.warmupRuns ?? 1
|
||||
const measurementRuns = options.measurementRuns ?? 5
|
||||
const profile = options.profile ?? DEFAULT_PROFILES.dev
|
||||
const budgetName = options.budgetName ?? 'e2e'
|
||||
|
||||
// Warm-up phase: discard results
|
||||
for (let i = 0; i < warmupRuns; i++) {
|
||||
await fn()
|
||||
}
|
||||
|
||||
// Measurement phase
|
||||
const runs: RunMeasurement[] = []
|
||||
for (let i = 0; i < measurementRuns; i++) {
|
||||
const { durationMs, memoryDeltaBytes } = await profileRun(fn)
|
||||
runs.push({ durationMs, memoryDeltaBytes })
|
||||
}
|
||||
|
||||
const durations = runs.map(r => r.durationMs)
|
||||
const meanDurationMs =
|
||||
durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
const minDurationMs = Math.min(...durations)
|
||||
const maxDurationMs = Math.max(...durations)
|
||||
const variance =
|
||||
durations.reduce((sum, d) => sum + Math.pow(d - meanDurationMs, 2), 0) /
|
||||
durations.length
|
||||
const stdDevDurationMs = Math.sqrt(variance)
|
||||
const meanMemoryDeltaBytes =
|
||||
runs.reduce((sum, r) => sum + r.memoryDeltaBytes, 0) / runs.length
|
||||
|
||||
const budgetCheck = checkBudget(
|
||||
budgetName,
|
||||
meanDurationMs,
|
||||
meanMemoryDeltaBytes,
|
||||
profile
|
||||
)
|
||||
|
||||
return {
|
||||
name,
|
||||
meanDurationMs,
|
||||
minDurationMs,
|
||||
maxDurationMs,
|
||||
stdDevDurationMs,
|
||||
meanMemoryDeltaBytes,
|
||||
budgetCheck,
|
||||
runs,
|
||||
}
|
||||
}
|
||||
|
||||
export interface BenchmarkSuiteOptions {
|
||||
/** Global benchmark options applied to every benchmark */
|
||||
defaults?: BenchmarkOptions
|
||||
/** Promise pool options for parallel execution */
|
||||
poolOptions?: PromisePoolOptions
|
||||
}
|
||||
|
||||
export interface BenchmarkSuiteResult {
|
||||
suiteName: string
|
||||
results: BenchmarkResult[]
|
||||
totalDurationMs: number
|
||||
allBudgetsPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* BenchmarkSuite collects and runs multiple named benchmarks.
|
||||
* Supports sequential and parallel execution.
|
||||
*/
|
||||
export class BenchmarkSuite {
|
||||
private benchmarks: Array<{
|
||||
name: string
|
||||
fn: () => unknown | Promise<unknown>
|
||||
options?: BenchmarkOptions
|
||||
}> = []
|
||||
|
||||
constructor(private suiteName: string, private options: BenchmarkSuiteOptions = {}) {}
|
||||
|
||||
/** Register a benchmark function */
|
||||
add<T>(
|
||||
name: string,
|
||||
fn: () => T | Promise<T>,
|
||||
options?: BenchmarkOptions
|
||||
): this {
|
||||
this.benchmarks.push({ name, fn, options })
|
||||
return this
|
||||
}
|
||||
|
||||
/** Run all benchmarks sequentially */
|
||||
async runSequential(): Promise<BenchmarkSuiteResult> {
|
||||
const results: BenchmarkResult[] = []
|
||||
const suiteStart = performance.now()
|
||||
|
||||
for (const { name, fn, options } of this.benchmarks) {
|
||||
const merged: BenchmarkOptions = {
|
||||
...this.options.defaults,
|
||||
...options,
|
||||
}
|
||||
const result = await runBenchmark(name, fn, merged)
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
const suiteEnd = performance.now()
|
||||
|
||||
return {
|
||||
suiteName: this.suiteName,
|
||||
results,
|
||||
totalDurationMs: suiteEnd - suiteStart,
|
||||
allBudgetsPassed: results.every(
|
||||
r => !r.budgetCheck || r.budgetCheck.passed
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Run all benchmarks in parallel with controlled concurrency */
|
||||
async runParallel(): Promise<BenchmarkSuiteResult> {
|
||||
const poolOptions: PromisePoolOptions = {
|
||||
maxConcurrency: this.options.poolOptions?.maxConcurrency ?? 4,
|
||||
taskTimeoutMs: this.options.poolOptions?.taskTimeoutMs ?? 30000,
|
||||
}
|
||||
const pool = new PromisePool(poolOptions)
|
||||
const suiteStart = performance.now()
|
||||
|
||||
try {
|
||||
const tasks = this.benchmarks.map(({ name, fn, options }) => ({
|
||||
id: name,
|
||||
input: undefined,
|
||||
fn: async () => {
|
||||
const merged: BenchmarkOptions = {
|
||||
...this.options.defaults,
|
||||
...options,
|
||||
}
|
||||
return runBenchmark(name, fn, merged)
|
||||
},
|
||||
}))
|
||||
|
||||
const results = (await pool.executeAll(tasks)) as BenchmarkResult[]
|
||||
const suiteEnd = performance.now()
|
||||
|
||||
return {
|
||||
suiteName: this.suiteName,
|
||||
results,
|
||||
totalDurationMs: suiteEnd - suiteStart,
|
||||
allBudgetsPassed: results.every(
|
||||
r => !r.budgetCheck || r.budgetCheck.passed
|
||||
),
|
||||
}
|
||||
} finally {
|
||||
await pool.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// benchmarks.test.ts - Validation tests for benchmark suites.
|
||||
// Runs each benchmark with reduced parameters to verify correctness.
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { runFolBenchmarks } from './fol-benchmark.js'
|
||||
import { runPropertyBenchmarks } from './property-benchmark.js'
|
||||
import { runRenderTargetBenchmarks } from './render-target-benchmark.js'
|
||||
import { runPublicApiBenchmarks } from './public-api-benchmark.js'
|
||||
|
||||
describe('FOL benchmarks', () => {
|
||||
it('runs forall benchmarks without error', async () => {
|
||||
const results = await runFolBenchmarks();
|
||||
assert.ok(results.length >= 4);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
assert.ok(r.runs.length > 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Property benchmarks', () => {
|
||||
it('runs enumerated and sampled benchmarks', async () => {
|
||||
const results = await runPropertyBenchmarks();
|
||||
assert.ok(results.length >= 2);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render target benchmarks', () => {
|
||||
it('runs renderer mount benchmarks', async () => {
|
||||
const results = await runRenderTargetBenchmarks();
|
||||
assert.ok(results.length >= 4);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public API benchmarks', () => {
|
||||
it('runs leftOf relation benchmark under 100ms', async () => {
|
||||
const results = await runPublicApiBenchmarks();
|
||||
assert.ok(results.length >= 1);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
assert.ok(r.meanDurationMs < 100, `Expected public API benchmark to complete in under 100ms, but took ${r.meanDurationMs}ms`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cache-hit-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
|
||||
// Warmup: run once to populate cache
|
||||
const ui1 = await imhotep(page)
|
||||
for (const sel of selectors) {
|
||||
ui1.expect(sel).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
await ui1.checkAll()
|
||||
|
||||
console.log('=== Cache Hit Performance ===\n')
|
||||
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const ui = await imhotep(page)
|
||||
for (let i = 0; i < count; i++) {
|
||||
ui.expect(selectors[i]).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} selectors (cache hit): ${mean.toFixed(2)}ms (avg of 10 runs)`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,171 @@
|
||||
// cache.ts - Compilation and extraction cache implementations for Imhotep bench harness
|
||||
// Provides in-memory caching with TTL, versioning, and explicit invalidation.
|
||||
|
||||
export interface CacheKey {
|
||||
/** Content hash or deterministic identifier */
|
||||
hash: string
|
||||
/** Cache schema version for invalidation on format changes */
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
/** Stored value */
|
||||
value: T
|
||||
/** Unix timestamp when the entry was created */
|
||||
createdAt: number
|
||||
/** Optional Unix timestamp when the entry expires */
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export interface Cache<T> {
|
||||
get(key: CacheKey): T | undefined
|
||||
set(key: CacheKey, value: T, ttlMs?: number): void
|
||||
invalidate(key: CacheKey): boolean
|
||||
clear(): void
|
||||
size(): number
|
||||
}
|
||||
|
||||
/** Simple in-memory cache backed by a Map. */
|
||||
export class MemoryCache<T> implements Cache<T> {
|
||||
private store = new Map<string, CacheEntry<T>>()
|
||||
|
||||
private makeKey(key: CacheKey): string {
|
||||
return `${key.version}:${key.hash}`
|
||||
}
|
||||
|
||||
get(key: CacheKey): T | undefined {
|
||||
const k = this.makeKey(key)
|
||||
const entry = this.store.get(k)
|
||||
if (!entry) return undefined
|
||||
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
|
||||
this.store.delete(k)
|
||||
return undefined
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
|
||||
set(key: CacheKey, value: T, ttlMs?: number): void {
|
||||
const k = this.makeKey(key)
|
||||
const now = Date.now()
|
||||
this.store.set(k, {
|
||||
value,
|
||||
createdAt: now,
|
||||
expiresAt: ttlMs !== undefined ? now + ttlMs : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
invalidate(key: CacheKey): boolean {
|
||||
return this.store.delete(this.makeKey(key))
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear()
|
||||
}
|
||||
|
||||
size(): number {
|
||||
// Prune expired entries before reporting size for accuracy
|
||||
const now = Date.now()
|
||||
for (const [k, entry] of this.store) {
|
||||
if (entry.expiresAt !== undefined && now > entry.expiresAt) {
|
||||
this.store.delete(k)
|
||||
}
|
||||
}
|
||||
return this.store.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache key factory for compilation inputs. */
|
||||
export function makeCompilationCacheKey(
|
||||
source: string,
|
||||
version: string
|
||||
): CacheKey {
|
||||
// Simple hash: in production this should be a real hash function
|
||||
let hash = 0
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash |= 0
|
||||
}
|
||||
return { hash: String(hash), version }
|
||||
}
|
||||
|
||||
/** Cache key factory for extraction plans. */
|
||||
export function makeExtractionCacheKey(
|
||||
selector: string,
|
||||
facts: string[],
|
||||
version: string
|
||||
): CacheKey {
|
||||
const combined = `${selector}::${facts.sort().join(',')}`
|
||||
let hash = 0
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
const char = combined.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash |= 0
|
||||
}
|
||||
return { hash: String(hash), version }
|
||||
}
|
||||
|
||||
/**
|
||||
* CompilationCache wraps a generic Cache for CompileResult-like objects.
|
||||
* Accepts the underlying Cache via constructor injection.
|
||||
*/
|
||||
export class CompilationCache<TCompileResult> {
|
||||
constructor(
|
||||
private cache: Cache<TCompileResult>,
|
||||
private version: string
|
||||
) {}
|
||||
|
||||
get(source: string): TCompileResult | undefined {
|
||||
return this.cache.get(makeCompilationCacheKey(source, this.version))
|
||||
}
|
||||
|
||||
set(source: string, result: TCompileResult, ttlMs?: number): void {
|
||||
this.cache.set(makeCompilationCacheKey(source, this.version), result, ttlMs)
|
||||
}
|
||||
|
||||
invalidate(source: string): boolean {
|
||||
return this.cache.invalidate(makeCompilationCacheKey(source, this.version))
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractionCache wraps a generic Cache for extraction payloads.
|
||||
* Accepts the underlying Cache via constructor injection.
|
||||
*/
|
||||
export class ExtractionCache<TExtractResult> {
|
||||
constructor(
|
||||
private cache: Cache<TExtractResult>,
|
||||
private version: string
|
||||
) {}
|
||||
|
||||
get(selector: string, facts: string[]): TExtractResult | undefined {
|
||||
return this.cache.get(makeExtractionCacheKey(selector, facts, this.version))
|
||||
}
|
||||
|
||||
set(
|
||||
selector: string,
|
||||
facts: string[],
|
||||
result: TExtractResult,
|
||||
ttlMs?: number
|
||||
): void {
|
||||
this.cache.set(
|
||||
makeExtractionCacheKey(selector, facts, this.version),
|
||||
result,
|
||||
ttlMs
|
||||
)
|
||||
}
|
||||
|
||||
invalidate(selector: string, facts: string[]): boolean {
|
||||
return this.cache.invalidate(
|
||||
makeExtractionCacheKey(selector, facts, this.version)
|
||||
)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Contention analysis benchmark.
|
||||
*
|
||||
* Tests parallel execution at different granularities to identify
|
||||
* where contention occurs: browser, context, or page level.
|
||||
*/
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-contention-'))
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="app">
|
||||
<button id="btn1">Button 1</button>
|
||||
<button id="btn2">Button 2</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'fixture.html'), html)
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((a, b) => a + b, 0) / values.length
|
||||
}
|
||||
|
||||
async function benchmark() {
|
||||
console.log('========================================')
|
||||
console.log(' Contention Analysis Benchmark')
|
||||
console.log('========================================\n')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const fixtureUrl = `file:${join(tmpdir2, 'fixture.html')}`
|
||||
|
||||
// ============================================================
|
||||
// Test 1: Baseline sequential page operations
|
||||
// ============================================================
|
||||
console.log('--- Test 1: Sequential Baseline ---')
|
||||
const seqTimes = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => document.getElementById('result').textContent = 'done')
|
||||
await page.close()
|
||||
await ctx.close()
|
||||
}
|
||||
seqTimes.push(performance.now() - start)
|
||||
}
|
||||
const seqMean = mean(seqTimes)
|
||||
console.log(` 20 ops (new context each): ${seqMean.toFixed(1)}ms\n`)
|
||||
|
||||
// ============================================================
|
||||
// Test 2: Parallel with varying concurrency
|
||||
// ============================================================
|
||||
console.log('--- Test 2: Parallel Execution Sweep ---')
|
||||
console.log(' Using isolated contexts (no sharing)')
|
||||
|
||||
for (const concurrency of [1, 2, 4, 6, 8, 12, 16]) {
|
||||
const sem = new Semaphore(concurrency)
|
||||
const times = []
|
||||
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const start = performance.now()
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
sem.run(async () => {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => document.getElementById('result').textContent = 'done')
|
||||
await page.close()
|
||||
await ctx.close()
|
||||
})
|
||||
)
|
||||
)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const t = mean(times)
|
||||
const speedup = seqMean / t
|
||||
const efficiency = (speedup / concurrency) * 100
|
||||
console.log(` concurrency=${concurrency.toString().padStart(2)}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1).padStart(4)}x efficiency: ${efficiency.toFixed(0).padStart(3)}%`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Test 3: Page-level parallelism within single context
|
||||
// ============================================================
|
||||
console.log('--- Test 3: Pages in Single Context ---')
|
||||
|
||||
for (const concurrency of [1, 2, 4, 6, 8]) {
|
||||
const sem = new Semaphore(concurrency)
|
||||
const times = []
|
||||
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const ctx = await browser.newContext()
|
||||
const start = performance.now()
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
sem.run(async () => {
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => document.getElementById('result').textContent = 'done')
|
||||
await page.close()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
times.push(performance.now() - start)
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
const t = mean(times)
|
||||
const speedup = seqMean / t
|
||||
console.log(` concurrency=${concurrency}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1)}x`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Test 4: I/O vs CPU operation types
|
||||
// ============================================================
|
||||
console.log('--- Test 4: Operation Type Comparison ---')
|
||||
|
||||
async function ioOp() {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
async function mixedOp() {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = 'test'
|
||||
document.body.appendChild(div)
|
||||
return div.getBoundingClientRect()
|
||||
})
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
async function cpuOp() {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => {
|
||||
let a = 0, b = 1
|
||||
for (let i = 0; i < 1000000; i++) {
|
||||
const temp = a + b; a = b; b = temp
|
||||
}
|
||||
return b
|
||||
})
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
for (const [name, op] of [['I/O', ioOp], ['Mixed', mixedOp], ['CPU', cpuOp]]) {
|
||||
const sem = new Semaphore(4)
|
||||
const seqT = []
|
||||
const parT = []
|
||||
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const s0 = performance.now()
|
||||
for (let i = 0; i < 4; i++) await op()
|
||||
seqT.push(performance.now() - s0)
|
||||
|
||||
const p0 = performance.now()
|
||||
await Promise.all(Array.from({ length: 4 }, () => sem.run(op)))
|
||||
parT.push(performance.now() - p0)
|
||||
}
|
||||
|
||||
const speedup = mean(seqT) / mean(parT)
|
||||
console.log(` ${name.padEnd(5)}: seq=${mean(seqT).toFixed(1).padStart(6)}ms par=${mean(parT).toFixed(1).padStart(6)}ms speedup: ${speedup.toFixed(1)}x`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Test 5: Resource degradation with many pages
|
||||
// ============================================================
|
||||
console.log('--- Test 5: Resource Saturation ---')
|
||||
|
||||
const ctx = await browser.newContext()
|
||||
const pageCreationTimes = []
|
||||
const pages = []
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const t0 = performance.now()
|
||||
const p = await ctx.newPage()
|
||||
pageCreationTimes.push(performance.now() - t0)
|
||||
pages.push(p)
|
||||
}
|
||||
|
||||
console.log(` Page creation (1st): ${pageCreationTimes[0].toFixed(1)}ms`)
|
||||
console.log(` Page creation (10th): ${pageCreationTimes[9].toFixed(1)}ms`)
|
||||
console.log(` Page creation (20th): ${pageCreationTimes[19].toFixed(1)}ms`)
|
||||
|
||||
// Now navigate all simultaneously
|
||||
const navStart = performance.now()
|
||||
await Promise.all(pages.map(p => p.goto(fixtureUrl)))
|
||||
const navTime = performance.now() - navStart
|
||||
console.log(` Simultaneous navigation (20 pages): ${navTime.toFixed(1)}ms`)
|
||||
|
||||
await Promise.all(pages.map(p => p.close()))
|
||||
await ctx.close()
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Cleanup
|
||||
// ============================================================
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
|
||||
console.log('========================================')
|
||||
console.log(' Contention Analysis Complete')
|
||||
console.log('========================================')
|
||||
}
|
||||
|
||||
benchmark().catch(err => {
|
||||
console.error('Benchmark failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-e2e-profile-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 100}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
// Profile each phase separately
|
||||
console.log('=== End-to-End Pipeline Profile ===\n')
|
||||
|
||||
// Phase 1: Page creation + imhotep setup
|
||||
const setupTimes = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = performance.now()
|
||||
const ui = await imhotep(page)
|
||||
setupTimes.push(performance.now() - start)
|
||||
}
|
||||
console.log(`imhotep() setup: ${(setupTimes.reduce((a,b) => a+b, 0) / setupTimes.length).toFixed(1)}ms`)
|
||||
|
||||
// Phase 2: Assertion accumulation
|
||||
const ui = await imhotep(page)
|
||||
const accStart = performance.now()
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
|
||||
}
|
||||
console.log(`Assertion accumulation (50): ${(performance.now() - accStart).toFixed(2)}ms`)
|
||||
|
||||
// Phase 3: First extraction (cold, no cache)
|
||||
const coldStart = performance.now()
|
||||
await ui.checkAll()
|
||||
const coldDur = performance.now() - coldStart
|
||||
console.log(`First extraction (cold): ${coldDur.toFixed(1)}ms`)
|
||||
|
||||
// Phase 4: Second extraction (warm, with cache)
|
||||
const ui2 = await imhotep(page, { cacheDir: join(tmpdir2, 'cache') })
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui2.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
|
||||
}
|
||||
const warmStart = performance.now()
|
||||
await ui2.checkAll()
|
||||
const warmDur = performance.now() - warmStart
|
||||
console.log(`First check with cache dir: ${warmDur.toFixed(1)}ms`)
|
||||
|
||||
// Phase 5: Cached run
|
||||
const warmStart2 = performance.now()
|
||||
await ui2.checkAll()
|
||||
const warmDur2 = performance.now() - warmStart2
|
||||
console.log(`Second check (cache hit): ${warmDur2.toFixed(1)}ms`)
|
||||
|
||||
// Phase 6: validate() only
|
||||
const ui3 = await imhotep(page)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui3.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
|
||||
}
|
||||
const valStart = performance.now()
|
||||
ui3.validate()
|
||||
console.log(`validate() only: ${(performance.now() - valStart).toFixed(2)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,167 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
} from 'imhotep-solver'
|
||||
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
registerDefaultPredicates()
|
||||
|
||||
// Build a world with 100 elements arranged in a grid
|
||||
function buildWorld(n) {
|
||||
const canonical = {
|
||||
sceneId: 's',
|
||||
snapshotId: 'sn',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
|
||||
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
|
||||
},
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
},
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
}
|
||||
|
||||
return adaptCanonicalWorldToSolver(canonical)
|
||||
}
|
||||
|
||||
class SimpleResolver {
|
||||
constructor() {
|
||||
this.domains = new Map()
|
||||
}
|
||||
register(selector, ids) {
|
||||
this.domains.set(selector, {
|
||||
domainId: `dom_${selector}`,
|
||||
subjectIds: new Uint32Array(ids),
|
||||
provenance: `elements(${selector})`,
|
||||
closed: true,
|
||||
})
|
||||
}
|
||||
resolve(domain) {
|
||||
return this.domains.get(domain.selector ?? domain.domain)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Evaluation Allocation Benchmark ===\n')
|
||||
|
||||
for (const n of [10, 50, 100]) {
|
||||
const world = buildWorld(n)
|
||||
const resolver = new SimpleResolver()
|
||||
resolver.register('.a', Array.from({length: n}, (_, i) => i))
|
||||
resolver.register('.b', Array.from({length: n}, (_, i) => i))
|
||||
|
||||
const formula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$subject'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$reference'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: '$subject' },
|
||||
{ type: 'VariableRef', name: '$reference' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timesTrace = []
|
||||
const timesNoTrace = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start1 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: true } })
|
||||
timesTrace.push(performance.now() - start1)
|
||||
|
||||
const start2 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: false } })
|
||||
timesNoTrace.push(performance.now() - start2)
|
||||
}
|
||||
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
|
||||
const withTrace = mean(timesTrace)
|
||||
const withoutTrace = mean(timesNoTrace)
|
||||
console.log(`${n}×${n} pairs (${n*n} evaluations):`)
|
||||
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
|
||||
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
|
||||
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
} from 'imhotep-solver'
|
||||
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
registerDefaultPredicates()
|
||||
|
||||
function buildWorld(n) {
|
||||
const canonical = {
|
||||
sceneId: 's',
|
||||
snapshotId: 'sn',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
|
||||
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
|
||||
},
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
},
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
}
|
||||
|
||||
return adaptCanonicalWorldToSolver(canonical)
|
||||
}
|
||||
|
||||
class SimpleResolver {
|
||||
constructor() {
|
||||
this.domains = new Map()
|
||||
}
|
||||
register(selector, ids) {
|
||||
this.domains.set(selector, {
|
||||
domainId: `dom_${selector}`,
|
||||
subjectIds: new Uint32Array(ids),
|
||||
provenance: `elements(${selector})`,
|
||||
closed: true,
|
||||
})
|
||||
}
|
||||
resolve(domain) {
|
||||
return this.domains.get(domain.selector ?? domain.domain)
|
||||
}
|
||||
}
|
||||
|
||||
// Run many iterations for V8 profiling
|
||||
const n = 100
|
||||
const world = buildWorld(n)
|
||||
const resolver = new SimpleResolver()
|
||||
resolver.register('.a', Array.from({length: n}, (_, i) => i))
|
||||
resolver.register('.b', Array.from({length: n}, (_, i) => i))
|
||||
|
||||
const formula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$subject'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$reference'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: '$subject' },
|
||||
{ type: 'VariableRef', name: '$reference' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: false } })
|
||||
}
|
||||
|
||||
console.log('Evaluation profiling complete')
|
||||
@@ -0,0 +1,79 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-breakdown-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Extraction Pipeline Breakdown ===\n')
|
||||
|
||||
// Profile CDP session creation
|
||||
const sessionStart = performance.now()
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send('DOM.enable')
|
||||
console.log(`CDP session + DOM.enable: ${(performance.now() - sessionStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile selector resolution (50 selectors)
|
||||
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
const doc = await session.send('DOM.getDocument')
|
||||
const rootNodeId = doc.root.nodeId
|
||||
const resolveStart = performance.now()
|
||||
for (const sel of selectors) {
|
||||
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
|
||||
}
|
||||
console.log(`Resolve 50 selectors (DOM.querySelectorAll): ${(performance.now() - resolveStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile box model extraction (10 elements)
|
||||
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: '[data-testid="item-0"]' })
|
||||
const boxModelStart = performance.now()
|
||||
for (const nodeId of nodeIds.slice(0, 10)) {
|
||||
try {
|
||||
await session.send('DOM.getBoxModel', { nodeId })
|
||||
} catch {}
|
||||
}
|
||||
console.log(`Get box model for 10 elements: ${(performance.now() - boxModelStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile full CDP extraction via Imhotep
|
||||
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
|
||||
const fullStart = performance.now()
|
||||
const sm = createSessionManager(page)
|
||||
const extractor = new CDPExtractor(sm)
|
||||
const result = await extractor.extract({
|
||||
requestId: 'bench',
|
||||
sceneTarget: { kind: 'page', pageRef: page, url: page.url() },
|
||||
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
|
||||
subjects: selectors.slice(0, 10).map((s, i) => ({ id: `subj_${i}`, selector: s })),
|
||||
requiredFacts: { geometry: true, topology: true, styles: false, fragments: false }
|
||||
})
|
||||
const fullDur = performance.now() - fullStart
|
||||
console.log(`\nFull CDPExtractor (10 subjects): ${fullDur.toFixed(1)}ms`)
|
||||
console.log(`Status: ${result.status}, Snapshots: ${result.snapshots?.length ?? 0}`)
|
||||
|
||||
// Profile world adaptation
|
||||
if (result.snapshots?.length > 0) {
|
||||
const { adaptSnapshotToCanonical } = await import('imhotep-cdp')
|
||||
const { adaptCanonicalWorldToSolver } = await import('imhotep-core')
|
||||
|
||||
const adaptStart = performance.now()
|
||||
const canonical = adaptSnapshotToCanonical(result.snapshots[0])
|
||||
const solver = adaptCanonicalWorldToSolver(canonical)
|
||||
console.log(`World adaptation (canonical -> solver): ${(performance.now() - adaptStart).toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,45 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cold-extract-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Cold Extraction (cache cleared each run) ===\n')
|
||||
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
const ui = await imhotep(page)
|
||||
for (const sel of selectors) {
|
||||
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
await ui.clearCache()
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 cold runs)`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,36 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-hotspot-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
// Run extraction 50 times for profiling
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const ui = await imhotep(page)
|
||||
for (let j = 0; j < 10; j++) {
|
||||
ui.expect(`[data-testid="item-${j}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
await ui.checkAll()
|
||||
}
|
||||
|
||||
console.log('Extraction profiling complete')
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,47 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-profile-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 200}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 20) * 60}px;top:${Math.floor(i / 20) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
// Warm up
|
||||
const ui0 = await imhotep(page)
|
||||
ui0.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
|
||||
await ui0.checkAll()
|
||||
|
||||
// Profile extraction + evaluation
|
||||
const times = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
const ui = await imhotep(page)
|
||||
ui.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
|
||||
for (let i = 2; i < 20; i++) {
|
||||
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i}]`)
|
||||
}
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`Extraction + 20 assertions: ${mean.toFixed(1)}ms (avg of 5)`)
|
||||
console.log(`Min: ${Math.min(...times).toFixed(1)}ms, Max: ${Math.max(...times).toFixed(1)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,71 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-prof2-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Extraction Phase Breakdown ===\n')
|
||||
|
||||
// Profile with varying number of selectors
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
const ui = await imhotep(page)
|
||||
for (const sel of selectors) {
|
||||
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 runs)`)
|
||||
}
|
||||
|
||||
// Now profile cache serialization separately
|
||||
console.log('\n=== Cache Serialization Profile ===')
|
||||
const { createEmptyWorld, serializeGeometryWorld } = await import('imhotep-core')
|
||||
|
||||
for (const count of [100, 500, 1000, 5000]) {
|
||||
const world = createEmptyWorld('s', 'sn')
|
||||
world.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
world.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
|
||||
world.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
|
||||
world.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
world.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
|
||||
world.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
|
||||
world.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
|
||||
world.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
|
||||
|
||||
const times = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now()
|
||||
serializeGeometryWorld(world)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
const json = serializeGeometryWorld(world)
|
||||
console.log(`${count} elements: ${mean.toFixed(2)}ms, ${(json.length / 1024).toFixed(1)}KB JSON`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,54 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-fact-plan-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 10}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const selectors = Array.from({length: 10}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
|
||||
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
|
||||
const sm = createSessionManager(page)
|
||||
|
||||
// Full extraction (all facts)
|
||||
const fullStart = performance.now()
|
||||
const fullResult = await new CDPExtractor(sm).extract({
|
||||
requestId: 'full',
|
||||
sceneTarget: { pageRef: page, url: page.url() },
|
||||
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
|
||||
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
|
||||
requiredFacts: { geometry: true, topology: true, styles: true, fragments: true }
|
||||
})
|
||||
const fullDur = performance.now() - fullStart
|
||||
console.log(`Full extraction (all facts): ${fullDur.toFixed(1)}ms`)
|
||||
|
||||
// Geometry only
|
||||
const geoStart = performance.now()
|
||||
const geoResult = await new CDPExtractor(sm).extract({
|
||||
requestId: 'geo',
|
||||
sceneTarget: { pageRef: page, url: page.url() },
|
||||
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
|
||||
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
|
||||
requiredFacts: { geometry: true, topology: false, styles: false, fragments: false }
|
||||
})
|
||||
const geoDur = performance.now() - geoStart
|
||||
console.log(`Geometry only: ${geoDur.toFixed(1)}ms`)
|
||||
console.log(`Savings: ${((1 - geoDur/fullDur) * 100).toFixed(0)}%`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,78 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-final-bench-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `
|
||||
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Final Comprehensive Benchmark ===\n')
|
||||
|
||||
// Cold extraction with varying assertion counts
|
||||
console.log('Cold extraction (cache cleared):')
|
||||
for (const count of [1, 10, 50]) {
|
||||
const ui = await imhotep(page)
|
||||
for (let i = 0; i < count; i++) {
|
||||
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
const times = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
await ui.clearCache()
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(` ${count} assertions: ${mean.toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
// Cache hit performance
|
||||
console.log('\nCache hit (warm):')
|
||||
const ui2 = await imhotep(page)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui2.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
await ui2.checkAll() // warm up cache
|
||||
|
||||
const warmTimes = []
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const start = performance.now()
|
||||
await ui2.checkAll()
|
||||
warmTimes.push(performance.now() - start)
|
||||
}
|
||||
const warmMean = warmTimes.reduce((a,b) => a+b, 0) / warmTimes.length
|
||||
console.log(` 50 assertions: ${warmMean.toFixed(2)}ms`)
|
||||
|
||||
// validate() only
|
||||
console.log('\nValidation only (no browser):')
|
||||
const ui3 = await imhotep(page)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui3.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
const validateTimes = []
|
||||
for (let run = 0; run < 100; run++) {
|
||||
const start = performance.now()
|
||||
ui3.validate()
|
||||
validateTimes.push(performance.now() - start)
|
||||
}
|
||||
const validateMean = validateTimes.reduce((a,b) => a+b, 0) / validateTimes.length
|
||||
console.log(` 50 assertions: ${validateMean.toFixed(2)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,308 @@
|
||||
// fol-benchmark.ts - Benchmark deterministic first-order logic evaluation.
|
||||
// Measures forall/exists performance over finite domains.
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
type BenchmarkResult,
|
||||
} from './benchmark.js';
|
||||
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
clearPredicateRegistry,
|
||||
} from 'imhotep-solver';
|
||||
|
||||
import type {
|
||||
GeometryWorld,
|
||||
} from 'imhotep-solver';
|
||||
|
||||
interface DomainValue {
|
||||
domainId: string;
|
||||
subjectIds: Uint32Array;
|
||||
provenance: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeLinearWorld(count: number): GeometryWorld {
|
||||
const ids = Array.from({ length: count }, (_, i) => i + 1);
|
||||
const boxIds = ids.map((id) => id * 100);
|
||||
|
||||
// Place elements in a horizontal row, each 50px wide with 10px gap
|
||||
const borderLeft = ids.map((_, i) => i * 60);
|
||||
const borderTop = ids.map(() => 0);
|
||||
const borderRight = borderLeft.map((l) => l + 50);
|
||||
const borderBottom = ids.map(() => 50);
|
||||
|
||||
return {
|
||||
sceneId: `linear_${count}`,
|
||||
snapshotId: 'snap_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids,
|
||||
domNodeId: ids.map((id) => id * 10),
|
||||
subjectKind: ids.map(() => 1),
|
||||
primaryBoxId: boxIds,
|
||||
firstFragmentId: ids.map(() => 0),
|
||||
fragmentCount: ids.map(() => 0),
|
||||
},
|
||||
dom: {
|
||||
nodeId: ids.map((id) => id * 10),
|
||||
parentNodeId: ids.map(() => 0),
|
||||
childCount: ids.map(() => 0),
|
||||
tagNameStringId: ids.map(() => 0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: boxIds,
|
||||
subjectId: ids,
|
||||
frameId: ids.map(() => 1),
|
||||
borderLeft,
|
||||
borderTop,
|
||||
borderRight,
|
||||
borderBottom,
|
||||
paddingLeft: ids.map(() => 0),
|
||||
paddingTop: ids.map(() => 0),
|
||||
paddingRight: ids.map(() => 0),
|
||||
paddingBottom: ids.map(() => 0),
|
||||
contentLeft: ids.map(() => 0),
|
||||
contentTop: ids.map(() => 0),
|
||||
contentRight: ids.map(() => 0),
|
||||
contentBottom: ids.map(() => 0),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: boxIds,
|
||||
subjectId: ids,
|
||||
frameId: ids.map(() => 1),
|
||||
borderLeft,
|
||||
borderTop,
|
||||
borderRight,
|
||||
borderBottom,
|
||||
paddingLeft: ids.map(() => 0),
|
||||
paddingTop: ids.map(() => 0),
|
||||
paddingRight: ids.map(() => 0),
|
||||
paddingBottom: ids.map(() => 0),
|
||||
contentLeft: ids.map(() => 0),
|
||||
contentTop: ids.map(() => 0),
|
||||
contentRight: ids.map(() => 0),
|
||||
contentBottom: ids.map(() => 0),
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
matrices: {
|
||||
values: [],
|
||||
},
|
||||
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
|
||||
topology: {
|
||||
containingBlockOf: ids.map(() => 0),
|
||||
nearestPositionedAncestorOf: ids.map(() => 0),
|
||||
scrollContainerOf: ids.map(() => 0),
|
||||
stackingContextOf: ids.map(() => 0),
|
||||
formattingContextOf: ids.map(() => 0),
|
||||
clippingRootOf: ids.map(() => 0),
|
||||
paintOrderBucket: ids.map(() => 0),
|
||||
paintOrderIndex: ids.map(() => 0),
|
||||
},
|
||||
scroll: {
|
||||
containerId: [], scrollLeft: [], scrollTop: [],
|
||||
scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: []
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: [], subjectId: [], clipKind: [],
|
||||
clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: []
|
||||
},
|
||||
visibility: {
|
||||
subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: []
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolver Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeResolver(count: number) {
|
||||
return {
|
||||
resolve(domain: any): DomainValue | undefined {
|
||||
const key = domain.selector ?? domain.domain;
|
||||
if (key === '.el') {
|
||||
return {
|
||||
domainId: 'dom_el',
|
||||
subjectIds: new Uint32Array(Array.from({ length: count }, (_, i) => i + 1)),
|
||||
provenance: 'elements(.el)',
|
||||
closed: true,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula Factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function forallFormula(): any {
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['x'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'atLeast',
|
||||
args: [{ type: 'VariableRef', name: 'x' }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function existsFormula(): any {
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['x'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'atLeast',
|
||||
args: [{ type: 'VariableRef', name: 'x' }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function nestedFormula(): any {
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['x'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['y'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [{ type: 'VariableRef', name: 'x' }, { type: 'VariableRef', name: 'y' }]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runFolBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
clearPredicateRegistry();
|
||||
registerDefaultPredicates();
|
||||
|
||||
const suite = new BenchmarkSuite('fol-evaluation', {
|
||||
defaults: { warmupRuns: 1, measurementRuns: 5 }
|
||||
});
|
||||
|
||||
// forall over 100 elements
|
||||
suite.add('forall-100', () => {
|
||||
const world = makeLinearWorld(100);
|
||||
evaluateLogic({
|
||||
formula: forallFormula(),
|
||||
world,
|
||||
resolver: makeResolver(100),
|
||||
});
|
||||
});
|
||||
|
||||
// forall over 1,000 elements
|
||||
suite.add('forall-1000', () => {
|
||||
const world = makeLinearWorld(1000);
|
||||
evaluateLogic({
|
||||
formula: forallFormula(),
|
||||
world,
|
||||
resolver: makeResolver(1000),
|
||||
});
|
||||
});
|
||||
|
||||
// forall over 10,000 elements
|
||||
suite.add('forall-10000', () => {
|
||||
const world = makeLinearWorld(10000);
|
||||
evaluateLogic({
|
||||
formula: forallFormula(),
|
||||
world,
|
||||
resolver: makeResolver(10000),
|
||||
});
|
||||
});
|
||||
|
||||
// exists with early exit (first element always matches)
|
||||
suite.add('exists-early-exit-10000', () => {
|
||||
const world = makeLinearWorld(10000);
|
||||
evaluateLogic({
|
||||
formula: existsFormula(),
|
||||
world,
|
||||
resolver: makeResolver(10000),
|
||||
});
|
||||
});
|
||||
|
||||
// nested quantifiers (forall + exists) over 100 elements
|
||||
suite.add('nested-quantifiers-100', () => {
|
||||
const world = makeLinearWorld(100);
|
||||
evaluateLogic({
|
||||
formula: nestedFormula(),
|
||||
world,
|
||||
resolver: makeResolver(100),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await suite.runSequential();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runFolBenchmarks().then((results) => {
|
||||
console.log('\n=== FOL Benchmark Results ===\n');
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}:`);
|
||||
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
|
||||
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
|
||||
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
|
||||
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-full-pipeline-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Full Pipeline Benchmark ===\n')
|
||||
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const ui = await imhotep(page)
|
||||
for (let i = 0; i < count; i++) {
|
||||
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
await ui.clearCache()
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} assertions (cold): ${mean.toFixed(1)}ms (5 runs)`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,83 @@
|
||||
// index.ts - Package exports for imhotep-bench
|
||||
// Benchmarking and performance testing harness for Imhotep.
|
||||
|
||||
export {
|
||||
type PerformanceBudget,
|
||||
type PerformanceProfile,
|
||||
type BudgetCheck,
|
||||
DEFAULT_PROFILES,
|
||||
checkBudget,
|
||||
checkAllBudgets,
|
||||
} from './profiles.js'
|
||||
|
||||
export {
|
||||
type CacheKey,
|
||||
type CacheEntry,
|
||||
type Cache,
|
||||
MemoryCache,
|
||||
CompilationCache,
|
||||
ExtractionCache,
|
||||
makeCompilationCacheKey,
|
||||
makeExtractionCacheKey,
|
||||
} from './cache.js'
|
||||
|
||||
export {
|
||||
type WorkerTask,
|
||||
type WorkerPoolOptions,
|
||||
type WorkerPoolMetrics,
|
||||
WorkerPool,
|
||||
type PromiseTask,
|
||||
type PromisePoolOptions,
|
||||
PromisePool,
|
||||
} from './parallel.js'
|
||||
|
||||
export {
|
||||
type BenchmarkOptions,
|
||||
type BenchmarkResult,
|
||||
type RunMeasurement,
|
||||
type BenchmarkSuiteOptions,
|
||||
type BenchmarkSuiteResult,
|
||||
profileRun,
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
} from './benchmark.js'
|
||||
|
||||
export {
|
||||
type ProfileEntry,
|
||||
ProfileSession,
|
||||
type CompilationProfilerOptions,
|
||||
type ExtractionProfilerOptions,
|
||||
type SolvingProfilerOptions,
|
||||
type WorldCreationProfilerOptions,
|
||||
type FrameResolutionProfilerOptions,
|
||||
profileCompilation,
|
||||
profileExtraction,
|
||||
profileSolving,
|
||||
profileWorldCreation,
|
||||
profileFrameResolution,
|
||||
type RegressionResult,
|
||||
detectRegression,
|
||||
formatProfileReport,
|
||||
} from './profile.js'
|
||||
|
||||
export {
|
||||
type MemoCache,
|
||||
LruMemoCache,
|
||||
memoize,
|
||||
type StringInterner,
|
||||
FastStringInterner,
|
||||
type ClauseDescriptor,
|
||||
type ClauseResult,
|
||||
type ClauseEvaluator,
|
||||
batchEvaluateClauses,
|
||||
type FrameResolver,
|
||||
createLazyFrameResolver,
|
||||
type FlatTable,
|
||||
buildIdIndex,
|
||||
buildSubjectIndex,
|
||||
binarySearchUint32,
|
||||
type TimedFunction,
|
||||
timeFunction,
|
||||
type BufferPool,
|
||||
Float64BufferPool,
|
||||
} from './optimize.js'
|
||||
@@ -0,0 +1,373 @@
|
||||
// optimize.ts - Optimization helpers for Imhotep hot paths
|
||||
// Provides memoization, batching, string interning, and lazy evaluation utilities.
|
||||
// All helpers use dependency injection (no optional imports or global state).
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memoization — cache function results by serialized key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemoCache<K, V> {
|
||||
get(key: K): V | undefined
|
||||
set(key: K, value: V): void
|
||||
has(key: K): boolean
|
||||
clear(): void
|
||||
}
|
||||
|
||||
/** Simple Map-backed memo cache with size limit and LRU eviction. */
|
||||
export class LruMemoCache<K, V> implements MemoCache<K, V> {
|
||||
private cache = new Map<K, V>()
|
||||
|
||||
constructor(private maxSize: number = 1024) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key)
|
||||
if (value !== undefined) {
|
||||
// Touch entry for LRU ordering
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Evict oldest (first inserted)
|
||||
const firstKey = this.cache.keys().next().value
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoize a pure function using an injected cache.
|
||||
* The keyFn serializes arguments; the cache stores results.
|
||||
*/
|
||||
export function memoize<A extends unknown[], R>(
|
||||
fn: (...args: A) => R,
|
||||
keyFn: (...args: A) => string,
|
||||
cache: MemoCache<string, R>
|
||||
): (...args: A) => R {
|
||||
return (...args: A): R => {
|
||||
const key = keyFn(...args)
|
||||
const cached = cache.get(key)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const result = fn(...args)
|
||||
cache.set(key, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String interning — cache selector strings to avoid repeated hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StringInterner {
|
||||
intern(value: string): number
|
||||
resolve(index: number): string
|
||||
size(): number
|
||||
}
|
||||
|
||||
/** Fast string interner backed by a Map for O(1) lookups. */
|
||||
export class FastStringInterner implements StringInterner {
|
||||
private indexByValue = new Map<string, number>()
|
||||
private values: string[] = []
|
||||
|
||||
intern(value: string): number {
|
||||
const existing = this.indexByValue.get(value)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
const idx = this.values.length
|
||||
this.values.push(value)
|
||||
this.indexByValue.set(value, idx)
|
||||
return idx
|
||||
}
|
||||
|
||||
resolve(index: number): string {
|
||||
return this.values[index] ?? ''
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.values.length
|
||||
}
|
||||
|
||||
/** Export the string table as a plain array (for world construction). */
|
||||
exportTable(): string[] {
|
||||
return this.values.slice()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch clause evaluation — evaluate multiple clauses in a single pass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClauseDescriptor {
|
||||
clauseId: string
|
||||
clauseKind: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ClauseResult {
|
||||
clauseId: string
|
||||
status: string
|
||||
truth: string
|
||||
}
|
||||
|
||||
export type ClauseEvaluator = (clause: ClauseDescriptor) => ClauseResult
|
||||
|
||||
/**
|
||||
* Batch-evaluate clauses by grouping them by kind.
|
||||
* Returns results in the same order as input clauses.
|
||||
* The evaluatorFactory is injected so callers can supply their own registry.
|
||||
*/
|
||||
export function batchEvaluateClauses(
|
||||
clauses: ClauseDescriptor[],
|
||||
evaluatorFactory: (kind: string, version: number) => ClauseEvaluator | undefined
|
||||
): ClauseResult[] {
|
||||
// Group clause indices by kind+version to minimize evaluator lookups
|
||||
const groups = new Map<string, number[]>()
|
||||
for (let i = 0; i < clauses.length; i++) {
|
||||
const c = clauses[i]
|
||||
const key = `${c.clauseKind}@v${c.version}`
|
||||
const indices = groups.get(key)
|
||||
if (indices) {
|
||||
indices.push(i)
|
||||
} else {
|
||||
groups.set(key, [i])
|
||||
}
|
||||
}
|
||||
|
||||
const results: ClauseResult[] = new Array(clauses.length)
|
||||
|
||||
for (const [key, indices] of groups) {
|
||||
const first = clauses[indices[0]]
|
||||
const evaluator = evaluatorFactory(first.clauseKind, first.version)
|
||||
if (!evaluator) {
|
||||
// Fill missing evaluators with error results
|
||||
for (const idx of indices) {
|
||||
results[idx] = {
|
||||
clauseId: clauses[idx].clauseId,
|
||||
status: 'error',
|
||||
truth: 'indeterminate',
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for (const idx of indices) {
|
||||
results[idx] = evaluator(clauses[idx])
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy frame resolution — only resolve frames when needed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FrameResolver {
|
||||
resolve(frameId: number): unknown
|
||||
invalidate(frameId: number): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy frame resolver that caches resolved transforms.
|
||||
* The resolveFn is injected so callers can supply their own coordinate system.
|
||||
*/
|
||||
export function createLazyFrameResolver(
|
||||
resolveFn: (frameId: number) => unknown,
|
||||
cache: MemoCache<number, unknown>
|
||||
): FrameResolver {
|
||||
return {
|
||||
resolve(frameId: number): unknown {
|
||||
const cached = cache.get(frameId)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const result = resolveFn(frameId)
|
||||
cache.set(frameId, result)
|
||||
return result
|
||||
},
|
||||
invalidate(frameId: number): void {
|
||||
// No-op for Map-based cache; real implementation may support deletion
|
||||
void frameId
|
||||
},
|
||||
clear(): void {
|
||||
cache.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index pre-computation — build lookup maps during normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FlatTable {
|
||||
ids: Uint32Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bidirectional index for a flat id table.
|
||||
* Returns a Map<id, index> for O(1) lookups.
|
||||
*/
|
||||
export function buildIdIndex(table: FlatTable): Map<number, number> {
|
||||
const map = new Map<number, number>()
|
||||
const ids = table.ids
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
map.set(ids[i], i)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-value index (one-to-many) from a subject-id column.
|
||||
* Returns a Map<subjectId, index[]> for fast reverse lookups.
|
||||
*/
|
||||
export function buildSubjectIndex(subjectIdColumn: Uint32Array): Map<number, number[]> {
|
||||
const map = new Map<number, number[]>()
|
||||
for (let i = 0; i < subjectIdColumn.length; i++) {
|
||||
const sid = subjectIdColumn[i]
|
||||
const arr = map.get(sid)
|
||||
if (arr) {
|
||||
arr.push(i)
|
||||
} else {
|
||||
map.set(sid, [i])
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Array scan replacement — binary search on sorted arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Binary search for a value in a sorted Uint32Array.
|
||||
* Returns the index if found, otherwise -1.
|
||||
* This replaces linear scans when data is pre-sorted.
|
||||
*/
|
||||
export function binarySearchUint32(arr: Uint32Array, value: number): number {
|
||||
let lo = 0
|
||||
let hi = arr.length - 1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
const midVal = arr[mid]
|
||||
if (midVal === value) {
|
||||
return mid
|
||||
} else if (midVal < value) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Performance timing decorator — wrap functions with measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimedFunction<T extends unknown[], R> {
|
||||
(...args: T): R
|
||||
readonly totalCalls: number
|
||||
readonly totalDurationMs: number
|
||||
readonly meanDurationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function to measure its execution time.
|
||||
* Returns a new function with attached statistics.
|
||||
*/
|
||||
export function timeFunction<T extends unknown[], R>(
|
||||
fn: (...args: T) => R,
|
||||
label?: string
|
||||
): TimedFunction<T, R> {
|
||||
let calls = 0
|
||||
let duration = 0
|
||||
|
||||
const wrapped = (...args: T): R => {
|
||||
const start = performance.now()
|
||||
try {
|
||||
return fn(...args)
|
||||
} finally {
|
||||
duration += performance.now() - start
|
||||
calls++
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(wrapped, 'totalCalls', {
|
||||
get: () => calls,
|
||||
})
|
||||
Object.defineProperty(wrapped, 'totalDurationMs', {
|
||||
get: () => duration,
|
||||
})
|
||||
Object.defineProperty(wrapped, 'meanDurationMs', {
|
||||
get: () => (calls > 0 ? duration / calls : 0),
|
||||
})
|
||||
|
||||
return wrapped as TimedFunction<T, R>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buffer pooling — reuse typed arrays to reduce GC pressure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BufferPool<T> {
|
||||
acquire(minSize: number): T
|
||||
release(buf: T): void
|
||||
}
|
||||
|
||||
/** Simple pool for Float64Array buffers with power-of-2 sizing. */
|
||||
export class Float64BufferPool implements BufferPool<Float64Array> {
|
||||
private pool: Float64Array[] = []
|
||||
private maxPoolSize: number
|
||||
|
||||
constructor(maxPoolSize = 16) {
|
||||
this.maxPoolSize = maxPoolSize
|
||||
}
|
||||
|
||||
acquire(minSize: number): Float64Array {
|
||||
const size = nextPowerOf2(minSize)
|
||||
// Find a buffer that is large enough
|
||||
for (let i = 0; i < this.pool.length; i++) {
|
||||
if (this.pool[i].length >= size) {
|
||||
return this.pool.splice(i, 1)[0]
|
||||
}
|
||||
}
|
||||
return new Float64Array(size)
|
||||
}
|
||||
|
||||
release(buf: Float64Array): void {
|
||||
if (this.pool.length < this.maxPoolSize) {
|
||||
this.pool.push(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextPowerOf2(n: number): number {
|
||||
if (n <= 1) return 1
|
||||
let p = 1
|
||||
while (p < n) {
|
||||
p <<= 1
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// parallel.ts - Worker parallelism utilities for Imhotep bench harness
|
||||
// Supports both worker_thread pools and lightweight Promise-based pools.
|
||||
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
export interface WorkerTask<TInput, TOutput> {
|
||||
/** Unique task identifier */
|
||||
id: string
|
||||
/** Task payload */
|
||||
input: TInput
|
||||
/** Worker script path for worker_thread execution */
|
||||
workerScript: string
|
||||
}
|
||||
|
||||
export interface WorkerPoolOptions {
|
||||
/** Maximum concurrent workers */
|
||||
maxWorkers: number
|
||||
/** Per-task timeout in milliseconds */
|
||||
taskTimeoutMs: number
|
||||
}
|
||||
|
||||
export interface WorkerPoolMetrics {
|
||||
queued: number
|
||||
running: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
interface PendingTask<TInput, TOutput> {
|
||||
task: WorkerTask<TInput, TOutput>
|
||||
resolve: (value: TOutput) => void
|
||||
reject: (reason: Error) => void
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkerPool manages a fixed number of worker threads.
|
||||
* Tasks are queued and executed as workers become available.
|
||||
*/
|
||||
export class WorkerPool extends EventEmitter {
|
||||
private queue: Array<PendingTask<unknown, unknown>> = []
|
||||
private activeTasks = new Map<string, PendingTask<unknown, unknown>>()
|
||||
private metrics = { queued: 0, running: 0, completed: 0, failed: 0 }
|
||||
private shuttingDown = false
|
||||
|
||||
constructor(private options: WorkerPoolOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task on an available worker.
|
||||
* Returns a promise that resolves with the worker output.
|
||||
*/
|
||||
execute<TInput, TOutput>(
|
||||
task: WorkerTask<TInput, TOutput>
|
||||
): Promise<TOutput> {
|
||||
if (this.shuttingDown) {
|
||||
return Promise.reject(new Error('WorkerPool is shutting down'))
|
||||
}
|
||||
|
||||
return new Promise<TOutput>((resolve, reject) => {
|
||||
const pending: PendingTask<TInput, TOutput> = {
|
||||
task,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject: reject as (reason: Error) => void,
|
||||
}
|
||||
this.queue.push(pending as PendingTask<unknown, unknown>)
|
||||
this.metrics.queued++
|
||||
this.emit('queued', task.id)
|
||||
this._pump()
|
||||
})
|
||||
}
|
||||
|
||||
/** Execute multiple tasks in parallel, returning results in input order. */
|
||||
async executeAll<TInput, TOutput>(
|
||||
tasks: WorkerTask<TInput, TOutput>[]
|
||||
): Promise<TOutput[]> {
|
||||
const promises = tasks.map(t => this.execute(t))
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
/** Current pool metrics snapshot */
|
||||
getMetrics(): WorkerPoolMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
/** Terminate all workers and clear the queue */
|
||||
async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true
|
||||
|
||||
for (const pending of this.queue) {
|
||||
pending.reject(new Error('WorkerPool shutdown'))
|
||||
}
|
||||
this.queue = []
|
||||
|
||||
for (const [, pending] of this.activeTasks) {
|
||||
if (pending.timer) clearTimeout(pending.timer)
|
||||
pending.reject(new Error('WorkerPool shutdown'))
|
||||
}
|
||||
this.activeTasks.clear()
|
||||
}
|
||||
|
||||
private _pump(): void {
|
||||
if (this.queue.length === 0) return
|
||||
if (this.activeTasks.size >= this.options.maxWorkers) return
|
||||
|
||||
const pending = this.queue.shift()!
|
||||
this.metrics.queued--
|
||||
this.metrics.running++
|
||||
|
||||
const taskId = pending.task.id
|
||||
this.activeTasks.set(taskId, pending)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pending.reject(
|
||||
new Error(
|
||||
`Task ${pending.task.id} timed out after ${this.options.taskTimeoutMs}ms`
|
||||
)
|
||||
)
|
||||
this.activeTasks.delete(taskId)
|
||||
this.metrics.running--
|
||||
this.metrics.failed++
|
||||
this.emit('timeout', pending.task.id)
|
||||
this._pump()
|
||||
}, this.options.taskTimeoutMs)
|
||||
|
||||
pending.timer = timer
|
||||
|
||||
const worker = new Worker(pending.task.workerScript)
|
||||
|
||||
worker.once(
|
||||
'message',
|
||||
(message: {
|
||||
success: boolean
|
||||
result?: unknown
|
||||
error?: string
|
||||
}) => {
|
||||
clearTimeout(timer)
|
||||
this.activeTasks.delete(taskId)
|
||||
this.metrics.running--
|
||||
void worker.terminate()
|
||||
|
||||
if (message.success) {
|
||||
pending.resolve(message.result)
|
||||
this.metrics.completed++
|
||||
this.emit('completed', pending.task.id)
|
||||
} else {
|
||||
pending.reject(new Error(message.error || 'Worker task failed'))
|
||||
this.metrics.failed++
|
||||
this.emit('failed', pending.task.id)
|
||||
}
|
||||
|
||||
this._pump()
|
||||
}
|
||||
)
|
||||
|
||||
worker.once('error', (err: Error) => {
|
||||
clearTimeout(timer)
|
||||
this.activeTasks.delete(taskId)
|
||||
this.metrics.running--
|
||||
this.metrics.failed++
|
||||
void worker.terminate()
|
||||
pending.reject(err)
|
||||
this.emit('failed', pending.task.id)
|
||||
this._pump()
|
||||
})
|
||||
|
||||
worker.postMessage({ id: pending.task.id, input: pending.task.input })
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromiseTask<TInput, TOutput> {
|
||||
id: string
|
||||
input: TInput
|
||||
fn: (input: TInput) => TOutput | Promise<TOutput>
|
||||
}
|
||||
|
||||
export interface PromisePoolOptions {
|
||||
maxConcurrency: number
|
||||
taskTimeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* PromisePool provides lightweight parallelism without worker threads.
|
||||
* Runs async functions with controlled concurrency.
|
||||
*/
|
||||
export class PromisePool extends EventEmitter {
|
||||
private queue: Array<{
|
||||
task: PromiseTask<unknown, unknown>
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: Error) => void
|
||||
}> = []
|
||||
private running = 0
|
||||
private metrics = { queued: 0, completed: 0, failed: 0 }
|
||||
private shuttingDown = false
|
||||
|
||||
constructor(private options: PromisePoolOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
execute<TInput, TOutput>(
|
||||
task: PromiseTask<TInput, TOutput>
|
||||
): Promise<TOutput> {
|
||||
if (this.shuttingDown) {
|
||||
return Promise.reject(new Error('PromisePool is shutting down'))
|
||||
}
|
||||
|
||||
return new Promise<TOutput>((resolve, reject) => {
|
||||
this.queue.push({
|
||||
task: task as PromiseTask<unknown, unknown>,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
this.metrics.queued++
|
||||
this.emit('queued', task.id)
|
||||
this._pump()
|
||||
})
|
||||
}
|
||||
|
||||
async executeAll<TInput, TOutput>(
|
||||
tasks: PromiseTask<TInput, TOutput>[]
|
||||
): Promise<TOutput[]> {
|
||||
const promises = tasks.map(t => this.execute(t))
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
getMetrics(): WorkerPoolMetrics {
|
||||
return {
|
||||
queued: this.metrics.queued,
|
||||
running: this.running,
|
||||
completed: this.metrics.completed,
|
||||
failed: this.metrics.failed,
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true
|
||||
for (const item of this.queue) {
|
||||
item.reject(new Error('PromisePool shutdown'))
|
||||
}
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
private _pump(): void {
|
||||
if (this.queue.length === 0) return
|
||||
if (this.running >= this.options.maxConcurrency) return
|
||||
|
||||
const item = this.queue.shift()!
|
||||
this.metrics.queued--
|
||||
this.running++
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.running--
|
||||
this.metrics.failed++
|
||||
item.reject(
|
||||
new Error(
|
||||
`Task ${item.task.id} timed out after ${this.options.taskTimeoutMs}ms`
|
||||
)
|
||||
)
|
||||
this.emit('timeout', item.task.id)
|
||||
this._pump()
|
||||
}, this.options.taskTimeoutMs)
|
||||
|
||||
Promise.resolve(item.task.fn(item.task.input))
|
||||
.then(result => {
|
||||
clearTimeout(timer)
|
||||
this.running--
|
||||
this.metrics.completed++
|
||||
item.resolve(result)
|
||||
this.emit('completed', item.task.id)
|
||||
this._pump()
|
||||
})
|
||||
.catch(err => {
|
||||
clearTimeout(timer)
|
||||
this.running--
|
||||
this.metrics.failed++
|
||||
item.reject(err instanceof Error ? err : new Error(String(err)))
|
||||
this.emit('failed', item.task.id)
|
||||
this._pump()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
// Profile dense spec parsing
|
||||
const spec = `
|
||||
in viewport:
|
||||
'.header' leftOf '.content' gap 16px
|
||||
'.sidebar' above '.main' gap 8px
|
||||
'.footer' below '.main' gap 24px
|
||||
'.button' atLeast 44px wide
|
||||
'.card' inside '.container'
|
||||
'.modal' centeredWithin '.viewport' tolerance 2px
|
||||
`
|
||||
|
||||
async function profileParse() {
|
||||
const { parseSpec } = await import('imhotep-dsl')
|
||||
const times = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const start = performance.now()
|
||||
parseSpec(spec)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`Parse 6-clause spec: ${mean.toFixed(3)}ms (avg of 1000)`)
|
||||
console.log(`Min: ${Math.min(...times).toFixed(3)}ms, Max: ${Math.max(...times).toFixed(3)}ms`)
|
||||
}
|
||||
|
||||
async function profileSerialize() {
|
||||
const { createEmptyWorld } = await import('imhotep-core')
|
||||
const { serializeGeometryWorld } = await import('imhotep-core')
|
||||
|
||||
const world = createEmptyWorld('scene', 'snap')
|
||||
// Populate with 1000 elements
|
||||
const n = 1000
|
||||
world.subjects.ids = new Uint32Array(Array.from({length: n}, (_, i) => i + 1))
|
||||
world.subjects.domNodeId = new Uint32Array(Array.from({length: n}, (_, i) => i + 10))
|
||||
world.boxes.boxId = new Uint32Array(Array.from({length: n}, (_, i) => i + 100))
|
||||
world.boxes.subjectId = new Uint32Array(Array.from({length: n}, (_, i) => i + 1))
|
||||
world.boxes.borderLeft = new Float64Array(Array.from({length: n}, (_, i) => i * 10))
|
||||
world.boxes.borderTop = new Float64Array(Array.from({length: n}, () => 0))
|
||||
world.boxes.borderRight = new Float64Array(Array.from({length: n}, (_, i) => i * 10 + 50))
|
||||
world.boxes.borderBottom = new Float64Array(Array.from({length: n}, () => 50))
|
||||
|
||||
const times = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now()
|
||||
serializeGeometryWorld(world)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`Serialize 1000-element world: ${mean.toFixed(2)}ms (avg of 100)`)
|
||||
console.log(`Min: ${Math.min(...times).toFixed(2)}ms, Max: ${Math.max(...times).toFixed(2)}ms`)
|
||||
}
|
||||
|
||||
await profileParse()
|
||||
await profileSerialize()
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Comprehensive pooling performance benchmark.
|
||||
*
|
||||
* Measures REAL speedups from the new parallel pooling architecture
|
||||
* using actual Playwright + Imhotep code paths.
|
||||
*/
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { createRuntime } from '../../imhotep-playwright/src/runtime.js'
|
||||
import { runEnumeratedProperty } from '../../imhotep-playwright/src/property-runner.js'
|
||||
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-pool-bench-'))
|
||||
|
||||
// Create a simple fixture page
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="app">
|
||||
<button id="btn1">Button 1</button>
|
||||
<button id="btn2">Button 2</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'fixture.html'), html)
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((a, b) => a + b, 0) / values.length
|
||||
}
|
||||
|
||||
async function benchmark() {
|
||||
console.log('========================================')
|
||||
console.log(' Imhotep Pooling Performance Benchmark')
|
||||
console.log('========================================\n')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 1: Page Lifecycle — Direct vs Pool
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 1: Page Creation vs Pool Borrow ---')
|
||||
|
||||
const runtime = await createRuntime(browser, {
|
||||
poolOptions: { maxPagesPerContext: 8, warmPages: 4 }
|
||||
})
|
||||
|
||||
// Warm up the pool first
|
||||
const warmPages = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
warmPages.push(await runtime.createPage())
|
||||
}
|
||||
for (const p of warmPages) {
|
||||
await runtime.releasePage(p)
|
||||
}
|
||||
|
||||
const directTimes = []
|
||||
const poolTimes = []
|
||||
const iterations = 20
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Direct: newContext → newPage → close
|
||||
const ctx = await browser.newContext()
|
||||
const t0 = performance.now()
|
||||
const p = await ctx.newPage()
|
||||
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await p.close()
|
||||
directTimes.push(performance.now() - t0)
|
||||
await ctx.close()
|
||||
|
||||
// Pool: borrow → goto → return
|
||||
const t1 = performance.now()
|
||||
const page = await runtime.createPage()
|
||||
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await runtime.releasePage(page)
|
||||
poolTimes.push(performance.now() - t1)
|
||||
}
|
||||
|
||||
const directMean = mean(directTimes)
|
||||
const poolMean = mean(poolTimes)
|
||||
|
||||
console.log(` Direct create/nav/close (n=${iterations}):`)
|
||||
console.log(` mean: ${directMean.toFixed(2)}ms median: ${median(directTimes).toFixed(2)}ms`)
|
||||
console.log(` Pool borrow/nav/return (n=${iterations}):`)
|
||||
console.log(` mean: ${poolMean.toFixed(2)}ms median: ${median(poolTimes).toFixed(2)}ms`)
|
||||
console.log(` Speedup: ${(directMean / poolMean).toFixed(1)}x\n`)
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 2: Context Reuse
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 2: Context Creation vs Context Pool ---')
|
||||
|
||||
const env = { viewport: { width: 1280, height: 720 } }
|
||||
const envKey = JSON.stringify(env)
|
||||
|
||||
const noPoolCtxTimes = []
|
||||
const poolCtxTimes = []
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// No pool: create fresh context
|
||||
const t0 = performance.now()
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 } })
|
||||
const p = await ctx.newPage()
|
||||
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await ctx.close()
|
||||
noPoolCtxTimes.push(performance.now() - t0)
|
||||
|
||||
// Pool: acquire from pool (should reuse after first iteration)
|
||||
const t1 = performance.now()
|
||||
const ctx2 = await runtime.createContext(env)
|
||||
const p2 = await runtime.createPage(ctx2)
|
||||
await p2.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await runtime.releasePage(p2)
|
||||
// Note: we don't close context, pool reuses it
|
||||
poolCtxTimes.push(performance.now() - t1)
|
||||
}
|
||||
|
||||
const noPoolCtxMean = mean(noPoolCtxTimes)
|
||||
const poolCtxMean = mean(poolCtxTimes)
|
||||
|
||||
console.log(` New context each time (n=10):`)
|
||||
console.log(` mean: ${noPoolCtxMean.toFixed(2)}ms median: ${median(noPoolCtxTimes).toFixed(2)}ms`)
|
||||
console.log(` Pooled context (n=10, first=cold):`)
|
||||
console.log(` mean: ${poolCtxMean.toFixed(2)}ms median: ${median(poolCtxTimes).toFixed(2)}ms`)
|
||||
console.log(` Speedup: ${(noPoolCtxMean / poolCtxMean).toFixed(1)}x\n`)
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 3: Sequential vs Parallel Enumerated Properties
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 3: Enumerated Property Runner ---')
|
||||
|
||||
// Create a simple mock renderer adapter that tracks timing
|
||||
const mockAdapter = {
|
||||
async mount(target, renderCase) {
|
||||
const page = await runtime.createPage()
|
||||
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
return {
|
||||
caseId: renderCase.caseId,
|
||||
world: { sceneId: renderCase.caseId },
|
||||
page,
|
||||
}
|
||||
},
|
||||
async unmount(mounted) {
|
||||
await runtime.releasePage(mounted.page)
|
||||
},
|
||||
}
|
||||
|
||||
const mockFC = {
|
||||
assert: async () => ({ passed: true, numRuns: 100 }),
|
||||
record: () => ({}),
|
||||
constantFrom: (...values) => values,
|
||||
sample: () => [],
|
||||
property: () => ({}),
|
||||
asyncProperty: () => ({}),
|
||||
}
|
||||
|
||||
const domain = {
|
||||
mode: 'enumerated',
|
||||
values: Array.from({ length: 20 }, (_, i) => ({ id: i })),
|
||||
}
|
||||
|
||||
const predicate = async () => true
|
||||
|
||||
const deps = {
|
||||
rendererAdapter: mockAdapter,
|
||||
worldExtractor: { extract: async (mounted) => mounted.world },
|
||||
fc: mockFC,
|
||||
}
|
||||
|
||||
// Sequential (concurrency = 1)
|
||||
const seqTimes = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const t0 = performance.now()
|
||||
await runEnumeratedProperty(
|
||||
{ kind: 'fixture', fixtureId: 'test.html' },
|
||||
domain,
|
||||
predicate,
|
||||
deps,
|
||||
{ concurrency: 1, failFast: true }
|
||||
)
|
||||
seqTimes.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
// Parallel (concurrency = 4)
|
||||
const par4Times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const t0 = performance.now()
|
||||
await runEnumeratedProperty(
|
||||
{ kind: 'fixture', fixtureId: 'test.html' },
|
||||
domain,
|
||||
predicate,
|
||||
deps,
|
||||
{ concurrency: 4, failFast: true }
|
||||
)
|
||||
par4Times.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
// Parallel (concurrency = 8)
|
||||
const par8Times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const t0 = performance.now()
|
||||
await runEnumeratedProperty(
|
||||
{ kind: 'fixture', fixtureId: 'test.html' },
|
||||
domain,
|
||||
predicate,
|
||||
deps,
|
||||
{ concurrency: 8, failFast: true }
|
||||
)
|
||||
par8Times.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
const seqMean = mean(seqTimes)
|
||||
const par4Mean = mean(par4Times)
|
||||
const par8Mean = mean(par8Times)
|
||||
|
||||
console.log(` Sequential (concurrency=1, n=20 cases):`)
|
||||
console.log(` mean: ${seqMean.toFixed(1)}ms`)
|
||||
console.log(` Parallel (concurrency=4):`)
|
||||
console.log(` mean: ${par4Mean.toFixed(1)}ms speedup: ${(seqMean / par4Mean).toFixed(1)}x`)
|
||||
console.log(` Parallel (concurrency=8):`)
|
||||
console.log(` mean: ${par8Mean.toFixed(1)}ms speedup: ${(seqMean / par8Mean).toFixed(1)}x`)
|
||||
console.log(` Theoretical max (if perfectly parallel): ${(seqMean / 20).toFixed(1)}ms\n`)
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 4: Semaphore Overhead
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 4: Semaphore Scheduling Overhead ---')
|
||||
|
||||
const sem = new Semaphore(4)
|
||||
const taskCount = 100
|
||||
const taskDuration = 5 // ms
|
||||
|
||||
// Sequential baseline
|
||||
const seqSemStart = performance.now()
|
||||
for (let i = 0; i < taskCount; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, taskDuration))
|
||||
}
|
||||
const seqSemTime = performance.now() - seqSemStart
|
||||
|
||||
// Parallel with semaphore
|
||||
const parSemStart = performance.now()
|
||||
await Promise.all(
|
||||
Array.from({ length: taskCount }, () =>
|
||||
sem.run(() => new Promise(resolve => setTimeout(resolve, taskDuration)))
|
||||
)
|
||||
)
|
||||
const parSemTime = performance.now() - parSemStart
|
||||
|
||||
console.log(` Sequential (${taskCount} tasks × ${taskDuration}ms): ${seqSemTime.toFixed(1)}ms`)
|
||||
console.log(` Parallel (concurrency=4): ${parSemTime.toFixed(1)}ms`)
|
||||
console.log(` Speedup: ${(seqSemTime / parSemTime).toFixed(1)}x`)
|
||||
console.log(` Theoretical max: ${(taskCount * taskDuration / 4).toFixed(1)}ms\n`)
|
||||
|
||||
// ============================================================
|
||||
// Cleanup
|
||||
// ============================================================
|
||||
await runtime.shutdown()
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
|
||||
console.log('========================================')
|
||||
console.log(' Benchmark Complete')
|
||||
console.log('========================================')
|
||||
}
|
||||
|
||||
benchmark().catch(err => {
|
||||
console.error('Benchmark failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-describe-profile-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `
|
||||
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send('DOM.enable')
|
||||
|
||||
// Get document root
|
||||
const doc = await session.send('DOM.getDocument')
|
||||
const rootId = doc.root.nodeId
|
||||
|
||||
// Profile querySelectorAll
|
||||
const queryStart = performance.now()
|
||||
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootId, selector: '.item' })
|
||||
console.log(`querySelectorAll for 50 items: ${(performance.now() - queryStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile sequential DOM.describeNode
|
||||
const seqStart = performance.now()
|
||||
for (const nodeId of nodeIds.slice(0, 10)) {
|
||||
await session.send('DOM.describeNode', { nodeId })
|
||||
}
|
||||
console.log(`Sequential describeNode (10): ${(performance.now() - seqStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile parallel DOM.describeNode
|
||||
const parStart = performance.now()
|
||||
await Promise.all(nodeIds.slice(0, 10).map(nodeId => session.send('DOM.describeNode', { nodeId })))
|
||||
console.log(`Parallel describeNode (10): ${(performance.now() - parStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile all 50 sequential
|
||||
const seq50Start = performance.now()
|
||||
for (const nodeId of nodeIds) {
|
||||
await session.send('DOM.describeNode', { nodeId })
|
||||
}
|
||||
console.log(`Sequential describeNode (50): ${(performance.now() - seq50Start).toFixed(1)}ms`)
|
||||
|
||||
// Profile all 50 parallel
|
||||
const par50Start = performance.now()
|
||||
await Promise.all(nodeIds.map(nodeId => session.send('DOM.describeNode', { nodeId })))
|
||||
console.log(`Parallel describeNode (50): ${(performance.now() - par50Start).toFixed(1)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,47 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-parallel-resolve-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const { createSessionManager } = await import('imhotep-cdp')
|
||||
const sm = createSessionManager(page)
|
||||
const session = await sm.attach()
|
||||
await session.send('DOM.enable')
|
||||
const rootNodeId = await sm.getDocumentRootNodeId()
|
||||
|
||||
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
|
||||
// Sequential resolution
|
||||
const seqStart = performance.now()
|
||||
for (const sel of selectors) {
|
||||
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
|
||||
}
|
||||
const seqDur = performance.now() - seqStart
|
||||
console.log(`Sequential querySelectorAll (50): ${seqDur.toFixed(1)}ms`)
|
||||
|
||||
// Parallel resolution
|
||||
const parStart = performance.now()
|
||||
await Promise.all(selectors.map(sel => session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })))
|
||||
const parDur = performance.now() - parStart
|
||||
console.log(`Parallel querySelectorAll (50): ${parDur.toFixed(1)}ms`)
|
||||
console.log(`Speedup: ${(seqDur / parDur).toFixed(1)}x`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,470 @@
|
||||
// profile.ts - Profiling utilities for Imhotep hot paths
|
||||
// Measures compilation, extraction, solving, world creation, and frame resolution.
|
||||
// Injected dependencies: performance hooks, benchmark runner, budget checker.
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { runBenchmark, type BenchmarkOptions, type BenchmarkResult } from './benchmark.js'
|
||||
import { type CompilationCache, type ExtractionCache } from './cache.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile entry — single timed observation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProfileEntry {
|
||||
phase: string
|
||||
durationMs: number
|
||||
memoryDeltaBytes: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile session — collects multiple entries for a single benchmark run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ProfileSession {
|
||||
private entries: ProfileEntry[] = []
|
||||
private lastTime = 0
|
||||
private lastMemory = 0
|
||||
|
||||
constructor(private sessionName: string) {
|
||||
this.lastTime = performance.now()
|
||||
this.lastMemory = process.memoryUsage().heapUsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a named phase boundary.
|
||||
* Duration is measured from the previous mark() or session start.
|
||||
*/
|
||||
mark(phase: string, metadata?: Record<string, unknown>): ProfileEntry {
|
||||
const now = performance.now()
|
||||
const memNow = process.memoryUsage().heapUsed
|
||||
const entry: ProfileEntry = {
|
||||
phase,
|
||||
durationMs: now - this.lastTime,
|
||||
memoryDeltaBytes: memNow - this.lastMemory,
|
||||
metadata,
|
||||
}
|
||||
this.entries.push(entry)
|
||||
this.lastTime = now
|
||||
this.lastMemory = memNow
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all recorded entries.
|
||||
*/
|
||||
getEntries(): ProfileEntry[] {
|
||||
return this.entries.slice()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total duration across all phases.
|
||||
*/
|
||||
totalDurationMs(): number {
|
||||
return this.entries.reduce((sum, e) => sum + e.durationMs, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the entry for a specific phase, or undefined.
|
||||
*/
|
||||
findPhase(phase: string): ProfileEntry | undefined {
|
||||
return this.entries.find(e => e.phase === phase)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-path profilers — targeted wrappers around core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CompilationProfilerOptions {
|
||||
/** Number of assertions to compile */
|
||||
assertionCount: number
|
||||
/** Optional compilation cache injected by caller */
|
||||
compilationCache?: CompilationCache<unknown>
|
||||
/** Benchmark options (warmup runs, measurement runs, profile) */
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface ExtractionProfilerOptions {
|
||||
/** Number of subjects to extract */
|
||||
subjectCount: number
|
||||
/** Optional extraction cache injected by caller */
|
||||
extractionCache?: ExtractionCache<unknown>
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface SolvingProfilerOptions {
|
||||
/** Number of clauses to evaluate */
|
||||
clauseCount: number
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface WorldCreationProfilerOptions {
|
||||
/** Number of raw facts (subjects, boxes, etc.) to normalize */
|
||||
factCount: number
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface FrameResolutionProfilerOptions {
|
||||
/** Number of frames to resolve */
|
||||
frameCount: number
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile compilation: time to compile N assertions to Execution IR.
|
||||
* Returns a BenchmarkResult with mean/min/max/std-dev durations.
|
||||
*/
|
||||
export async function profileCompilation(
|
||||
compileFn: (assertions: unknown[]) => unknown,
|
||||
options: CompilationProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { assertionCount, benchmarkOptions } = options
|
||||
const assertions = generateMockAssertions(assertionCount)
|
||||
|
||||
return runBenchmark(
|
||||
`compile-${assertionCount}`,
|
||||
() => compileFn(assertions),
|
||||
{
|
||||
budgetName: 'compile',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile extraction: time to extract geometry from a page.
|
||||
*/
|
||||
export async function profileExtraction(
|
||||
extractFn: (subjects: unknown[]) => unknown,
|
||||
options: ExtractionProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { subjectCount, benchmarkOptions } = options
|
||||
const subjects = generateMockSubjects(subjectCount)
|
||||
|
||||
return runBenchmark(
|
||||
`extract-${subjectCount}`,
|
||||
() => extractFn(subjects),
|
||||
{
|
||||
budgetName: 'extract',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile solving: time to evaluate M clauses against a world.
|
||||
*/
|
||||
export async function profileSolving(
|
||||
solveFn: (clauses: unknown[], world: unknown) => unknown,
|
||||
options: SolvingProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { clauseCount, benchmarkOptions } = options
|
||||
const clauses = generateMockClauses(clauseCount)
|
||||
const world = generateMockWorld()
|
||||
|
||||
return runBenchmark(
|
||||
`solve-${clauseCount}`,
|
||||
() => solveFn(clauses, world),
|
||||
{
|
||||
budgetName: 'solve',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile world creation: time to normalize raw facts into a world.
|
||||
*/
|
||||
export async function profileWorldCreation(
|
||||
normalizeFn: (raw: unknown) => unknown,
|
||||
options: WorldCreationProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { factCount, benchmarkOptions } = options
|
||||
const raw = generateMockRawExtraction(factCount)
|
||||
|
||||
return runBenchmark(
|
||||
`world-create-${factCount}`,
|
||||
() => normalizeFn(raw),
|
||||
{
|
||||
budgetName: 'extract',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile frame resolution: time to resolve transforms and coordinates.
|
||||
*/
|
||||
export async function profileFrameResolution(
|
||||
resolveFn: (frames: unknown[]) => unknown,
|
||||
options: FrameResolutionProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { frameCount, benchmarkOptions } = options
|
||||
const frames = generateMockFrames(frameCount)
|
||||
|
||||
return runBenchmark(
|
||||
`frame-resolve-${frameCount}`,
|
||||
() => resolveFn(frames),
|
||||
{
|
||||
budgetName: 'solve',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators — deterministic, lightweight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateMockAssertions(n: number): unknown[] {
|
||||
const assertions: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
assertions.push({
|
||||
type: 'RelationAssertion',
|
||||
relation: i % 2 === 0 ? 'leftOf' : 'above',
|
||||
subject: { type: 'SelectorRef', value: `.s-${i}` },
|
||||
reference: { type: 'SelectorRef', value: `.r-${i}` },
|
||||
options: { type: 'RelationOptions' },
|
||||
})
|
||||
}
|
||||
return assertions
|
||||
}
|
||||
|
||||
function generateMockSubjects(n: number): unknown[] {
|
||||
const subjects: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
subjects.push({
|
||||
id: i,
|
||||
selector: `.subj-${i}`,
|
||||
kind: 'element',
|
||||
})
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
function generateMockClauses(n: number): unknown[] {
|
||||
const clauses: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
clauses.push({
|
||||
clauseId: `clause-${i}`,
|
||||
clauseKind: i % 2 === 0 ? 'relation.leftOf' : 'relation.above',
|
||||
version: 1,
|
||||
subjectRef: i,
|
||||
referenceRef: i + 1,
|
||||
})
|
||||
}
|
||||
return clauses
|
||||
}
|
||||
|
||||
function generateMockWorld(): unknown {
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snapshot_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(0),
|
||||
domNodeId: new Uint32Array(0),
|
||||
subjectKind: new Uint16Array(0),
|
||||
primaryBoxId: new Uint32Array(0),
|
||||
firstFragmentId: new Uint32Array(0),
|
||||
fragmentCount: new Uint16Array(0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
frameId: new Uint32Array(0),
|
||||
borderLeft: new Float64Array(0),
|
||||
borderTop: new Float64Array(0),
|
||||
borderRight: new Float64Array(0),
|
||||
borderBottom: new Float64Array(0),
|
||||
paddingLeft: new Float64Array(0),
|
||||
paddingTop: new Float64Array(0),
|
||||
paddingRight: new Float64Array(0),
|
||||
paddingBottom: new Float64Array(0),
|
||||
contentLeft: new Float64Array(0),
|
||||
contentTop: new Float64Array(0),
|
||||
contentRight: new Float64Array(0),
|
||||
contentBottom: new Float64Array(0),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(0),
|
||||
nearestPositionedAncestorOf: new Uint32Array(0),
|
||||
scrollContainerOf: new Uint32Array(0),
|
||||
stackingContextOf: new Uint32Array(0),
|
||||
formattingContextOf: new Uint32Array(0),
|
||||
clippingRootOf: new Uint32Array(0),
|
||||
paintOrderBucket: new Uint16Array(0),
|
||||
paintOrderIndex: new Uint32Array(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockRawExtraction(factCount: number): unknown {
|
||||
const subjects: unknown[] = []
|
||||
const boxes: unknown[] = []
|
||||
for (let i = 0; i < factCount; i++) {
|
||||
subjects.push({
|
||||
id: i,
|
||||
domNodeId: i,
|
||||
kind: 'element',
|
||||
primaryBoxId: i,
|
||||
firstFragmentId: 0,
|
||||
fragmentCount: 0,
|
||||
firstTextRunId: 0,
|
||||
textRunCount: 0,
|
||||
})
|
||||
boxes.push({
|
||||
id: i,
|
||||
subjectId: i,
|
||||
frameId: 0,
|
||||
borderLeft: 0,
|
||||
borderTop: 0,
|
||||
borderRight: 10,
|
||||
borderBottom: 10,
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
contentLeft: 0,
|
||||
contentTop: 0,
|
||||
contentRight: 10,
|
||||
contentBottom: 10,
|
||||
})
|
||||
}
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snapshot_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: 'none',
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: 'http://localhost/',
|
||||
browserName: 'chromium',
|
||||
browserVersion: '120',
|
||||
engine: 'blink',
|
||||
extractedAt: Date.now(),
|
||||
},
|
||||
subjects,
|
||||
dom: [],
|
||||
frames: [],
|
||||
rects: [],
|
||||
boxes,
|
||||
fragments: [],
|
||||
transforms: [],
|
||||
styles: [],
|
||||
text: [],
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
paint: [],
|
||||
visibility: [],
|
||||
provenance: [],
|
||||
confidence: [],
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockFrames(n: number): unknown[] {
|
||||
const frames: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
frames.push({
|
||||
id: i,
|
||||
kind: 'viewport',
|
||||
ownerSubjectId: i,
|
||||
parentFrameId: -1,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
matrix: i % 3 === 0 ? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] : undefined,
|
||||
clipRectId: 0,
|
||||
scrollContainerId: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comparison helpers — detect regressions between two benchmark results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegressionResult {
|
||||
phase: string
|
||||
baselineMs: number
|
||||
currentMs: number
|
||||
deltaMs: number
|
||||
deltaPercent: number
|
||||
regressed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two benchmark results and flag regressions.
|
||||
* A regression is defined as current being > thresholdPercent slower than baseline.
|
||||
*/
|
||||
export function detectRegression(
|
||||
baseline: BenchmarkResult,
|
||||
current: BenchmarkResult,
|
||||
thresholdPercent = 10
|
||||
): RegressionResult {
|
||||
const deltaMs = current.meanDurationMs - baseline.meanDurationMs
|
||||
const deltaPercent = baseline.meanDurationMs > 0
|
||||
? (deltaMs / baseline.meanDurationMs) * 100
|
||||
: 0
|
||||
return {
|
||||
phase: current.name,
|
||||
baselineMs: baseline.meanDurationMs,
|
||||
currentMs: current.meanDurationMs,
|
||||
deltaMs,
|
||||
deltaPercent,
|
||||
regressed: deltaPercent > thresholdPercent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a profile report as plain text (no markdown per user preference).
|
||||
*/
|
||||
export function formatProfileReport(results: BenchmarkResult[]): string {
|
||||
const lines: string[] = []
|
||||
lines.push('Performance Profile Report')
|
||||
lines.push('='.repeat(40))
|
||||
for (const r of results) {
|
||||
lines.push(`\nBenchmark: ${r.name}`)
|
||||
lines.push(` Mean: ${r.meanDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` Min: ${r.minDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` Max: ${r.maxDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` StdDev: ${r.stdDevDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` Memory: ${r.meanMemoryDeltaBytes.toFixed(0)} bytes`)
|
||||
if (r.budgetCheck) {
|
||||
lines.push(` Budget: ${r.budgetCheck.budgetName} ${r.budgetCheck.passed ? 'PASS' : 'FAIL'}`)
|
||||
}
|
||||
}
|
||||
lines.push('\n' + '='.repeat(40))
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// profiles.ts - Performance profiles and budgets for Imhotep benchmark harness
|
||||
// Defines budgets for compilation, extraction, solving, and end-to-end runs.
|
||||
|
||||
export interface PerformanceBudget {
|
||||
/** Human-readable budget name */
|
||||
name: string
|
||||
/** Maximum allowed duration in milliseconds */
|
||||
maxDurationMs: number
|
||||
/** Optional maximum memory usage in bytes */
|
||||
maxMemoryBytes?: number
|
||||
}
|
||||
|
||||
export interface PerformanceProfile {
|
||||
/** Profile identifier */
|
||||
name: string
|
||||
/** Budgets active in this profile */
|
||||
budgets: PerformanceBudget[]
|
||||
}
|
||||
|
||||
/** Budget presets keyed by profile name */
|
||||
export const DEFAULT_PROFILES: Record<string, PerformanceProfile> = {
|
||||
dev: {
|
||||
name: 'dev',
|
||||
budgets: [
|
||||
{ name: 'compile', maxDurationMs: 50 },
|
||||
{ name: 'extract', maxDurationMs: 200 },
|
||||
{ name: 'solve', maxDurationMs: 100 },
|
||||
{ name: 'e2e', maxDurationMs: 1000 },
|
||||
],
|
||||
},
|
||||
ci: {
|
||||
name: 'ci',
|
||||
budgets: [
|
||||
{ name: 'compile', maxDurationMs: 30 },
|
||||
{ name: 'extract', maxDurationMs: 150 },
|
||||
{ name: 'solve', maxDurationMs: 80 },
|
||||
{ name: 'e2e', maxDurationMs: 800 },
|
||||
],
|
||||
},
|
||||
benchmark: {
|
||||
name: 'benchmark',
|
||||
budgets: [
|
||||
{ name: 'compile', maxDurationMs: 20 },
|
||||
{ name: 'extract', maxDurationMs: 100 },
|
||||
{ name: 'solve', maxDurationMs: 50 },
|
||||
{ name: 'e2e', maxDurationMs: 500 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/** Result of a single budget check */
|
||||
export interface BudgetCheck {
|
||||
budgetName: string
|
||||
passed: boolean
|
||||
actualDurationMs: number
|
||||
actualMemoryBytes?: number
|
||||
maxDurationMs: number
|
||||
maxMemoryBytes?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a measurement fits within a budget.
|
||||
* Returns a BudgetCheck with the pass/fail result.
|
||||
*/
|
||||
export function checkBudget(
|
||||
budgetName: string,
|
||||
actualDurationMs: number,
|
||||
actualMemoryBytes?: number,
|
||||
profile: PerformanceProfile = DEFAULT_PROFILES.dev
|
||||
): BudgetCheck {
|
||||
const budget = profile.budgets.find(b => b.name === budgetName)
|
||||
if (!budget) {
|
||||
return {
|
||||
budgetName,
|
||||
passed: true,
|
||||
actualDurationMs,
|
||||
actualMemoryBytes,
|
||||
maxDurationMs: Infinity,
|
||||
maxMemoryBytes: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const passedDuration = actualDurationMs <= budget.maxDurationMs
|
||||
const passedMemory =
|
||||
budget.maxMemoryBytes === undefined ||
|
||||
actualMemoryBytes === undefined ||
|
||||
actualMemoryBytes <= budget.maxMemoryBytes
|
||||
|
||||
return {
|
||||
budgetName,
|
||||
passed: passedDuration && passedMemory,
|
||||
actualDurationMs,
|
||||
actualMemoryBytes,
|
||||
maxDurationMs: budget.maxDurationMs,
|
||||
maxMemoryBytes: budget.maxMemoryBytes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all budgets in a profile against a map of measurements.
|
||||
*/
|
||||
export function checkAllBudgets(
|
||||
measurements: Record<string, { durationMs: number; memoryBytes?: number }>,
|
||||
profile: PerformanceProfile = DEFAULT_PROFILES.dev
|
||||
): BudgetCheck[] {
|
||||
const results: BudgetCheck[] = []
|
||||
for (const [name, measurement] of Object.entries(measurements)) {
|
||||
results.push(
|
||||
checkBudget(name, measurement.durationMs, measurement.memoryBytes, profile)
|
||||
)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// property-benchmark.ts - Benchmark sampled property runs.
|
||||
// Measures enumerated combinations, fast-check generated runs, and shrink performance.
|
||||
|
||||
import {
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
type BenchmarkResult,
|
||||
} from './benchmark.js';
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enumerated prop combinations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ButtonProps {
|
||||
size: 'sm' | 'md' | 'lg';
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function* enumerateProps(count: number): Generator<ButtonProps> {
|
||||
const sizes: Array<'sm' | 'md' | 'lg'> = ['sm', 'md', 'lg'];
|
||||
const disableds = [false, true];
|
||||
const labels = ['OK', 'Cancel', 'Submit', 'Delete'];
|
||||
|
||||
let n = 0;
|
||||
for (const size of sizes) {
|
||||
for (const disabled of disableds) {
|
||||
for (const label of labels) {
|
||||
if (n >= count) return;
|
||||
yield { size, disabled, label };
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateEnumeratedCases(count: number): ButtonProps[] {
|
||||
return Array.from(enumerateProps(count));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fast-check arbitrary for ButtonProps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buttonPropsArb = fc.record<ButtonProps>({
|
||||
size: fc.constantFrom('sm', 'md', 'lg'),
|
||||
disabled: fc.boolean(),
|
||||
label: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property assertion (simulated layout check)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assertButtonLayout(props: ButtonProps): boolean {
|
||||
// Simulated: buttons are always at least 44px wide regardless of props
|
||||
const minWidths = { sm: 60, md: 80, lg: 100 };
|
||||
return minWidths[props.size] >= 44;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runPropertyBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const suite = new BenchmarkSuite('property-runs', {
|
||||
defaults: { warmupRuns: 1, measurementRuns: 5 }
|
||||
});
|
||||
|
||||
// 100 enumerated prop combinations
|
||||
suite.add('enumerated-100-combinations', () => {
|
||||
const cases = generateEnumeratedCases(100);
|
||||
for (const props of cases) {
|
||||
assertButtonLayout(props);
|
||||
}
|
||||
});
|
||||
|
||||
// 100 fast-check generated runs
|
||||
suite.add('sampled-100-fc-runs', () => {
|
||||
fc.assert(
|
||||
fc.property(buttonPropsArb, (props) => {
|
||||
return assertButtonLayout(props);
|
||||
}),
|
||||
{ numRuns: 100, seed: 42 }
|
||||
);
|
||||
});
|
||||
|
||||
// Shrink performance: how many steps to minimal counterexample
|
||||
suite.add('shrink-to-minimal-counterexample', () => {
|
||||
let shrinkSteps = 0;
|
||||
try {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
size: fc.constantFrom('sm', 'md', 'lg'),
|
||||
disabled: fc.boolean(),
|
||||
label: fc.string({ minLength: 1, maxLength: 40 }),
|
||||
}),
|
||||
(props) => {
|
||||
// Artificial failure: any label longer than 30 chars fails
|
||||
return props.label.length <= 30;
|
||||
}
|
||||
),
|
||||
{
|
||||
numRuns: 200,
|
||||
seed: 123,
|
||||
verbose: 0,
|
||||
}
|
||||
);
|
||||
} catch (e: any) {
|
||||
// Fast-check errors contain counterexample info
|
||||
if (e.counterExamplePath) {
|
||||
// Path depth approximates shrink steps
|
||||
shrinkSteps = e.counterExamplePath.split(':').length;
|
||||
}
|
||||
}
|
||||
return shrinkSteps;
|
||||
});
|
||||
|
||||
const result = await suite.runSequential();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runPropertyBenchmarks().then((results) => {
|
||||
console.log('\n=== Property Benchmark Results ===\n');
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}:`);
|
||||
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
|
||||
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
|
||||
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
|
||||
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
|
||||
if (r.name === 'shrink-to-minimal-counterexample') {
|
||||
// Report last run result as shrink steps
|
||||
const lastRun = r.runs[r.runs.length - 1];
|
||||
console.log(` shrink steps (last run): ${lastRun ? (lastRun as any).result || 'N/A' : 'N/A'}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// public-api-benchmark.ts - Micro-benchmark for the public relation evaluation path.
|
||||
// Measures the simplified vertical slice: world construction + clause evaluation.
|
||||
// Target: under 100ms for a simple two-element assertion.
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { runBenchmark, type BenchmarkResult } from './benchmark.js'
|
||||
import { evaluateLeftOf } from 'imhotep-solver'
|
||||
import type { GeometryWorld } from 'imhotep-solver'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal world builder (mirrors the path in public.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTwoElementWorld(gapPx: number): GeometryWorld {
|
||||
const s = { x: 0, y: 0, width: 50, height: 50 }
|
||||
const r = { x: 50 + gapPx, y: 0, width: 50, height: 50 }
|
||||
|
||||
return {
|
||||
sceneId: 'bench',
|
||||
snapshotId: 'snap',
|
||||
env: {
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 600,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: [0, 1],
|
||||
domNodeId: [0, 1],
|
||||
subjectKind: [0, 0],
|
||||
primaryBoxId: [0, 1],
|
||||
firstFragmentId: [0, 0],
|
||||
fragmentCount: [0, 0],
|
||||
},
|
||||
dom: {
|
||||
nodeId: [],
|
||||
parentNodeId: [],
|
||||
childCount: [],
|
||||
tagNameStringId: [],
|
||||
},
|
||||
boxes: {
|
||||
boxId: [0, 1],
|
||||
subjectId: [0, 1],
|
||||
frameId: [0, 0],
|
||||
borderLeft: [s.x, r.x],
|
||||
borderTop: [s.y, r.y],
|
||||
borderRight: [s.x + s.width, r.x + r.width],
|
||||
borderBottom: [s.y + s.height, r.y + r.height],
|
||||
paddingLeft: [0, 0],
|
||||
paddingTop: [0, 0],
|
||||
paddingRight: [0, 0],
|
||||
paddingBottom: [0, 0],
|
||||
contentLeft: [s.x, r.x],
|
||||
contentTop: [s.y, r.y],
|
||||
contentRight: [s.x + s.width, r.x + r.width],
|
||||
contentBottom: [s.y + s.height, r.y + r.height],
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: [0, 1],
|
||||
subjectId: [0, 1],
|
||||
frameId: [0, 0],
|
||||
borderLeft: [s.x, r.x],
|
||||
borderTop: [s.y, r.y],
|
||||
borderRight: [s.x + s.width, r.x + r.width],
|
||||
borderBottom: [s.y + s.height, r.y + r.height],
|
||||
paddingLeft: [0, 0],
|
||||
paddingTop: [0, 0],
|
||||
paddingRight: [0, 0],
|
||||
paddingBottom: [0, 0],
|
||||
contentLeft: [s.x, r.x],
|
||||
contentTop: [s.y, r.y],
|
||||
contentRight: [s.x + s.width, r.x + r.width],
|
||||
contentBottom: [s.y + s.height, r.y + r.height],
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
matrices: {
|
||||
values: [],
|
||||
},
|
||||
rects: {
|
||||
rectId: [0, 1],
|
||||
left: [s.x, r.x],
|
||||
top: [s.y, r.y],
|
||||
right: [s.x + s.width, r.x + r.width],
|
||||
bottom: [s.y + s.height, r.y + r.height],
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: {
|
||||
containerId: [],
|
||||
scrollLeft: [],
|
||||
scrollTop: [],
|
||||
scrollWidth: [],
|
||||
scrollHeight: [],
|
||||
clientWidth: [],
|
||||
clientHeight: [],
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: [],
|
||||
subjectId: [],
|
||||
clipKind: [],
|
||||
clipLeft: [],
|
||||
clipTop: [],
|
||||
clipRight: [],
|
||||
clipBottom: [],
|
||||
parentClipNodeId: [],
|
||||
},
|
||||
visibility: {
|
||||
subjectId: [],
|
||||
isRendered: [],
|
||||
isVisible: [],
|
||||
visibleArea: [],
|
||||
clippedArea: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runPublicApiBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const results: BenchmarkResult[] = []
|
||||
|
||||
// Benchmark: two-element leftOf evaluation with 10px gap and minGap: 8
|
||||
const world = buildTwoElementWorld(10)
|
||||
const clause = {
|
||||
clauseId: 'bench_leftof',
|
||||
clauseKind: 'relation.leftOf',
|
||||
version: 1,
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
bounds: { minGap: 8 },
|
||||
}
|
||||
|
||||
const r = await runBenchmark(
|
||||
'public-api-leftOf-2element',
|
||||
() => {
|
||||
evaluateLeftOf(world, clause)
|
||||
},
|
||||
{
|
||||
warmupRuns: 100,
|
||||
measurementRuns: 1000,
|
||||
}
|
||||
)
|
||||
|
||||
results.push(r)
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// render-target-benchmark.ts - Benchmark renderer adapter mounting.
|
||||
// Measures scene target creation and resolution overhead.
|
||||
|
||||
import {
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
type BenchmarkResult,
|
||||
} from './benchmark.js';
|
||||
|
||||
import {
|
||||
pageTarget,
|
||||
storybookStoryTarget,
|
||||
reactComponentTarget,
|
||||
vueComponentTarget,
|
||||
fixtureTarget,
|
||||
matchSceneTarget,
|
||||
type SceneTarget,
|
||||
} from 'imhotep-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulated adapter resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveTargetUrl(target: SceneTarget): string {
|
||||
return matchSceneTarget(target, {
|
||||
page: (url) => url,
|
||||
'playwright-page': (pageRef, url) => url || `playwright://${pageRef}`,
|
||||
'storybook-story': (storyId, storybookUrl) => `${storybookUrl}/iframe.html?id=${storyId}`,
|
||||
'react-component': (rendererId, componentId) =>
|
||||
`component://react/${rendererId}/${componentId}`,
|
||||
'vue-component': (rendererId, componentId) =>
|
||||
`component://vue/${rendererId}/${componentId}`,
|
||||
'custom-renderer': (rendererId, targetId) =>
|
||||
`component://custom/${rendererId}/${targetId}`,
|
||||
fixture: (fixtureId) => `file:///fixtures/${fixtureId}.html`,
|
||||
});
|
||||
}
|
||||
|
||||
function mountComponent(target: SceneTarget): { targetId: string; url: string } {
|
||||
const url = resolveTargetUrl(target);
|
||||
const targetId = `mount_${Math.random().toString(36).slice(2, 8)}`;
|
||||
// Simulate mount overhead
|
||||
const overhead = Array.from({ length: 100 }, (_, i) => i * 2).reduce((a, b) => a + b, 0);
|
||||
return { targetId, url };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runRenderTargetBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const suite = new BenchmarkSuite('render-target-mount', {
|
||||
defaults: { warmupRuns: 2, measurementRuns: 5 }
|
||||
});
|
||||
|
||||
// React mount time
|
||||
suite.add('react-mount', () => {
|
||||
const target = reactComponentTarget('react-dom', 'Button');
|
||||
mountComponent(target);
|
||||
});
|
||||
|
||||
// Vue mount time
|
||||
suite.add('vue-mount', () => {
|
||||
const target = vueComponentTarget('vue-dom', 'Button');
|
||||
mountComponent(target);
|
||||
});
|
||||
|
||||
// Storybook story load time
|
||||
suite.add('storybook-load', () => {
|
||||
const target = storybookStoryTarget('button--primary', 'http://localhost:6006');
|
||||
mountComponent(target);
|
||||
});
|
||||
|
||||
// Page target resolution
|
||||
suite.add('page-target-resolve', () => {
|
||||
const target = pageTarget('https://example.com/page');
|
||||
resolveTargetUrl(target);
|
||||
});
|
||||
|
||||
// Fixture target resolution
|
||||
suite.add('fixture-target-resolve', () => {
|
||||
const target = fixtureTarget('property-render-react');
|
||||
resolveTargetUrl(target);
|
||||
});
|
||||
|
||||
// Mixed renderer batch
|
||||
suite.add('mixed-renderer-batch-10', () => {
|
||||
const targets: SceneTarget[] = [
|
||||
reactComponentTarget('react-dom', 'Button'),
|
||||
vueComponentTarget('vue-dom', 'Button'),
|
||||
storybookStoryTarget('button--primary', 'http://localhost:6006'),
|
||||
pageTarget('https://example.com/page'),
|
||||
fixtureTarget('property-render-react'),
|
||||
reactComponentTarget('react-dom', 'Card'),
|
||||
vueComponentTarget('vue-dom', 'Card'),
|
||||
storybookStoryTarget('card--default', 'http://localhost:6006'),
|
||||
pageTarget('https://example.com/other'),
|
||||
fixtureTarget('property-render-vue'),
|
||||
];
|
||||
for (const t of targets) {
|
||||
mountComponent(t);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await suite.runSequential();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runRenderTargetBenchmarks().then((results) => {
|
||||
console.log('\n=== Render Target Benchmark Results ===\n');
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}:`);
|
||||
console.log(` mean: ${r.meanDurationMs.toFixed(3)}ms`);
|
||||
console.log(` min: ${r.minDurationMs.toFixed(3)}ms`);
|
||||
console.log(` max: ${r.maxDurationMs.toFixed(3)}ms`);
|
||||
console.log(` memory: ${r.meanMemoryDeltaBytes.toFixed(0)}B`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
} from 'imhotep-solver'
|
||||
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
registerDefaultPredicates()
|
||||
|
||||
function buildWorld(n) {
|
||||
const canonical = {
|
||||
sceneId: 's',
|
||||
snapshotId: 'sn',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
|
||||
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
|
||||
},
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
},
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
}
|
||||
|
||||
return adaptCanonicalWorldToSolver(canonical)
|
||||
}
|
||||
|
||||
class SimpleResolver {
|
||||
constructor() {
|
||||
this.domains = new Map()
|
||||
}
|
||||
register(selector, ids) {
|
||||
this.domains.set(selector, {
|
||||
domainId: `dom_${selector}`,
|
||||
subjectIds: new Uint32Array(ids),
|
||||
provenance: `elements(${selector})`,
|
||||
closed: true,
|
||||
})
|
||||
}
|
||||
resolve(domain) {
|
||||
return this.domains.get(domain.selector ?? domain.domain)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Evaluation With/Without Trace ===\n')
|
||||
|
||||
for (const n of [10, 50, 100]) {
|
||||
const world = buildWorld(n)
|
||||
const resolver = new SimpleResolver()
|
||||
resolver.register('.a', Array.from({length: n}, (_, i) => i))
|
||||
resolver.register('.b', Array.from({length: n}, (_, i) => i))
|
||||
|
||||
const formula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$subject'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$reference'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: '$subject' },
|
||||
{ type: 'VariableRef', name: '$reference' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timesWithTrace = []
|
||||
const timesWithoutTrace = []
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start1 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: true } })
|
||||
timesWithTrace.push(performance.now() - start1)
|
||||
|
||||
const start2 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: false } })
|
||||
timesWithoutTrace.push(performance.now() - start2)
|
||||
}
|
||||
|
||||
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
|
||||
const withTrace = mean(timesWithTrace)
|
||||
const withoutTrace = mean(timesWithoutTrace)
|
||||
|
||||
console.log(`${n}×${n} pairs:`)
|
||||
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
|
||||
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
|
||||
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const sizes = [1000, 5000, 10000, 50000]
|
||||
|
||||
console.log('=== TypedArray to Array Conversion Benchmark ===\n')
|
||||
|
||||
for (const size of sizes) {
|
||||
const arr = new Float64Array(Array.from({length: size}, (_, i) => i))
|
||||
|
||||
const times = {
|
||||
arrayFrom: [],
|
||||
spread: [],
|
||||
slice: [],
|
||||
bufferSlice: [],
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
let start = performance.now()
|
||||
Array.from(arr)
|
||||
times.arrayFrom.push(performance.now() - start)
|
||||
|
||||
start = performance.now()
|
||||
;[...arr]
|
||||
times.spread.push(performance.now() - start)
|
||||
|
||||
start = performance.now()
|
||||
Array.prototype.slice.call(arr)
|
||||
times.slice.push(performance.now() - start)
|
||||
|
||||
start = performance.now()
|
||||
const buffer = arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
|
||||
new Float64Array(buffer)
|
||||
times.bufferSlice.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
|
||||
console.log(`${size} elements:`)
|
||||
console.log(` Array.from: ${mean(times.arrayFrom).toFixed(3)}ms`)
|
||||
console.log(` [...spread]: ${mean(times.spread).toFixed(3)}ms`)
|
||||
console.log(` slice.call: ${mean(times.slice).toFixed(3)}ms`)
|
||||
console.log(` buffer.slice: ${mean(times.bufferSlice).toFixed(3)}ms`)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": false,
|
||||
"paths": {},
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "imhotep-cdp",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"imhotep-core": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Canonical Adapter
|
||||
*
|
||||
* Converts raw CDP extraction output into the canonical GeometryWorld
|
||||
* shape defined by imhotep-core. This adapter isolates CDP-specific
|
||||
* structures from the canonical representation.
|
||||
*/
|
||||
|
||||
import type { GeometryWorldSnapshot } from './extractor.js'
|
||||
import type { DOMExtractionResult } from './dom.js'
|
||||
|
||||
// We define local canonical interfaces to avoid cross-package import
|
||||
// failures when imhotep-core dist is stale. These mirror the core
|
||||
// canonical contracts exactly.
|
||||
|
||||
export interface CanonicalWorldEnvironment {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: string
|
||||
pointer: string
|
||||
hover: boolean
|
||||
reducedMotion: string
|
||||
locale: string
|
||||
writingMode: string
|
||||
}
|
||||
|
||||
export interface CanonicalWorldSource {
|
||||
url: string
|
||||
browserName: string
|
||||
browserVersion: string
|
||||
engine: 'chromium-cdp'
|
||||
extractedAt: number
|
||||
}
|
||||
|
||||
export interface CanonicalStringTable {
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface CanonicalSubjectsTable {
|
||||
ids: number[]
|
||||
domNodeId: number[]
|
||||
subjectKind: number[]
|
||||
primaryBoxId: number[]
|
||||
firstFragmentId: number[]
|
||||
fragmentCount: number[]
|
||||
firstTextRunId: number[]
|
||||
textRunCount: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalDomTable {
|
||||
nodeId: number[]
|
||||
backendNodeId: number[]
|
||||
parentNodeId: number[]
|
||||
firstChildIndex: number[]
|
||||
childCount: number[]
|
||||
shadowRootKind: number[]
|
||||
tagNameStringId: number[]
|
||||
roleStringId: number[]
|
||||
ariaNameStringId: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalFramesTable {
|
||||
frameId: number[]
|
||||
frameKind: number[]
|
||||
ownerSubjectId: number[]
|
||||
parentFrameId: number[]
|
||||
originX: number[]
|
||||
originY: number[]
|
||||
axisMatrixStart: number[]
|
||||
clipRectId: number[]
|
||||
scrollContainerId: number[]
|
||||
writingMode: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalMatricesTable {
|
||||
values: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalRectsTable {
|
||||
rectId: number[]
|
||||
left: number[]
|
||||
top: number[]
|
||||
right: number[]
|
||||
bottom: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalBoxesTable {
|
||||
boxId: number[]
|
||||
subjectId: number[]
|
||||
frameId: number[]
|
||||
borderLeft: number[]
|
||||
borderTop: number[]
|
||||
borderRight: number[]
|
||||
borderBottom: number[]
|
||||
paddingLeft: number[]
|
||||
paddingTop: number[]
|
||||
paddingRight: number[]
|
||||
paddingBottom: number[]
|
||||
contentLeft: number[]
|
||||
contentTop: number[]
|
||||
contentRight: number[]
|
||||
contentBottom: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalFragmentsTable {
|
||||
fragmentId: number[]
|
||||
subjectId: number[]
|
||||
fragmentKind: number[]
|
||||
boxLeft: number[]
|
||||
boxTop: number[]
|
||||
boxRight: number[]
|
||||
boxBottom: number[]
|
||||
lineIndex: number[]
|
||||
flowIndex: number[]
|
||||
parentFragmentId: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalTransformsTable {
|
||||
transformId: number[]
|
||||
subjectId: number[]
|
||||
matrixStart: number[]
|
||||
matrixLength: number[]
|
||||
originX: number[]
|
||||
originY: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalStylesTable {
|
||||
subjectId: number[]
|
||||
display: number[]
|
||||
position: number[]
|
||||
zIndexKind: number[]
|
||||
zIndexValue: number[]
|
||||
overflowX: number[]
|
||||
overflowY: number[]
|
||||
opacity: number[]
|
||||
visibility: number[]
|
||||
containFlags: number[]
|
||||
pointerEvents: number[]
|
||||
lineHeight: number[]
|
||||
fontFamilyStringId: number[]
|
||||
fontSize: number[]
|
||||
fontWeight: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalTextTable {
|
||||
runId: number[]
|
||||
subjectId: number[]
|
||||
contentStringId: number[]
|
||||
lineBoxId: number[]
|
||||
inkLeft: number[]
|
||||
inkTop: number[]
|
||||
inkRight: number[]
|
||||
inkBottom: number[]
|
||||
baselineY: number[]
|
||||
capHeight: number[]
|
||||
computedLineHeight: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalTopologyTable {
|
||||
containingBlockOf: number[]
|
||||
nearestPositionedAncestorOf: number[]
|
||||
scrollContainerOf: number[]
|
||||
stackingContextOf: number[]
|
||||
formattingContextOf: number[]
|
||||
clippingRootOf: number[]
|
||||
paintOrderBucket: number[]
|
||||
paintOrderIndex: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalScrollTable {
|
||||
containerId: number[]
|
||||
scrollLeft: number[]
|
||||
scrollTop: number[]
|
||||
scrollWidth: number[]
|
||||
scrollHeight: number[]
|
||||
clientWidth: number[]
|
||||
clientHeight: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalClippingTable {
|
||||
clipNodeId: number[]
|
||||
subjectId: number[]
|
||||
clipKind: number[]
|
||||
clipLeft: number[]
|
||||
clipTop: number[]
|
||||
clipRight: number[]
|
||||
clipBottom: number[]
|
||||
parentClipNodeId: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalPaintTable {
|
||||
paintNodeId: number[]
|
||||
subjectId: number[]
|
||||
stackingContextId: number[]
|
||||
bucket: number[]
|
||||
localPaintIndex: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalVisibilityTable {
|
||||
subjectId: number[]
|
||||
isRendered: number[]
|
||||
isVisible: number[]
|
||||
visibleArea: number[]
|
||||
clippedArea: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalProvenanceTable {
|
||||
factId: number[]
|
||||
extractionStepId: number[]
|
||||
sourceKind: number[]
|
||||
sourceRef: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalConfidenceTable {
|
||||
factId: number[]
|
||||
confidence: number[]
|
||||
reasonCode: number[]
|
||||
}
|
||||
|
||||
export interface CanonicalGeometryWorld {
|
||||
sceneId: string
|
||||
snapshotId: string
|
||||
env: CanonicalWorldEnvironment
|
||||
source: CanonicalWorldSource
|
||||
strings: CanonicalStringTable
|
||||
subjects: CanonicalSubjectsTable
|
||||
dom: CanonicalDomTable
|
||||
frames: CanonicalFramesTable
|
||||
matrices: CanonicalMatricesTable
|
||||
rects: CanonicalRectsTable
|
||||
boxes: CanonicalBoxesTable
|
||||
visualBoxes: CanonicalBoxesTable
|
||||
fragments: CanonicalFragmentsTable
|
||||
transforms: CanonicalTransformsTable
|
||||
styles: CanonicalStylesTable
|
||||
text: CanonicalTextTable
|
||||
topology: CanonicalTopologyTable
|
||||
scroll: CanonicalScrollTable
|
||||
clipping: CanonicalClippingTable
|
||||
paint: CanonicalPaintTable
|
||||
visibility: CanonicalVisibilityTable
|
||||
provenance: CanonicalProvenanceTable
|
||||
confidence: CanonicalConfidenceTable
|
||||
}
|
||||
|
||||
function adaptDom(dom: DOMExtractionResult): CanonicalDomTable {
|
||||
const nodeId: number[] = []
|
||||
const backendNodeId: number[] = []
|
||||
const parentNodeId: number[] = []
|
||||
const firstChildIndex: number[] = []
|
||||
const childCount: number[] = []
|
||||
const shadowRootKind: number[] = []
|
||||
const tagNameStringId: number[] = []
|
||||
const roleStringId: number[] = []
|
||||
const ariaNameStringId: number[] = []
|
||||
|
||||
for (const node of dom.nodes) {
|
||||
nodeId.push(node.nodeId)
|
||||
backendNodeId.push(node.backendNodeId)
|
||||
parentNodeId.push(node.parentNodeId)
|
||||
firstChildIndex.push(node.firstChildIndex)
|
||||
childCount.push(node.childCount)
|
||||
shadowRootKind.push(node.shadowRootKind === 'open' ? 1 : node.shadowRootKind === 'closed' ? 2 : 0)
|
||||
tagNameStringId.push(node.tagNameStringId)
|
||||
roleStringId.push(node.roleStringId)
|
||||
ariaNameStringId.push(node.ariaNameStringId)
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
backendNodeId,
|
||||
parentNodeId,
|
||||
firstChildIndex,
|
||||
childCount,
|
||||
shadowRootKind,
|
||||
tagNameStringId,
|
||||
roleStringId,
|
||||
ariaNameStringId,
|
||||
}
|
||||
}
|
||||
|
||||
function adaptStringTable(strings: string[]): CanonicalStringTable {
|
||||
return { values: strings }
|
||||
}
|
||||
|
||||
function adaptTimestamp(iso: string): number {
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
function emptyTextTable(): CanonicalTextTable {
|
||||
return {
|
||||
runId: [],
|
||||
subjectId: [],
|
||||
contentStringId: [],
|
||||
lineBoxId: [],
|
||||
inkLeft: [],
|
||||
inkTop: [],
|
||||
inkRight: [],
|
||||
inkBottom: [],
|
||||
baselineY: [],
|
||||
capHeight: [],
|
||||
computedLineHeight: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPaintTable(): CanonicalPaintTable {
|
||||
return {
|
||||
paintNodeId: [],
|
||||
subjectId: [],
|
||||
stackingContextId: [],
|
||||
bucket: [],
|
||||
localPaintIndex: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyVisibilityTable(subjectCount: number): CanonicalVisibilityTable {
|
||||
return {
|
||||
subjectId: Array.from({ length: subjectCount }, (_, i) => i),
|
||||
isRendered: Array(subjectCount).fill(1),
|
||||
isVisible: Array(subjectCount).fill(1),
|
||||
visibleArea: Array(subjectCount).fill(0),
|
||||
clippedArea: Array(subjectCount).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CDP GeometryWorldSnapshot into the canonical GeometryWorld shape.
|
||||
*
|
||||
* This is a lossless structural mapping: CDP-specific structures (like the
|
||||
* DOM tree object) are flattened into columnar arrays, and ISO timestamps
|
||||
* are converted to epoch millis.
|
||||
*/
|
||||
export function adaptSnapshotToCanonical(snapshot: GeometryWorldSnapshot): CanonicalGeometryWorld {
|
||||
return {
|
||||
sceneId: snapshot.sceneId,
|
||||
snapshotId: snapshot.snapshotId,
|
||||
env: snapshot.env,
|
||||
source: {
|
||||
url: snapshot.source.url,
|
||||
browserName: snapshot.source.browserName,
|
||||
browserVersion: snapshot.source.browserVersion,
|
||||
engine: 'chromium-cdp',
|
||||
extractedAt: adaptTimestamp(snapshot.source.extractedAt),
|
||||
},
|
||||
strings: adaptStringTable(snapshot.strings),
|
||||
subjects: snapshot.subjects,
|
||||
dom: adaptDom(snapshot.dom),
|
||||
frames: snapshot.frames,
|
||||
matrices: snapshot.matrices,
|
||||
rects: snapshot.rects,
|
||||
boxes: snapshot.boxes,
|
||||
visualBoxes: snapshot.visualBoxes,
|
||||
fragments: snapshot.fragments,
|
||||
transforms: snapshot.transforms,
|
||||
styles: snapshot.styles,
|
||||
text: emptyTextTable(),
|
||||
topology: snapshot.topology,
|
||||
scroll: {
|
||||
containerId: snapshot.scroll.map((s) => s.containerId),
|
||||
scrollLeft: snapshot.scroll.map((s) => s.scrollLeft),
|
||||
scrollTop: snapshot.scroll.map((s) => s.scrollTop),
|
||||
scrollWidth: snapshot.scroll.map((s) => s.scrollWidth),
|
||||
scrollHeight: snapshot.scroll.map((s) => s.scrollHeight),
|
||||
clientWidth: snapshot.scroll.map((s) => s.clientWidth),
|
||||
clientHeight: snapshot.scroll.map((s) => s.clientHeight),
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: snapshot.clipping.map((c) => c.clipNodeId),
|
||||
subjectId: snapshot.clipping.map((c) => c.subjectId),
|
||||
clipKind: snapshot.clipping.map((c) => c.clipKind),
|
||||
clipLeft: snapshot.clipping.map((c) => c.clipLeft),
|
||||
clipTop: snapshot.clipping.map((c) => c.clipTop),
|
||||
clipRight: snapshot.clipping.map((c) => c.clipRight),
|
||||
clipBottom: snapshot.clipping.map((c) => c.clipBottom),
|
||||
parentClipNodeId: snapshot.clipping.map((c) => c.parentClipNodeId),
|
||||
},
|
||||
paint: emptyPaintTable(),
|
||||
visibility: emptyVisibilityTable(snapshot.subjects.ids.length),
|
||||
provenance: {
|
||||
factId: snapshot.provenance.map((p) => p.factId),
|
||||
extractionStepId: snapshot.provenance.map((p) => p.extractionStepId),
|
||||
sourceKind: snapshot.provenance.map((p) => p.sourceKind),
|
||||
sourceRef: snapshot.provenance.map((p) => p.sourceRef),
|
||||
},
|
||||
confidence: {
|
||||
factId: snapshot.confidence.map((c) => c.factId),
|
||||
confidence: snapshot.confidence.map((c) => c.confidence),
|
||||
reasonCode: snapshot.confidence.map((c) => c.reasonCode),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* DOM Extraction
|
||||
*
|
||||
* Captures the DOM tree, node identifiers, selectors, and shadow DOM
|
||||
* boundaries using CDP DOM domain commands.
|
||||
*
|
||||
* Bulk-first: we request the full document tree in one call, then
|
||||
* traverse the returned tree locally rather than making per-node
|
||||
* round-trips.
|
||||
*/
|
||||
|
||||
import type { CDPSession } from './session.js'
|
||||
|
||||
/**
|
||||
* Raw DOM node as returned by CDP DOM.getDocument with depth -1.
|
||||
*/
|
||||
export interface CDPDOMNode {
|
||||
nodeId: number
|
||||
backendNodeId: number
|
||||
nodeType: number
|
||||
nodeName: string
|
||||
localName?: string
|
||||
nodeValue?: string
|
||||
parentId?: number
|
||||
children?: CDPDOMNode[]
|
||||
shadowRoots?: CDPDOMNode[]
|
||||
pseudoElements?: CDPDOMNode[]
|
||||
attributes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened DOM record for a single node.
|
||||
*/
|
||||
export interface DOMNodeRecord {
|
||||
nodeId: number
|
||||
backendNodeId: number
|
||||
parentNodeId: number
|
||||
firstChildIndex: number
|
||||
childCount: number
|
||||
shadowRootKind: 'open' | 'closed' | 'none'
|
||||
tagNameStringId: number
|
||||
roleStringId: number
|
||||
ariaNameStringId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of DOM extraction.
|
||||
*/
|
||||
export interface DOMExtractionResult {
|
||||
/** Flat array of DOM nodes in document order */
|
||||
nodes: DOMNodeRecord[]
|
||||
/** String table for deduplication */
|
||||
strings: string[]
|
||||
/** Map from nodeId to index in nodes array */
|
||||
nodeIdToIndex: Map<number, number>
|
||||
/** Root node index */
|
||||
rootIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the full DOM tree from the current page via CDP.
|
||||
*
|
||||
* Uses DOM.getDocument with depth -1 to fetch the entire tree
|
||||
* in a single protocol round-trip.
|
||||
*/
|
||||
export async function extractDOM(session: CDPSession): Promise<DOMExtractionResult> {
|
||||
const response = await session.send<{ root: CDPDOMNode }>('DOM.getDocument', {
|
||||
depth: -1,
|
||||
pierce: true,
|
||||
})
|
||||
|
||||
const strings: string[] = []
|
||||
const stringIndex = new Map<string, number>()
|
||||
|
||||
function getStringId(value: string): number {
|
||||
if (stringIndex.has(value)) return stringIndex.get(value)!
|
||||
const id = strings.length
|
||||
strings.push(value)
|
||||
stringIndex.set(value, id)
|
||||
return id
|
||||
}
|
||||
|
||||
const nodes: DOMNodeRecord[] = []
|
||||
const nodeIdToIndex = new Map<number, number>()
|
||||
|
||||
function walk(node: CDPDOMNode, parentNodeId: number): number {
|
||||
const nodeIndex = nodes.length
|
||||
nodeIdToIndex.set(node.nodeId, nodeIndex)
|
||||
|
||||
const children = node.children ?? []
|
||||
const shadowRoots = node.shadowRoots ?? []
|
||||
const pseudoElements = node.pseudoElements ?? []
|
||||
const allChildren = [...children, ...shadowRoots, ...pseudoElements]
|
||||
|
||||
const firstChildIndex = nodes.length + 1
|
||||
const childCount = allChildren.length
|
||||
|
||||
// Determine shadow root kind
|
||||
let shadowRootKind: 'open' | 'closed' | 'none' = 'none'
|
||||
if (shadowRoots.length > 0) {
|
||||
// CDP does not explicitly return open/closed in getDocument;
|
||||
// we default to 'open' and refine later if needed.
|
||||
shadowRootKind = 'open'
|
||||
}
|
||||
|
||||
// Extract tag name from localName or nodeName
|
||||
const tagName = node.localName || node.nodeName.toLowerCase()
|
||||
const tagNameStringId = getStringId(tagName)
|
||||
|
||||
// Extract role from attributes if present
|
||||
let roleStringId = getStringId('')
|
||||
let ariaNameStringId = getStringId('')
|
||||
if (node.attributes) {
|
||||
for (let i = 0; i < node.attributes.length; i += 2) {
|
||||
const attrName = node.attributes[i]
|
||||
const attrValue = node.attributes[i + 1]
|
||||
if (attrName === 'role') {
|
||||
roleStringId = getStringId(attrValue)
|
||||
}
|
||||
if (attrName === 'aria-label' || attrName === 'aria-labelledby') {
|
||||
ariaNameStringId = getStringId(attrValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
nodeId: node.nodeId,
|
||||
backendNodeId: node.backendNodeId,
|
||||
parentNodeId,
|
||||
firstChildIndex,
|
||||
childCount,
|
||||
shadowRootKind,
|
||||
tagNameStringId,
|
||||
roleStringId,
|
||||
ariaNameStringId,
|
||||
})
|
||||
|
||||
// Walk children after pushing parent so indices are stable.
|
||||
for (const child of allChildren) {
|
||||
walk(child, node.nodeId)
|
||||
}
|
||||
|
||||
return nodeIndex
|
||||
}
|
||||
|
||||
const rootIndex = walk(response.root, 0)
|
||||
|
||||
return {
|
||||
nodes,
|
||||
strings,
|
||||
nodeIdToIndex,
|
||||
rootIndex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved selector match with both frontend nodeId and stable backendNodeId.
|
||||
*/
|
||||
export interface SelectorMatch {
|
||||
nodeId: number
|
||||
backendNodeId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CSS selector to node IDs via CDP.
|
||||
*
|
||||
* Returns an array of matches with both frontend nodeId and stable
|
||||
* backendNodeId. We keep both because backendNodeId is the canonical
|
||||
* identity for geometry/topology, while frontend nodeId is required
|
||||
* by CSS.getComputedStyleForNode.
|
||||
*/
|
||||
export async function resolveSelector(
|
||||
session: CDPSession | { send: CDPSession['send']; getDocumentRootNodeId?: () => Promise<number> },
|
||||
selector: string
|
||||
): Promise<SelectorMatch[]> {
|
||||
// Use cached document root if available, otherwise fetch it.
|
||||
let rootNodeId: number
|
||||
if ('getDocumentRootNodeId' in session && session.getDocumentRootNodeId) {
|
||||
rootNodeId = await session.getDocumentRootNodeId()
|
||||
} else {
|
||||
const doc = await session.send<{ root: { nodeId: number } }>('DOM.getDocument')
|
||||
rootNodeId = doc.root.nodeId
|
||||
}
|
||||
const { nodeIds } = await session.send<{ nodeIds: number[] }>(
|
||||
'DOM.querySelectorAll',
|
||||
{
|
||||
nodeId: rootNodeId,
|
||||
selector,
|
||||
}
|
||||
)
|
||||
// Convert each frontend nodeId to its stable backendNodeId in parallel.
|
||||
const matches: SelectorMatch[] = []
|
||||
const describeResults = await Promise.all(
|
||||
nodeIds.map(async (nodeId) => {
|
||||
try {
|
||||
const result = await session.send<{ node: { backendNodeId: number } }>('DOM.describeNode', { nodeId })
|
||||
return { nodeId, backendNodeId: result.node.backendNodeId }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const match of describeResults) {
|
||||
if (match !== null) {
|
||||
matches.push(match)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single selector to one backend node ID, or null if none match.
|
||||
*/
|
||||
export async function resolveOneSelector(
|
||||
session: CDPSession,
|
||||
selector: string
|
||||
): Promise<number | null> {
|
||||
const matches = await resolveSelector(session, selector)
|
||||
return matches.length > 0 ? matches[0].backendNodeId : null
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* Extractor Integration Tests
|
||||
*
|
||||
* Tests CDP session setup, DOM extraction, and geometry extraction
|
||||
* using a mock CDP session to avoid requiring a real browser.
|
||||
*
|
||||
* Run with: node --test dist/extractor.test.js
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
CDPSessionManager,
|
||||
CDPExtractor,
|
||||
extractDOM,
|
||||
extractBoxModels,
|
||||
extractFragments,
|
||||
extractTransforms,
|
||||
extractStyles,
|
||||
extractTopology,
|
||||
type CDPSession,
|
||||
type ExtractorRequest,
|
||||
} from './index.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock CDP Session Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockSession(
|
||||
responses: Map<string, unknown>,
|
||||
fallback?: (method: string, params?: Record<string, unknown>) => unknown
|
||||
): CDPSession {
|
||||
return {
|
||||
async send<T>(method: string, params?: Record<string, unknown>): Promise<T> {
|
||||
const key = `${method}:${JSON.stringify(params ?? {})}`
|
||||
let value: unknown = undefined
|
||||
if (responses.has(method)) {
|
||||
value = responses.get(method)
|
||||
} else if (responses.has(key)) {
|
||||
value = responses.get(key)
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
throw value
|
||||
}
|
||||
if (value !== undefined) {
|
||||
return Promise.resolve(value as T)
|
||||
}
|
||||
if (fallback) {
|
||||
const fallbackValue = fallback(method, params)
|
||||
if (fallbackValue !== undefined) {
|
||||
return Promise.resolve(fallbackValue as T)
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected CDP call: ${method} with ${JSON.stringify(params)}`)
|
||||
},
|
||||
on() {},
|
||||
off() {},
|
||||
async detach() {},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockSessionFactory(
|
||||
responses: Map<string, unknown>,
|
||||
fallback?: (method: string, params?: Record<string, unknown>) => unknown
|
||||
): (pageRef: unknown) => Promise<CDPSession> {
|
||||
return async () => createMockSession(responses, fallback)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP Session Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CDP Session Management', () => {
|
||||
it('should attach a session via the factory', async () => {
|
||||
const factory = createMockSessionFactory(new Map())
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
const session = await manager.attach()
|
||||
assert.ok(session, 'session should be defined')
|
||||
})
|
||||
|
||||
it('should return the same session on re-attach', async () => {
|
||||
const factory = createMockSessionFactory(new Map())
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
const s1 = await manager.attach()
|
||||
const s2 = await manager.attach()
|
||||
assert.strictEqual(s1, s2, 'should reuse attached session')
|
||||
})
|
||||
|
||||
it('should throw if getSession called before attach', () => {
|
||||
const factory = createMockSessionFactory(new Map())
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
assert.throws(() => manager.getSession(), /not attached/)
|
||||
})
|
||||
|
||||
it('should detach cleanly', async () => {
|
||||
const factory = createMockSessionFactory(new Map())
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
await manager.attach()
|
||||
await manager.detach()
|
||||
assert.throws(() => manager.getSession(), /not attached/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DOM Extraction', () => {
|
||||
it('should extract a flat DOM tree from CDP getDocument', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
responses.set('DOM.getDocument', {
|
||||
root: {
|
||||
nodeId: 1,
|
||||
backendNodeId: 101,
|
||||
nodeType: 9,
|
||||
nodeName: '#document',
|
||||
children: [
|
||||
{
|
||||
nodeId: 2,
|
||||
backendNodeId: 102,
|
||||
nodeType: 1,
|
||||
nodeName: 'HTML',
|
||||
localName: 'html',
|
||||
children: [
|
||||
{
|
||||
nodeId: 3,
|
||||
backendNodeId: 103,
|
||||
nodeType: 1,
|
||||
nodeName: 'BODY',
|
||||
localName: 'body',
|
||||
children: [
|
||||
{
|
||||
nodeId: 4,
|
||||
backendNodeId: 104,
|
||||
nodeType: 1,
|
||||
nodeName: 'DIV',
|
||||
localName: 'div',
|
||||
attributes: ['class', 'container', 'role', 'main'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const session = createMockSession(responses)
|
||||
const result = await extractDOM(session)
|
||||
|
||||
assert.strictEqual(result.nodes.length, 4, 'should have 4 nodes')
|
||||
assert.strictEqual(result.rootIndex, 0, 'root should be index 0')
|
||||
assert.ok(result.nodeIdToIndex.has(4), 'should map nodeId 4')
|
||||
|
||||
// Check string deduplication
|
||||
assert.ok(result.strings.includes('div'), 'should include div tag')
|
||||
assert.ok(result.strings.includes('main'), 'should include role main')
|
||||
|
||||
// Check node 4 record
|
||||
const node4Idx = result.nodeIdToIndex.get(4)!
|
||||
const node4 = result.nodes[node4Idx]
|
||||
assert.strictEqual(node4.nodeId, 4)
|
||||
assert.strictEqual(node4.parentNodeId, 3)
|
||||
assert.strictEqual(node4.shadowRootKind, 'none')
|
||||
})
|
||||
|
||||
it('should handle shadow roots', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
responses.set('DOM.getDocument', {
|
||||
root: {
|
||||
nodeId: 1,
|
||||
backendNodeId: 101,
|
||||
nodeType: 9,
|
||||
nodeName: '#document',
|
||||
children: [
|
||||
{
|
||||
nodeId: 2,
|
||||
backendNodeId: 102,
|
||||
nodeType: 1,
|
||||
nodeName: 'DIV',
|
||||
localName: 'div',
|
||||
shadowRoots: [
|
||||
{
|
||||
nodeId: 3,
|
||||
backendNodeId: 103,
|
||||
nodeType: 11,
|
||||
nodeName: '#document-fragment',
|
||||
children: [
|
||||
{
|
||||
nodeId: 4,
|
||||
backendNodeId: 104,
|
||||
nodeType: 1,
|
||||
nodeName: 'SPAN',
|
||||
localName: 'span',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const session = createMockSession(responses)
|
||||
const result = await extractDOM(session)
|
||||
|
||||
assert.strictEqual(result.nodes.length, 4, 'should have 4 nodes including shadow root and its child')
|
||||
// shadowRootKind is recorded on the host element (node 2), not the fragment
|
||||
const hostIdx = result.nodeIdToIndex.get(2)!
|
||||
assert.strictEqual(result.nodes[hostIdx].shadowRootKind, 'open')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Geometry Extraction', () => {
|
||||
it('should extract box models in bulk', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
|
||||
// Box model responses keyed by backendNodeId.
|
||||
// DOM.getBoxModel now accepts backendNodeId directly and returns { model: CDPBoxModel }.
|
||||
responses.set(
|
||||
'DOM.getBoxModel:{"backendNodeId":10}',
|
||||
{
|
||||
model: {
|
||||
content: [100, 100, 200, 100, 200, 200, 100, 200],
|
||||
padding: [90, 90, 210, 90, 210, 210, 90, 210],
|
||||
border: [80, 80, 220, 80, 220, 220, 80, 220],
|
||||
margin: [70, 70, 230, 70, 230, 230, 70, 230],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
responses.set(
|
||||
'DOM.getBoxModel:{"backendNodeId":20}',
|
||||
{
|
||||
model: {
|
||||
content: [300, 300, 400, 300, 400, 400, 300, 400],
|
||||
padding: [290, 290, 410, 290, 410, 410, 290, 410],
|
||||
border: [280, 280, 420, 280, 420, 420, 280, 420],
|
||||
margin: [270, 270, 430, 270, 430, 430, 270, 430],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const session = createMockSession(responses)
|
||||
const backendNodeIds = [10, 20]
|
||||
const subjectIds = [0, 1]
|
||||
|
||||
const { boxes, errors } = await extractBoxModels(session, backendNodeIds, subjectIds)
|
||||
|
||||
assert.strictEqual(boxes.length, 2, 'should extract 2 boxes')
|
||||
assert.strictEqual(errors.length, 0, 'should have no errors')
|
||||
|
||||
const box0 = boxes[0]
|
||||
assert.strictEqual(box0.borderLeft, 80)
|
||||
assert.strictEqual(box0.borderTop, 80)
|
||||
assert.strictEqual(box0.borderRight, 220)
|
||||
assert.strictEqual(box0.borderBottom, 220)
|
||||
assert.strictEqual(box0.contentLeft, 100)
|
||||
assert.strictEqual(box0.contentTop, 100)
|
||||
assert.strictEqual(box0.contentRight, 200)
|
||||
assert.strictEqual(box0.contentBottom, 200)
|
||||
})
|
||||
|
||||
it('should handle box model extraction failures gracefully', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
// DOM.getBoxModel now uses backendNodeId parameter.
|
||||
responses.set('DOM.getBoxModel:{"backendNodeId":10}', new Error('Node not found'))
|
||||
|
||||
const session = createMockSession(responses)
|
||||
const backendNodeIds = [10]
|
||||
const subjectIds = [0]
|
||||
|
||||
const { boxes, errors } = await extractBoxModels(session, backendNodeIds, subjectIds)
|
||||
assert.strictEqual(boxes.length, 0)
|
||||
assert.strictEqual(errors.length, 1)
|
||||
assert.ok(errors[0].reason.includes('Node not found'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Style Extraction', () => {
|
||||
it('should extract computed styles and map to enums', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
|
||||
// extractStyles now resolves backendNodeId -> objectId via DOM.resolveNode
|
||||
// then uses Runtime.callFunctionOn to read computed styles via JS.
|
||||
responses.set('DOM.resolveNode', { object: { objectId: 'obj1' } })
|
||||
|
||||
responses.set('Runtime.callFunctionOn', {
|
||||
result: {
|
||||
value: {
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
zIndex: '10',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
opacity: '0.5',
|
||||
visibility: 'visible',
|
||||
contain: 'layout paint',
|
||||
pointerEvents: 'none',
|
||||
lineHeight: '24px',
|
||||
fontFamily: 'Arial',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const session = createMockSession(responses)
|
||||
const backendNodeIds = [110]
|
||||
const subjectIds = [0]
|
||||
|
||||
const { styles, errors } = await extractStyles(session, backendNodeIds, subjectIds, { all: true }, [])
|
||||
|
||||
assert.strictEqual(styles.length, 1)
|
||||
assert.strictEqual(errors.length, 0)
|
||||
|
||||
const s = styles[0]
|
||||
assert.strictEqual(s.display, 5) // flex
|
||||
assert.strictEqual(s.position, 2) // relative
|
||||
assert.strictEqual(s.zIndexKind, 1) // integer
|
||||
assert.strictEqual(s.zIndexValue, 10)
|
||||
assert.strictEqual(s.overflowX, 2) // hidden
|
||||
assert.strictEqual(s.overflowY, 5) // auto
|
||||
assert.strictEqual(s.opacity, 0.5)
|
||||
assert.strictEqual(s.containFlags, 3) // layout(1) | paint(2)
|
||||
assert.strictEqual(s.pointerEvents, 2) // none
|
||||
assert.strictEqual(s.fontSize, 16)
|
||||
assert.strictEqual(s.fontWeight, 700)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topology Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Topology Extraction', () => {
|
||||
it('should extract topology via Runtime.evaluate', async () => {
|
||||
const mockTopologyResult = {
|
||||
scroll: [
|
||||
{
|
||||
containerId: 1,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 100,
|
||||
scrollWidth: 500,
|
||||
scrollHeight: 1000,
|
||||
clientWidth: 400,
|
||||
clientHeight: 300,
|
||||
},
|
||||
],
|
||||
clipping: [
|
||||
{
|
||||
clipNodeId: 0,
|
||||
subjectId: 1,
|
||||
clipKind: 1,
|
||||
clipLeft: 0,
|
||||
clipTop: 0,
|
||||
clipRight: 400,
|
||||
clipBottom: 300,
|
||||
parentClipNodeId: 0,
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
containingBlockOf: [0],
|
||||
nearestPositionedAncestorOf: [0],
|
||||
scrollContainerOf: [0],
|
||||
stackingContextOf: [0],
|
||||
formattingContextOf: [0],
|
||||
clippingRootOf: [0],
|
||||
paintOrderBucket: [1],
|
||||
paintOrderIndex: [0],
|
||||
},
|
||||
}
|
||||
|
||||
const responses = new Map<string, unknown>()
|
||||
responses.set('Runtime.evaluate', {
|
||||
result: { value: mockTopologyResult },
|
||||
})
|
||||
|
||||
const session = createMockSession(responses)
|
||||
const { result, errors } = await extractTopology(session, [10], [0])
|
||||
|
||||
assert.strictEqual(errors.length, 0)
|
||||
assert.strictEqual(result.scroll.length, 1)
|
||||
assert.strictEqual(result.scroll[0].scrollTop, 100)
|
||||
assert.strictEqual(result.clipping.length, 1)
|
||||
assert.strictEqual(result.topology.paintOrderBucket[0], 1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full Orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CDPExtractor', () => {
|
||||
it('should perform full extraction with partial handling', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
|
||||
// Domain enablement is now part of the attach flow.
|
||||
responses.set('DOM.enable', {})
|
||||
responses.set('CSS.enable', {})
|
||||
responses.set('Runtime.enable', {})
|
||||
|
||||
// DOM.getDocument
|
||||
responses.set('DOM.getDocument', {
|
||||
root: {
|
||||
nodeId: 1,
|
||||
backendNodeId: 101,
|
||||
nodeType: 9,
|
||||
nodeName: '#document',
|
||||
children: [
|
||||
{
|
||||
nodeId: 2,
|
||||
backendNodeId: 102,
|
||||
nodeType: 1,
|
||||
nodeName: 'BODY',
|
||||
localName: 'body',
|
||||
children: [
|
||||
{
|
||||
nodeId: 10,
|
||||
backendNodeId: 110,
|
||||
nodeType: 1,
|
||||
nodeName: 'DIV',
|
||||
localName: 'div',
|
||||
attributes: ['class', 'test'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Selector resolution
|
||||
responses.set('DOM.querySelectorAll', { nodeIds: [10] })
|
||||
responses.set('DOM.describeNode', { node: { backendNodeId: 110 } })
|
||||
|
||||
// Box model (uses backendNodeId, returns { model: {...} })
|
||||
responses.set(
|
||||
'DOM.getBoxModel:{"backendNodeId":110}',
|
||||
{
|
||||
model: {
|
||||
content: [10, 10, 110, 10, 110, 110, 10, 110],
|
||||
padding: [0, 0, 120, 0, 120, 120, 0, 120],
|
||||
border: [0, 0, 120, 0, 120, 120, 0, 120],
|
||||
margin: [0, 0, 120, 0, 120, 120, 0, 120],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Styles (uses DOM.resolveNode + Runtime.callFunctionOn)
|
||||
responses.set('DOM.resolveNode', { object: { objectId: 'obj1' } })
|
||||
responses.set('Runtime.callFunctionOn', {
|
||||
result: {
|
||||
value: {
|
||||
display: 'block',
|
||||
position: 'static',
|
||||
zIndex: 'auto',
|
||||
overflowX: 'visible',
|
||||
overflowY: 'visible',
|
||||
opacity: '1',
|
||||
visibility: 'visible',
|
||||
contain: 'none',
|
||||
pointerEvents: 'auto',
|
||||
lineHeight: 'normal',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Topology via Runtime.evaluate
|
||||
responses.set('Runtime.evaluate', {
|
||||
result: {
|
||||
value: {
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
topology: {
|
||||
containingBlockOf: [0],
|
||||
nearestPositionedAncestorOf: [0],
|
||||
scrollContainerOf: [0],
|
||||
stackingContextOf: [0],
|
||||
formattingContextOf: [0],
|
||||
clippingRootOf: [0],
|
||||
paintOrderBucket: [1],
|
||||
paintOrderIndex: [0],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const factory = createMockSessionFactory(responses)
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
const extractor = new CDPExtractor(manager)
|
||||
|
||||
const request: ExtractorRequest = {
|
||||
requestId: 'test-1',
|
||||
sceneTarget: { pageRef: {}, url: 'https://example.test' },
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
},
|
||||
subjects: [{ id: 's1', selector: '.test' }],
|
||||
requiredFacts: {
|
||||
geometry: true,
|
||||
fragments: false,
|
||||
styles: true,
|
||||
topology: true,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await extractor.extract(request)
|
||||
|
||||
assert.strictEqual(response.requestId, 'test-1')
|
||||
assert.strictEqual(response.status, 'ok')
|
||||
assert.strictEqual(response.diagnostics.length, 0)
|
||||
assert.ok(response.snapshots, 'snapshots array should exist')
|
||||
assert.strictEqual(response.snapshots.length, 1)
|
||||
assert.ok(response.snapshot, 'deprecated snapshot field still present')
|
||||
assert.strictEqual(response.snapshots[0].subjects.ids.length, 1)
|
||||
assert.strictEqual(response.snapshots[0].boxes.boxId.length, 1)
|
||||
assert.strictEqual(response.snapshots[0].styles.display[0], 2) // block
|
||||
assert.strictEqual(response.snapshots[0].provenance.length, 5) // 5 extraction steps
|
||||
})
|
||||
|
||||
it('should return partial status when selectors do not match', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
|
||||
// Domain enablement is now part of the attach flow.
|
||||
responses.set('DOM.enable', {})
|
||||
responses.set('CSS.enable', {})
|
||||
responses.set('Runtime.enable', {})
|
||||
|
||||
responses.set('DOM.getDocument', {
|
||||
root: {
|
||||
nodeId: 1,
|
||||
backendNodeId: 101,
|
||||
nodeType: 9,
|
||||
nodeName: '#document',
|
||||
children: [],
|
||||
},
|
||||
})
|
||||
responses.set('DOM.querySelectorAll', { nodeIds: [] })
|
||||
|
||||
const factory = createMockSessionFactory(responses)
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
const extractor = new CDPExtractor(manager)
|
||||
|
||||
const request: ExtractorRequest = {
|
||||
requestId: 'test-2',
|
||||
sceneTarget: { pageRef: {}, url: 'https://example.test' },
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
},
|
||||
subjects: [{ id: 's1', selector: '.missing' }],
|
||||
requiredFacts: { geometry: true },
|
||||
}
|
||||
|
||||
const response = await extractor.extract(request)
|
||||
|
||||
assert.strictEqual(response.status, 'partial')
|
||||
assert.ok(
|
||||
response.diagnostics.some((d) => d.code === 'IMH_SELECTOR_NO_MATCH'),
|
||||
'should emit selector no match diagnostic'
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve multiple matches per selector and preserve identity', async () => {
|
||||
const responses = new Map<string, unknown>()
|
||||
|
||||
// Domain enablement is now part of the attach flow.
|
||||
responses.set('DOM.enable', {})
|
||||
responses.set('CSS.enable', {})
|
||||
responses.set('Runtime.enable', {})
|
||||
|
||||
responses.set('DOM.getDocument', {
|
||||
root: {
|
||||
nodeId: 1,
|
||||
backendNodeId: 101,
|
||||
nodeType: 9,
|
||||
nodeName: '#document',
|
||||
children: [
|
||||
{
|
||||
nodeId: 2,
|
||||
backendNodeId: 102,
|
||||
nodeType: 1,
|
||||
nodeName: 'DIV',
|
||||
localName: 'div',
|
||||
children: [
|
||||
{
|
||||
nodeId: 10,
|
||||
backendNodeId: 110,
|
||||
nodeType: 1,
|
||||
nodeName: 'BUTTON',
|
||||
localName: 'button',
|
||||
attributes: ['class', 'btn'],
|
||||
},
|
||||
{
|
||||
nodeId: 11,
|
||||
backendNodeId: 111,
|
||||
nodeType: 1,
|
||||
nodeName: 'BUTTON',
|
||||
localName: 'button',
|
||||
attributes: ['class', 'btn'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// querySelectorAll returns frontend nodeIds for the selector.
|
||||
responses.set('DOM.querySelectorAll', { nodeIds: [10, 11] })
|
||||
|
||||
// DOM.describeNode converts each frontend nodeId to its stable backendNodeId.
|
||||
// We mock the keyed response for each nodeId.
|
||||
responses.set('DOM.describeNode:{"nodeId":10}', { node: { backendNodeId: 110 } })
|
||||
responses.set('DOM.describeNode:{"nodeId":11}', { node: { backendNodeId: 111 } })
|
||||
|
||||
// Box models for each backendNodeId.
|
||||
responses.set(
|
||||
'DOM.getBoxModel:{"backendNodeId":110}',
|
||||
{
|
||||
model: {
|
||||
content: [0, 0, 100, 0, 100, 40, 0, 40],
|
||||
padding: [0, 0, 100, 0, 100, 40, 0, 40],
|
||||
border: [0, 0, 100, 0, 100, 40, 0, 40],
|
||||
margin: [0, 0, 100, 0, 100, 40, 0, 40],
|
||||
width: 100,
|
||||
height: 40,
|
||||
},
|
||||
}
|
||||
)
|
||||
responses.set(
|
||||
'DOM.getBoxModel:{"backendNodeId":111}',
|
||||
{
|
||||
model: {
|
||||
content: [110, 0, 210, 0, 210, 40, 110, 40],
|
||||
padding: [110, 0, 210, 0, 210, 40, 110, 40],
|
||||
border: [110, 0, 210, 0, 210, 40, 110, 40],
|
||||
margin: [110, 0, 210, 0, 210, 40, 110, 40],
|
||||
width: 100,
|
||||
height: 40,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Transforms use DOM.resolveNode + Runtime.callFunctionOn
|
||||
responses.set('DOM.resolveNode', { object: { objectId: 'obj1' } })
|
||||
responses.set('Runtime.callFunctionOn', {
|
||||
result: {
|
||||
value: {
|
||||
matrix: [1, 0, 0, 1, 0, 0],
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const factory = createMockSessionFactory(responses)
|
||||
const manager = new CDPSessionManager(factory, {})
|
||||
const extractor = new CDPExtractor(manager)
|
||||
|
||||
const request: ExtractorRequest = {
|
||||
requestId: 'test-multi',
|
||||
sceneTarget: { pageRef: {}, url: 'https://example.test' },
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
},
|
||||
subjects: [{ id: 's1', selector: '.btn' }],
|
||||
requiredFacts: { geometry: true, styles: false, topology: false },
|
||||
}
|
||||
|
||||
const response = await extractor.extract(request)
|
||||
|
||||
assert.strictEqual(response.status, 'ok')
|
||||
assert.strictEqual(response.diagnostics.length, 0)
|
||||
assert.strictEqual(response.snapshots[0].subjects.ids.length, 2)
|
||||
assert.strictEqual(response.snapshots[0].boxes.boxId.length, 2)
|
||||
|
||||
// Verify backendNodeIds are preserved in the subjects table.
|
||||
const backendIds = response.snapshots[0].subjects.domNodeId
|
||||
assert.strictEqual(backendIds.length, 2)
|
||||
assert.ok(backendIds.includes(110), 'should include backendNodeId 110')
|
||||
assert.ok(backendIds.includes(111), 'should include backendNodeId 111')
|
||||
})
|
||||
|
||||
it('should return error status when session attach fails', async () => {
|
||||
const failingFactory = async () => {
|
||||
throw new Error('Browser not reachable')
|
||||
}
|
||||
|
||||
const manager = new CDPSessionManager(failingFactory, {})
|
||||
const extractor = new CDPExtractor(manager)
|
||||
|
||||
const request: ExtractorRequest = {
|
||||
requestId: 'test-3',
|
||||
sceneTarget: { pageRef: {}, url: 'https://example.test' },
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
},
|
||||
subjects: [],
|
||||
requiredFacts: {},
|
||||
}
|
||||
|
||||
const response = await extractor.extract(request)
|
||||
|
||||
assert.strictEqual(response.status, 'error')
|
||||
assert.ok(
|
||||
response.diagnostics.some((d) => d.code === 'IMH_CDP_SESSION_ATTACH_FAILED'),
|
||||
'should emit attach failed diagnostic'
|
||||
)
|
||||
assert.strictEqual(response.snapshots[0].subjects.ids.length, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,973 @@
|
||||
/**
|
||||
* Main Extraction Orchestrator
|
||||
*
|
||||
* Consumes an extractor request plan, coordinates CDP session
|
||||
* management, DOM resolution, geometry capture, style extraction,
|
||||
* and topology building.
|
||||
*
|
||||
* Returns raw browser facts matching the geometry world schema,
|
||||
* with provenance metadata and graceful partial handling.
|
||||
*/
|
||||
|
||||
import type { DiagnosticCode } from 'imhotep-core'
|
||||
import type { CDPSession, CDPSessionManager } from './session.js'
|
||||
import { extractDOM, resolveSelector, type DOMExtractionResult, type SelectorMatch } from './dom.js'
|
||||
import {
|
||||
extractBoxModels,
|
||||
extractFragments,
|
||||
extractTransforms,
|
||||
extractVisualBoxes,
|
||||
type GeometryExtractionResult,
|
||||
type BoxRecord,
|
||||
type FragmentRecord,
|
||||
type TransformRecord,
|
||||
} from './geometry.js'
|
||||
import { extractStyles, type StyleRecord, type StyleExtractionConfig } from './styles.js'
|
||||
import { extractTopology, type TopologyExtractionResult } from './topology.js'
|
||||
|
||||
/**
|
||||
* Extractor request plan.
|
||||
*/
|
||||
export interface ExtractorRequest {
|
||||
requestId: string
|
||||
sceneTarget: {
|
||||
pageRef: unknown
|
||||
url: string
|
||||
}
|
||||
env: {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
colorScheme: string
|
||||
pointer: string
|
||||
}
|
||||
subjects: Array<{ id: string; selector: string; nodeId?: number; backendNodeId?: number }>
|
||||
requiredFacts: {
|
||||
geometry?: boolean
|
||||
fragments?: boolean
|
||||
styles?: boolean | string[]
|
||||
topology?: boolean | string[]
|
||||
text?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance entry for a fact.
|
||||
*/
|
||||
export interface ProvenanceEntry {
|
||||
factId: number
|
||||
extractionStepId: number
|
||||
sourceKind: number
|
||||
sourceRef: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence entry for a fact.
|
||||
*/
|
||||
export interface ConfidenceEntry {
|
||||
factId: number
|
||||
confidence: number
|
||||
reasonCode: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extraction trace entry.
|
||||
*/
|
||||
export interface ExtractionTraceEntry {
|
||||
stepId: number
|
||||
factKind: string
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extractor response.
|
||||
*
|
||||
* Canonical shape uses snapshots (array). The singular snapshot field is
|
||||
* kept for backward compatibility but deprecated — use snapshots[0].
|
||||
*/
|
||||
export interface ExtractorResponse {
|
||||
requestId: string
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
/** @deprecated Use snapshots[0] instead. Kept for backward compatibility. */
|
||||
snapshot: GeometryWorldSnapshot
|
||||
/** Canonical shape: array of snapshots for multi-state extraction. */
|
||||
snapshots: GeometryWorldSnapshot[]
|
||||
diagnostics: ExtractorDiagnostic[]
|
||||
extractionTrace: {
|
||||
steps: ExtractionTraceEntry[]
|
||||
timings: Array<{ stepId: number; startMs: number; endMs: number }>
|
||||
protocolCalls: Array<{ stepId: number; protocol: string; method: string; params: Record<string, unknown> }>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic for extraction failures.
|
||||
*/
|
||||
export interface ExtractorDiagnostic {
|
||||
code: DiagnosticCode
|
||||
severity: 'warning' | 'error'
|
||||
message: string
|
||||
subjectId?: string
|
||||
selector?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry world snapshot.
|
||||
* Mirrors the geometry world schema from the core contracts.
|
||||
*/
|
||||
export interface GeometryWorldSnapshot {
|
||||
sceneId: string
|
||||
snapshotId: string
|
||||
env: {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: string
|
||||
pointer: string
|
||||
hover: boolean
|
||||
reducedMotion: string
|
||||
locale: string
|
||||
writingMode: string
|
||||
}
|
||||
source: {
|
||||
url: string
|
||||
browserName: string
|
||||
browserVersion: string
|
||||
engine: string
|
||||
extractedAt: string
|
||||
}
|
||||
strings: string[]
|
||||
subjects: {
|
||||
ids: number[]
|
||||
domNodeId: number[]
|
||||
subjectKind: number[]
|
||||
primaryBoxId: number[]
|
||||
firstFragmentId: number[]
|
||||
fragmentCount: number[]
|
||||
firstTextRunId: number[]
|
||||
textRunCount: number[]
|
||||
}
|
||||
dom: DOMExtractionResult
|
||||
frames: {
|
||||
frameId: number[]
|
||||
frameKind: number[]
|
||||
ownerSubjectId: number[]
|
||||
parentFrameId: number[]
|
||||
originX: number[]
|
||||
originY: number[]
|
||||
axisMatrixStart: number[]
|
||||
clipRectId: number[]
|
||||
scrollContainerId: number[]
|
||||
writingMode: number[]
|
||||
}
|
||||
matrices: {
|
||||
values: number[]
|
||||
}
|
||||
rects: {
|
||||
rectId: number[]
|
||||
left: number[]
|
||||
top: number[]
|
||||
right: number[]
|
||||
bottom: number[]
|
||||
}
|
||||
boxes: {
|
||||
boxId: number[]
|
||||
subjectId: number[]
|
||||
frameId: number[]
|
||||
borderLeft: number[]
|
||||
borderTop: number[]
|
||||
borderRight: number[]
|
||||
borderBottom: number[]
|
||||
paddingLeft: number[]
|
||||
paddingTop: number[]
|
||||
paddingRight: number[]
|
||||
paddingBottom: number[]
|
||||
contentLeft: number[]
|
||||
contentTop: number[]
|
||||
contentRight: number[]
|
||||
contentBottom: number[]
|
||||
}
|
||||
visualBoxes: {
|
||||
boxId: number[]
|
||||
subjectId: number[]
|
||||
frameId: number[]
|
||||
borderLeft: number[]
|
||||
borderTop: number[]
|
||||
borderRight: number[]
|
||||
borderBottom: number[]
|
||||
paddingLeft: number[]
|
||||
paddingTop: number[]
|
||||
paddingRight: number[]
|
||||
paddingBottom: number[]
|
||||
contentLeft: number[]
|
||||
contentTop: number[]
|
||||
contentRight: number[]
|
||||
contentBottom: number[]
|
||||
}
|
||||
fragments: {
|
||||
fragmentId: number[]
|
||||
subjectId: number[]
|
||||
fragmentKind: number[]
|
||||
boxLeft: number[]
|
||||
boxTop: number[]
|
||||
boxRight: number[]
|
||||
boxBottom: number[]
|
||||
lineIndex: number[]
|
||||
flowIndex: number[]
|
||||
parentFragmentId: number[]
|
||||
}
|
||||
transforms: {
|
||||
transformId: number[]
|
||||
subjectId: number[]
|
||||
matrixStart: number[]
|
||||
matrixLength: number[]
|
||||
originX: number[]
|
||||
originY: number[]
|
||||
}
|
||||
styles: {
|
||||
subjectId: number[]
|
||||
display: number[]
|
||||
position: number[]
|
||||
zIndexKind: number[]
|
||||
zIndexValue: number[]
|
||||
overflowX: number[]
|
||||
overflowY: number[]
|
||||
opacity: number[]
|
||||
visibility: number[]
|
||||
containFlags: number[]
|
||||
pointerEvents: number[]
|
||||
lineHeight: number[]
|
||||
fontFamilyStringId: number[]
|
||||
fontSize: number[]
|
||||
fontWeight: number[]
|
||||
}
|
||||
topology: TopologyExtractionResult['topology']
|
||||
scroll: TopologyExtractionResult['scroll']
|
||||
clipping: TopologyExtractionResult['clipping']
|
||||
provenance: ProvenanceEntry[]
|
||||
confidence: ConfidenceEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extractor class.
|
||||
*
|
||||
* Orchestrates the full extraction pipeline:
|
||||
* 1. Attach CDP session
|
||||
* 2. Resolve selectors to node IDs
|
||||
* 3. Extract DOM tree
|
||||
* 4. Extract geometry (boxes, fragments, transforms)
|
||||
* 5. Extract styles
|
||||
* 6. Extract topology
|
||||
* 7. Build geometry world snapshot
|
||||
* 8. Return response with provenance and diagnostics
|
||||
*/
|
||||
export class CDPExtractor {
|
||||
private readonly sessionManager: CDPSessionManager
|
||||
|
||||
constructor(sessionManager: CDPSessionManager) {
|
||||
this.sessionManager = sessionManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute extraction according to the request plan.
|
||||
*/
|
||||
async extract(request: ExtractorRequest): Promise<ExtractorResponse> {
|
||||
const startTime = Date.now()
|
||||
const diagnostics: ExtractorDiagnostic[] = []
|
||||
const traceSteps: ExtractionTraceEntry[] = []
|
||||
const traceTimings: Array<{ stepId: number; startMs: number; endMs: number }> = []
|
||||
const traceCalls: Array<{
|
||||
stepId: number
|
||||
protocol: string
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}> = []
|
||||
|
||||
function recordStep(
|
||||
stepId: number,
|
||||
factKind: string,
|
||||
status: 'ok' | 'partial' | 'error',
|
||||
startMs: number,
|
||||
endMs: number
|
||||
) {
|
||||
traceSteps.push({ stepId, factKind, status })
|
||||
traceTimings.push({ stepId, startMs, endMs })
|
||||
}
|
||||
|
||||
let session: CDPSession
|
||||
try {
|
||||
session = await this.sessionManager.attach()
|
||||
// Enable required CDP domains before extraction.
|
||||
// Use cached enablement to avoid redundant round-trips.
|
||||
await this.sessionManager.enableDomain('DOM')
|
||||
await this.sessionManager.enableDomain('CSS')
|
||||
await this.sessionManager.enableDomain('Runtime')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_CDP_SESSION_ATTACH_FAILED',
|
||||
severity: 'error',
|
||||
message: `Failed to attach CDP session: ${message}`,
|
||||
})
|
||||
const emptySnapshot = createEmptySnapshot(request)
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
status: 'error',
|
||||
snapshot: emptySnapshot,
|
||||
snapshots: [emptySnapshot],
|
||||
diagnostics,
|
||||
extractionTrace: {
|
||||
steps: traceSteps,
|
||||
timings: traceTimings,
|
||||
protocolCalls: traceCalls,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 1: Extract DOM ---
|
||||
// Extract DOM first so the full tree is pushed to the frontend.
|
||||
// This ensures backendNodeIds returned by querySelectorAll remain valid.
|
||||
const domStepStart = Date.now()
|
||||
let domResult: DOMExtractionResult
|
||||
try {
|
||||
domResult = await extractDOM(session)
|
||||
recordStep(1, 'dom-tree', 'ok', domStepStart, Date.now())
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_DOM_EXTRACTION_FAILED',
|
||||
severity: 'error',
|
||||
message: `DOM extraction failed: ${message}`,
|
||||
})
|
||||
domResult = { nodes: [], strings: [], nodeIdToIndex: new Map(), rootIndex: 0 }
|
||||
recordStep(1, 'dom-tree', 'error', domStepStart, Date.now())
|
||||
}
|
||||
|
||||
// --- Step 2: Resolve selectors ---
|
||||
const selectorStepStart = Date.now()
|
||||
const backendNodeIds: number[] = []
|
||||
const nodeIds: number[] = []
|
||||
const subjectIds: number[] = []
|
||||
const selectorDiagnosticsStart = diagnostics.length
|
||||
const resolvedSubjects: Array<{ id: string; selector: string; backendNodeId: number; nodeId: number }> = []
|
||||
|
||||
// Resolve all selectors sequentially to avoid CDP race conditions
|
||||
// when multiple querySelectorAll calls run concurrently.
|
||||
// Callers that already resolved selectors can pass nodeId/backendNodeId
|
||||
// to avoid a duplicate querySelectorAll + describeNode pass.
|
||||
const selectorResults: Array<{ subject: typeof request.subjects[0]; matches: Awaited<ReturnType<typeof resolveSelector>>; error: string | null }> = []
|
||||
for (const subject of request.subjects) {
|
||||
if (subject.nodeId !== undefined && subject.backendNodeId !== undefined) {
|
||||
selectorResults.push({
|
||||
subject,
|
||||
matches: [{ nodeId: subject.nodeId, backendNodeId: subject.backendNodeId }],
|
||||
error: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const matches = await resolveSelector(session, subject.selector)
|
||||
selectorResults.push({ subject, matches, error: null })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
selectorResults.push({ subject, matches: [], error: message })
|
||||
}
|
||||
}
|
||||
|
||||
for (const { subject, matches, error } of selectorResults) {
|
||||
if (error) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_SELECTOR_RESOLUTION_FAILED',
|
||||
severity: 'error',
|
||||
message: `Failed to resolve selector "${subject.selector}": ${error}`,
|
||||
subjectId: subject.id,
|
||||
selector: subject.selector,
|
||||
})
|
||||
} else if (matches.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_SELECTOR_NO_MATCH',
|
||||
severity: 'warning',
|
||||
message: `Selector "${subject.selector}" matched 0 elements.`,
|
||||
subjectId: subject.id,
|
||||
selector: subject.selector,
|
||||
})
|
||||
} else {
|
||||
for (const match of matches) {
|
||||
backendNodeIds.push(match.backendNodeId)
|
||||
nodeIds.push(match.nodeId)
|
||||
subjectIds.push(resolvedSubjects.length)
|
||||
resolvedSubjects.push({ ...subject, backendNodeId: match.backendNodeId, nodeId: match.nodeId })
|
||||
}
|
||||
}
|
||||
}
|
||||
const selectorDiagnosticsAdded = diagnostics.length > selectorDiagnosticsStart
|
||||
recordStep(2, 'selector-resolution', selectorDiagnosticsAdded ? 'partial' : 'ok', selectorStepStart, Date.now())
|
||||
|
||||
// --- Step 3: Extract Geometry ---
|
||||
const geometryStepStart = Date.now()
|
||||
let boxRecords: BoxRecord[] = []
|
||||
let fragmentRecords: FragmentRecord[] = []
|
||||
let transformRecords: TransformRecord[] = []
|
||||
let matrixValues: number[] = []
|
||||
|
||||
let visualBoxRecords: BoxRecord[] = []
|
||||
|
||||
if (request.requiredFacts.geometry !== false && backendNodeIds.length > 0) {
|
||||
try {
|
||||
const { boxes, errors } = await extractBoxModels(session, backendNodeIds, subjectIds)
|
||||
boxRecords = boxes
|
||||
for (const e of errors) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_BOX_MODEL_PARTIAL',
|
||||
severity: 'warning',
|
||||
message: `Box model extraction failed for node ${e.backendNodeId}: ${e.reason}`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_BOX_MODEL_FAILED',
|
||||
severity: 'error',
|
||||
message: `Box model extraction failed: ${message}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract visual boxes (post-transform coordinates via getBoundingClientRect)
|
||||
try {
|
||||
const { boxes, errors } = await extractVisualBoxes(session, backendNodeIds, subjectIds)
|
||||
visualBoxRecords = boxes
|
||||
for (const e of errors) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_VISUAL_BOX_PARTIAL',
|
||||
severity: 'warning',
|
||||
message: `Visual box extraction failed for node ${e.backendNodeId}: ${e.reason}`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_VISUAL_BOX_FAILED',
|
||||
severity: 'error',
|
||||
message: `Visual box extraction failed: ${message}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (request.requiredFacts.fragments) {
|
||||
try {
|
||||
const { fragments, errors } = await extractFragments(session, backendNodeIds, subjectIds)
|
||||
fragmentRecords = fragments
|
||||
for (const e of errors) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_FRAGMENT_PARTIAL',
|
||||
severity: 'warning',
|
||||
message: `Fragment extraction failed for node ${e.backendNodeId}: ${e.reason}`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_FRAGMENT_FAILED',
|
||||
severity: 'error',
|
||||
message: `Fragment extraction failed: ${message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { transforms, matrices, errors } = await extractTransforms(session, backendNodeIds, subjectIds)
|
||||
transformRecords = transforms
|
||||
matrixValues = matrices
|
||||
for (const e of errors) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_TRANSFORM_PARTIAL',
|
||||
severity: 'warning',
|
||||
message: `Transform extraction failed for node ${e.backendNodeId}: ${e.reason}`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_TRANSFORM_FAILED',
|
||||
severity: 'error',
|
||||
message: `Transform extraction failed: ${message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
recordStep(
|
||||
3,
|
||||
'geometry',
|
||||
diagnostics.some((d) => d.code.startsWith('IMH_BOX_MODEL') || d.code.startsWith('IMH_FRAGMENT') || d.code.startsWith('IMH_TRANSFORM'))
|
||||
? 'partial'
|
||||
: 'ok',
|
||||
geometryStepStart,
|
||||
Date.now()
|
||||
)
|
||||
|
||||
// --- Step 4: Extract Styles ---
|
||||
const styleStepStart = Date.now()
|
||||
let styleRecords: StyleRecord[] = []
|
||||
|
||||
if (request.requiredFacts.styles !== false && backendNodeIds.length > 0) {
|
||||
const styleConfig: StyleExtractionConfig =
|
||||
Array.isArray(request.requiredFacts.styles)
|
||||
? { all: false, properties: request.requiredFacts.styles }
|
||||
: { all: true }
|
||||
|
||||
try {
|
||||
const { styles, errors, strings: updatedStrings } = await extractStyles(
|
||||
session,
|
||||
backendNodeIds,
|
||||
subjectIds,
|
||||
styleConfig,
|
||||
domResult.strings
|
||||
)
|
||||
styleRecords = styles
|
||||
domResult.strings = updatedStrings
|
||||
for (const e of errors) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_STYLE_PARTIAL',
|
||||
severity: 'warning',
|
||||
message: `Style extraction failed for node ${e.backendNodeId}: ${e.reason}`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_STYLE_FAILED',
|
||||
severity: 'error',
|
||||
message: `Style extraction failed: ${message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
recordStep(4, 'styles', diagnostics.some((d) => d.code.startsWith('IMH_STYLE')) ? 'partial' : 'ok', styleStepStart, Date.now())
|
||||
|
||||
// --- Step 5: Extract Topology ---
|
||||
const topologyStepStart = Date.now()
|
||||
let topologyResult: TopologyExtractionResult = {
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
}
|
||||
|
||||
if (request.requiredFacts.topology !== false && backendNodeIds.length > 0) {
|
||||
try {
|
||||
const { result, errors } = await extractTopology(session, backendNodeIds, subjectIds)
|
||||
topologyResult = result
|
||||
for (const e of errors) {
|
||||
diagnostics.push({
|
||||
code: 'IMH_TOPOLOGY_PARTIAL',
|
||||
severity: 'warning',
|
||||
message: `Topology extraction failed for node ${e.backendNodeId}: ${e.reason}`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
diagnostics.push({
|
||||
code: 'IMH_TOPOLOGY_FAILED',
|
||||
severity: 'error',
|
||||
message: `Topology extraction failed: ${message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
recordStep(5, 'topology', diagnostics.some((d) => d.code.startsWith('IMH_TOPOLOGY')) ? 'partial' : 'ok', topologyStepStart, Date.now())
|
||||
|
||||
// --- Build Geometry World Snapshot ---
|
||||
const snapshot = buildSnapshot(
|
||||
request,
|
||||
domResult,
|
||||
boxRecords,
|
||||
visualBoxRecords,
|
||||
fragmentRecords,
|
||||
transformRecords,
|
||||
matrixValues,
|
||||
styleRecords,
|
||||
topologyResult,
|
||||
resolvedSubjects
|
||||
)
|
||||
|
||||
// Determine overall status
|
||||
const hasErrors = diagnostics.some((d) => d.severity === 'error')
|
||||
const hasWarnings = diagnostics.some((d) => d.severity === 'warning')
|
||||
const status: ExtractorResponse['status'] = hasErrors ? 'error' : hasWarnings ? 'partial' : 'ok'
|
||||
|
||||
// Add provenance for extraction steps
|
||||
let factId = 0
|
||||
const provenance: ProvenanceEntry[] = []
|
||||
for (const step of traceSteps) {
|
||||
provenance.push({
|
||||
factId: factId++,
|
||||
extractionStepId: step.stepId,
|
||||
sourceKind: 1, // CDP protocol
|
||||
sourceRef: step.stepId,
|
||||
})
|
||||
}
|
||||
snapshot.provenance = provenance
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
status,
|
||||
snapshot,
|
||||
snapshots: [snapshot],
|
||||
diagnostics,
|
||||
extractionTrace: {
|
||||
steps: traceSteps,
|
||||
timings: traceTimings,
|
||||
protocolCalls: traceCalls,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a geometry world snapshot from extracted raw data.
|
||||
*/
|
||||
function buildSnapshot(
|
||||
request: ExtractorRequest,
|
||||
dom: DOMExtractionResult,
|
||||
boxes: BoxRecord[],
|
||||
visualBoxes: BoxRecord[],
|
||||
fragments: FragmentRecord[],
|
||||
transforms: TransformRecord[],
|
||||
matrices: number[],
|
||||
styles: StyleRecord[],
|
||||
topology: TopologyExtractionResult,
|
||||
resolvedSubjects: Array<{ id: string; selector: string; backendNodeId: number; nodeId: number }>
|
||||
): GeometryWorldSnapshot {
|
||||
// Build subjects table
|
||||
const subjectIds: number[] = []
|
||||
const domNodeIds: number[] = []
|
||||
const subjectKinds: number[] = []
|
||||
const primaryBoxIds: number[] = []
|
||||
const firstFragmentIds: number[] = []
|
||||
const fragmentCounts: number[] = []
|
||||
const firstTextRunIds: number[] = []
|
||||
const textRunCounts: number[] = []
|
||||
|
||||
const boxBySubject = new Map<number, number>()
|
||||
for (const b of boxes) {
|
||||
boxBySubject.set(b.subjectId, b.boxId)
|
||||
}
|
||||
|
||||
const fragmentsBySubject = new Map<number, number[]>()
|
||||
for (const f of fragments) {
|
||||
if (!fragmentsBySubject.has(f.subjectId)) {
|
||||
fragmentsBySubject.set(f.subjectId, [])
|
||||
}
|
||||
fragmentsBySubject.get(f.subjectId)!.push(f.fragmentId)
|
||||
}
|
||||
|
||||
for (let i = 0; i < resolvedSubjects.length; i++) {
|
||||
const rs = resolvedSubjects[i]
|
||||
subjectIds.push(i)
|
||||
domNodeIds.push(rs.backendNodeId)
|
||||
subjectKinds.push(1) // element
|
||||
primaryBoxIds.push(boxBySubject.get(i) ?? 0)
|
||||
|
||||
const frags = fragmentsBySubject.get(i)
|
||||
if (frags && frags.length > 0) {
|
||||
firstFragmentIds.push(frags[0])
|
||||
fragmentCounts.push(frags.length)
|
||||
} else {
|
||||
firstFragmentIds.push(0)
|
||||
fragmentCounts.push(0)
|
||||
}
|
||||
|
||||
firstTextRunIds.push(0)
|
||||
textRunCounts.push(0)
|
||||
}
|
||||
|
||||
// Build boxes table arrays
|
||||
const boxesTable = {
|
||||
boxId: boxes.map((b) => b.boxId),
|
||||
subjectId: boxes.map((b) => b.subjectId),
|
||||
frameId: boxes.map((b) => b.frameId),
|
||||
borderLeft: boxes.map((b) => b.borderLeft),
|
||||
borderTop: boxes.map((b) => b.borderTop),
|
||||
borderRight: boxes.map((b) => b.borderRight),
|
||||
borderBottom: boxes.map((b) => b.borderBottom),
|
||||
paddingLeft: boxes.map((b) => b.paddingLeft),
|
||||
paddingTop: boxes.map((b) => b.paddingTop),
|
||||
paddingRight: boxes.map((b) => b.paddingRight),
|
||||
paddingBottom: boxes.map((b) => b.paddingBottom),
|
||||
contentLeft: boxes.map((b) => b.contentLeft),
|
||||
contentTop: boxes.map((b) => b.contentTop),
|
||||
contentRight: boxes.map((b) => b.contentRight),
|
||||
contentBottom: boxes.map((b) => b.contentBottom),
|
||||
}
|
||||
|
||||
const visualBoxesTable = {
|
||||
boxId: visualBoxes.map((b) => b.boxId),
|
||||
subjectId: visualBoxes.map((b) => b.subjectId),
|
||||
frameId: visualBoxes.map((b) => b.frameId),
|
||||
borderLeft: visualBoxes.map((b) => b.borderLeft),
|
||||
borderTop: visualBoxes.map((b) => b.borderTop),
|
||||
borderRight: visualBoxes.map((b) => b.borderRight),
|
||||
borderBottom: visualBoxes.map((b) => b.borderBottom),
|
||||
paddingLeft: visualBoxes.map((b) => b.paddingLeft),
|
||||
paddingTop: visualBoxes.map((b) => b.paddingTop),
|
||||
paddingRight: visualBoxes.map((b) => b.paddingRight),
|
||||
paddingBottom: visualBoxes.map((b) => b.paddingBottom),
|
||||
contentLeft: visualBoxes.map((b) => b.contentLeft),
|
||||
contentTop: visualBoxes.map((b) => b.contentTop),
|
||||
contentRight: visualBoxes.map((b) => b.contentRight),
|
||||
contentBottom: visualBoxes.map((b) => b.contentBottom),
|
||||
}
|
||||
|
||||
const fragmentsTable = {
|
||||
fragmentId: fragments.map((f) => f.fragmentId),
|
||||
subjectId: fragments.map((f) => f.subjectId),
|
||||
fragmentKind: fragments.map((f) => f.fragmentKind),
|
||||
boxLeft: fragments.map((f) => f.boxLeft),
|
||||
boxTop: fragments.map((f) => f.boxTop),
|
||||
boxRight: fragments.map((f) => f.boxRight),
|
||||
boxBottom: fragments.map((f) => f.boxBottom),
|
||||
lineIndex: fragments.map((f) => f.lineIndex),
|
||||
flowIndex: fragments.map((f) => f.flowIndex),
|
||||
parentFragmentId: fragments.map((f) => f.parentFragmentId),
|
||||
}
|
||||
|
||||
const transformsTable = {
|
||||
transformId: transforms.map((t) => t.transformId),
|
||||
subjectId: transforms.map((t) => t.subjectId),
|
||||
matrixStart: transforms.map((t) => t.matrixStart),
|
||||
matrixLength: transforms.map((t) => t.matrixLength),
|
||||
originX: transforms.map((t) => t.originX),
|
||||
originY: transforms.map((t) => t.originY),
|
||||
}
|
||||
|
||||
const stylesTable = {
|
||||
subjectId: styles.map((s) => s.subjectId),
|
||||
display: styles.map((s) => s.display),
|
||||
position: styles.map((s) => s.position),
|
||||
zIndexKind: styles.map((s) => s.zIndexKind),
|
||||
zIndexValue: styles.map((s) => s.zIndexValue),
|
||||
overflowX: styles.map((s) => s.overflowX),
|
||||
overflowY: styles.map((s) => s.overflowY),
|
||||
opacity: styles.map((s) => s.opacity),
|
||||
visibility: styles.map((s) => s.visibility),
|
||||
containFlags: styles.map((s) => s.containFlags),
|
||||
pointerEvents: styles.map((s) => s.pointerEvents),
|
||||
lineHeight: styles.map((s) => s.lineHeight),
|
||||
fontFamilyStringId: styles.map((s) => s.fontFamilyStringId),
|
||||
fontSize: styles.map((s) => s.fontSize),
|
||||
fontWeight: styles.map((s) => s.fontWeight),
|
||||
}
|
||||
|
||||
return {
|
||||
sceneId: request.requestId,
|
||||
snapshotId: 'default',
|
||||
env: {
|
||||
viewportWidth: request.env.viewportWidth,
|
||||
viewportHeight: request.env.viewportHeight,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: request.env.colorScheme,
|
||||
pointer: request.env.pointer,
|
||||
hover: false,
|
||||
reducedMotion: 'no-preference',
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: request.sceneTarget.url,
|
||||
browserName: 'chromium',
|
||||
browserVersion: '',
|
||||
engine: 'chromium-cdp',
|
||||
extractedAt: new Date().toISOString(),
|
||||
},
|
||||
strings: dom.strings,
|
||||
subjects: {
|
||||
ids: subjectIds,
|
||||
domNodeId: domNodeIds,
|
||||
subjectKind: subjectKinds,
|
||||
primaryBoxId: primaryBoxIds,
|
||||
firstFragmentId: firstFragmentIds,
|
||||
fragmentCount: fragmentCounts,
|
||||
firstTextRunId: firstTextRunIds,
|
||||
textRunCount: textRunCounts,
|
||||
},
|
||||
dom,
|
||||
frames: {
|
||||
frameId: [],
|
||||
frameKind: [],
|
||||
ownerSubjectId: [],
|
||||
parentFrameId: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
axisMatrixStart: [],
|
||||
clipRectId: [],
|
||||
scrollContainerId: [],
|
||||
writingMode: [],
|
||||
},
|
||||
matrices: { values: matrices },
|
||||
rects: {
|
||||
rectId: [],
|
||||
left: [],
|
||||
top: [],
|
||||
right: [],
|
||||
bottom: [],
|
||||
},
|
||||
boxes: boxesTable,
|
||||
visualBoxes: visualBoxesTable,
|
||||
fragments: fragmentsTable,
|
||||
transforms: transformsTable,
|
||||
styles: stylesTable,
|
||||
topology: topology.topology,
|
||||
scroll: topology.scroll,
|
||||
clipping: topology.clipping,
|
||||
provenance: [],
|
||||
confidence: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty snapshot for error cases.
|
||||
*/
|
||||
function createEmptySnapshot(request: ExtractorRequest): GeometryWorldSnapshot {
|
||||
return {
|
||||
sceneId: request.requestId,
|
||||
snapshotId: 'default',
|
||||
env: {
|
||||
viewportWidth: request.env.viewportWidth,
|
||||
viewportHeight: request.env.viewportHeight,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: request.env.colorScheme,
|
||||
pointer: request.env.pointer,
|
||||
hover: false,
|
||||
reducedMotion: 'no-preference',
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: request.sceneTarget.url,
|
||||
browserName: 'chromium',
|
||||
browserVersion: '',
|
||||
engine: 'chromium-cdp',
|
||||
extractedAt: new Date().toISOString(),
|
||||
},
|
||||
strings: [],
|
||||
subjects: {
|
||||
ids: [],
|
||||
domNodeId: [],
|
||||
subjectKind: [],
|
||||
primaryBoxId: [],
|
||||
firstFragmentId: [],
|
||||
fragmentCount: [],
|
||||
firstTextRunId: [],
|
||||
textRunCount: [],
|
||||
},
|
||||
dom: { nodes: [], strings: [], nodeIdToIndex: new Map(), rootIndex: 0 },
|
||||
frames: {
|
||||
frameId: [],
|
||||
frameKind: [],
|
||||
ownerSubjectId: [],
|
||||
parentFrameId: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
axisMatrixStart: [],
|
||||
clipRectId: [],
|
||||
scrollContainerId: [],
|
||||
writingMode: [],
|
||||
},
|
||||
matrices: { values: [] },
|
||||
rects: {
|
||||
rectId: [],
|
||||
left: [],
|
||||
top: [],
|
||||
right: [],
|
||||
bottom: [],
|
||||
},
|
||||
boxes: {
|
||||
boxId: [],
|
||||
subjectId: [],
|
||||
frameId: [],
|
||||
borderLeft: [],
|
||||
borderTop: [],
|
||||
borderRight: [],
|
||||
borderBottom: [],
|
||||
paddingLeft: [],
|
||||
paddingTop: [],
|
||||
paddingRight: [],
|
||||
paddingBottom: [],
|
||||
contentLeft: [],
|
||||
contentTop: [],
|
||||
contentRight: [],
|
||||
contentBottom: [],
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: [],
|
||||
subjectId: [],
|
||||
frameId: [],
|
||||
borderLeft: [],
|
||||
borderTop: [],
|
||||
borderRight: [],
|
||||
borderBottom: [],
|
||||
paddingLeft: [],
|
||||
paddingTop: [],
|
||||
paddingRight: [],
|
||||
paddingBottom: [],
|
||||
contentLeft: [],
|
||||
contentTop: [],
|
||||
contentRight: [],
|
||||
contentBottom: [],
|
||||
},
|
||||
fragments: {
|
||||
fragmentId: [],
|
||||
subjectId: [],
|
||||
fragmentKind: [],
|
||||
boxLeft: [],
|
||||
boxTop: [],
|
||||
boxRight: [],
|
||||
boxBottom: [],
|
||||
lineIndex: [],
|
||||
flowIndex: [],
|
||||
parentFragmentId: [],
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
styles: {
|
||||
subjectId: [],
|
||||
display: [],
|
||||
position: [],
|
||||
zIndexKind: [],
|
||||
zIndexValue: [],
|
||||
overflowX: [],
|
||||
overflowY: [],
|
||||
opacity: [],
|
||||
visibility: [],
|
||||
containFlags: [],
|
||||
pointerEvents: [],
|
||||
lineHeight: [],
|
||||
fontFamilyStringId: [],
|
||||
fontSize: [],
|
||||
fontWeight: [],
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
provenance: [],
|
||||
confidence: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Geometry Extraction
|
||||
*
|
||||
* Captures box models, fragment boxes, and transform data for DOM nodes
|
||||
* using CDP DOM.getBoxModel and Runtime.evaluate.
|
||||
*
|
||||
* We batch requests where possible: gather all target node IDs first,
|
||||
* then request box models in bulk via parallel sends.
|
||||
*/
|
||||
|
||||
import type { CDPSession } from './session.js'
|
||||
|
||||
/**
|
||||
* Raw CDP box model for a single node.
|
||||
*/
|
||||
export interface CDPBoxModel {
|
||||
content: number[]
|
||||
padding: number[]
|
||||
border: number[]
|
||||
margin: number[]
|
||||
width: number
|
||||
height: number
|
||||
shapeOutside?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical box geometry record for the geometry world.
|
||||
*/
|
||||
export interface BoxRecord {
|
||||
boxId: number
|
||||
subjectId: number
|
||||
frameId: number
|
||||
borderLeft: number
|
||||
borderTop: number
|
||||
borderRight: number
|
||||
borderBottom: number
|
||||
paddingLeft: number
|
||||
paddingTop: number
|
||||
paddingRight: number
|
||||
paddingBottom: number
|
||||
contentLeft: number
|
||||
contentTop: number
|
||||
contentRight: number
|
||||
contentBottom: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragment record for multi-line or multi-column content.
|
||||
*/
|
||||
export interface FragmentRecord {
|
||||
fragmentId: number
|
||||
subjectId: number
|
||||
fragmentKind: number
|
||||
boxLeft: number
|
||||
boxTop: number
|
||||
boxRight: number
|
||||
boxBottom: number
|
||||
lineIndex: number
|
||||
flowIndex: number
|
||||
parentFragmentId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform record for a subject.
|
||||
*/
|
||||
export interface TransformRecord {
|
||||
transformId: number
|
||||
subjectId: number
|
||||
matrixStart: number
|
||||
matrixLength: number
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry extraction result.
|
||||
*/
|
||||
export interface GeometryExtractionResult {
|
||||
boxes: BoxRecord[]
|
||||
fragments: FragmentRecord[]
|
||||
transforms: TransformRecord[]
|
||||
/** Flat array of matrix values (6 elements per 2D matrix) */
|
||||
matrices: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract box models for a list of node IDs.
|
||||
*
|
||||
* Sends DOM.getBoxModel for each node in parallel to minimize
|
||||
* round-trip latency.
|
||||
*/
|
||||
export async function extractBoxModels(
|
||||
session: CDPSession,
|
||||
backendNodeIds: number[],
|
||||
subjectIds: number[]
|
||||
): Promise<{ boxes: BoxRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
|
||||
const boxes: BoxRecord[] = []
|
||||
const errors: Array<{ backendNodeId: number; reason: string }> = []
|
||||
|
||||
// Batch: request all box models in parallel using backendNodeId.
|
||||
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
|
||||
try {
|
||||
const response = await session.send<{ model: CDPBoxModel }>('DOM.getBoxModel', {
|
||||
backendNodeId,
|
||||
})
|
||||
return { status: 'ok' as const, model: response.model, backendNodeId, idx }
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
return { status: 'error' as const, error: reason, backendNodeId, idx }
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'error') {
|
||||
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const model = result.model
|
||||
const subjectId = subjectIds[result.idx]
|
||||
|
||||
// CDP returns quads as [x1,y1,x2,y2,x3,y3,x4,y4].
|
||||
// For axis-aligned boxes, we use the first two points.
|
||||
const borderQuad = model.border
|
||||
const paddingQuad = model.padding
|
||||
const contentQuad = model.content
|
||||
|
||||
boxes.push({
|
||||
boxId: boxes.length,
|
||||
subjectId,
|
||||
frameId: 0, // Frame resolution happens later.
|
||||
borderLeft: Math.min(borderQuad[0], borderQuad[2]),
|
||||
borderTop: Math.min(borderQuad[1], borderQuad[5]),
|
||||
borderRight: Math.max(borderQuad[4], borderQuad[6]),
|
||||
borderBottom: Math.max(borderQuad[3], borderQuad[7]),
|
||||
paddingLeft: Math.min(paddingQuad[0], paddingQuad[2]),
|
||||
paddingTop: Math.min(paddingQuad[1], paddingQuad[5]),
|
||||
paddingRight: Math.max(paddingQuad[4], paddingQuad[6]),
|
||||
paddingBottom: Math.max(paddingQuad[3], paddingQuad[7]),
|
||||
contentLeft: Math.min(contentQuad[0], contentQuad[2]),
|
||||
contentTop: Math.min(contentQuad[1], contentQuad[5]),
|
||||
contentRight: Math.max(contentQuad[4], contentQuad[6]),
|
||||
contentBottom: Math.max(contentQuad[3], contentQuad[7]),
|
||||
})
|
||||
}
|
||||
|
||||
return { boxes, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client rect fragments for a list of backend node IDs.
|
||||
*
|
||||
* Resolves each backendNodeId to a RemoteObject via DOM.resolveNode,
|
||||
* then uses Runtime.callFunctionOn to invoke getClientRects() directly
|
||||
* on the element. This avoids DOM mutation and fragile attribute queries.
|
||||
*/
|
||||
export async function extractFragments(
|
||||
session: CDPSession,
|
||||
backendNodeIds: number[],
|
||||
subjectIds: number[]
|
||||
): Promise<{ fragments: FragmentRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
|
||||
const fragments: FragmentRecord[] = []
|
||||
const errors: Array<{ backendNodeId: number; reason: string }> = []
|
||||
|
||||
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
|
||||
try {
|
||||
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
|
||||
backendNodeId,
|
||||
})
|
||||
const response = await session.send<{
|
||||
result: {
|
||||
value: Array<{ left: number; top: number; right: number; bottom: number }>
|
||||
}
|
||||
}>('Runtime.callFunctionOn', {
|
||||
objectId: resolved.object.objectId,
|
||||
functionDeclaration: `function() {
|
||||
const rects = this.getClientRects()
|
||||
const boxes = []
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
boxes.push({
|
||||
left: rects[i].left,
|
||||
top: rects[i].top,
|
||||
right: rects[i].right,
|
||||
bottom: rects[i].bottom,
|
||||
})
|
||||
}
|
||||
return boxes
|
||||
}`,
|
||||
returnByValue: true,
|
||||
})
|
||||
return { status: 'ok' as const, boxes: response.result.value, backendNodeId, idx }
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
return { status: 'error' as const, error: reason, backendNodeId, idx }
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'error') {
|
||||
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const subjectId = subjectIds[result.idx]
|
||||
for (let j = 0; j < (result.boxes?.length ?? 0); j++) {
|
||||
const box = result.boxes![j]
|
||||
fragments.push({
|
||||
fragmentId: fragments.length,
|
||||
subjectId,
|
||||
fragmentKind: 1, // 1 = client rect fragment
|
||||
boxLeft: box.left,
|
||||
boxTop: box.top,
|
||||
boxRight: box.right,
|
||||
boxBottom: box.bottom,
|
||||
lineIndex: j,
|
||||
flowIndex: 0,
|
||||
parentFragmentId: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { fragments, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract visual bounding boxes for a list of backend node IDs.
|
||||
*
|
||||
* Uses getBoundingClientRect() via Runtime.callFunctionOn to obtain
|
||||
* post-transform viewport coordinates. These are stored in visualBoxes
|
||||
* and represent the effective rendered position after all CSS transforms.
|
||||
*/
|
||||
export async function extractVisualBoxes(
|
||||
session: CDPSession,
|
||||
backendNodeIds: number[],
|
||||
subjectIds: number[]
|
||||
): Promise<{ boxes: BoxRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
|
||||
const boxes: BoxRecord[] = []
|
||||
const errors: Array<{ backendNodeId: number; reason: string }> = []
|
||||
|
||||
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
|
||||
try {
|
||||
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
|
||||
backendNodeId,
|
||||
})
|
||||
const response = await session.send<{
|
||||
result: {
|
||||
value: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
}>('Runtime.callFunctionOn', {
|
||||
objectId: resolved.object.objectId,
|
||||
functionDeclaration: `function() {
|
||||
const rect = this.getBoundingClientRect()
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}
|
||||
}`,
|
||||
returnByValue: true,
|
||||
})
|
||||
return { status: 'ok' as const, rect: response.result.value, backendNodeId, idx }
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
return { status: 'error' as const, error: reason, backendNodeId, idx }
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'error') {
|
||||
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const rect = result.rect
|
||||
const subjectId = subjectIds[result.idx]
|
||||
|
||||
boxes.push({
|
||||
boxId: boxes.length,
|
||||
subjectId,
|
||||
frameId: 0,
|
||||
borderLeft: rect.left,
|
||||
borderTop: rect.top,
|
||||
borderRight: rect.right,
|
||||
borderBottom: rect.bottom,
|
||||
paddingLeft: rect.left,
|
||||
paddingTop: rect.top,
|
||||
paddingRight: rect.right,
|
||||
paddingBottom: rect.bottom,
|
||||
contentLeft: rect.left,
|
||||
contentTop: rect.top,
|
||||
contentRight: rect.right,
|
||||
contentBottom: rect.bottom,
|
||||
})
|
||||
}
|
||||
|
||||
return { boxes, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CSS transform matrices for a list of node IDs.
|
||||
*
|
||||
* Returns the computed 2D transform matrix (6 values) and origin
|
||||
* for each node that has a transform.
|
||||
*/
|
||||
export async function extractTransforms(
|
||||
session: CDPSession,
|
||||
backendNodeIds: number[],
|
||||
subjectIds: number[]
|
||||
): Promise<{
|
||||
transforms: TransformRecord[]
|
||||
matrices: number[]
|
||||
errors: Array<{ backendNodeId: number; reason: string }>
|
||||
}> {
|
||||
const transforms: TransformRecord[] = []
|
||||
const matrices: number[] = []
|
||||
const errors: Array<{ backendNodeId: number; reason: string }> = []
|
||||
|
||||
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
|
||||
try {
|
||||
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
|
||||
backendNodeId,
|
||||
})
|
||||
const response = await session.send<{
|
||||
result: {
|
||||
value: {
|
||||
matrix: number[]
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
}
|
||||
}>('Runtime.callFunctionOn', {
|
||||
objectId: resolved.object.objectId,
|
||||
functionDeclaration: `function() {
|
||||
const style = window.getComputedStyle(this)
|
||||
const transform = style.transform
|
||||
let matrix = [1, 0, 0, 1, 0, 0]
|
||||
if (transform && transform !== 'none') {
|
||||
const match = transform.match(/matrix\\(([^)]+)\\)/)
|
||||
if (match) {
|
||||
matrix = match[1].split(',').map(Number)
|
||||
}
|
||||
}
|
||||
const origin = style.transformOrigin.split(' ').map(parseFloat)
|
||||
return {
|
||||
matrix,
|
||||
originX: origin[0] || 0,
|
||||
originY: origin[1] || 0,
|
||||
}
|
||||
}`,
|
||||
returnByValue: true,
|
||||
})
|
||||
return { status: 'ok' as const, data: response.result.value, backendNodeId, idx }
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
return { status: 'error' as const, error: reason, backendNodeId, idx }
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'error') {
|
||||
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const matrix = result.data.matrix ?? [1, 0, 0, 1, 0, 0]
|
||||
const matrixStart = matrices.length
|
||||
matrices.push(...matrix)
|
||||
|
||||
transforms.push({
|
||||
transformId: transforms.length,
|
||||
subjectId: subjectIds[result.idx],
|
||||
matrixStart,
|
||||
matrixLength: matrix.length,
|
||||
originX: result.data.originX ?? 0,
|
||||
originY: result.data.originY ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
return { transforms, matrices, errors }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* imhotep-cdp — Chrome DevTools Protocol extraction layer
|
||||
*
|
||||
* Captures browser truth for geometry, topology, and style facts.
|
||||
* Returns raw browser data matching the geometry world schema.
|
||||
*/
|
||||
|
||||
// Session management
|
||||
export {
|
||||
CDPSessionManager,
|
||||
createPlaywrightCDPSession,
|
||||
createSessionManager,
|
||||
type CDPSession,
|
||||
type CDPSessionFactory,
|
||||
} from './session.js'
|
||||
|
||||
// DOM extraction
|
||||
export {
|
||||
extractDOM,
|
||||
resolveSelector,
|
||||
resolveOneSelector,
|
||||
type CDPDOMNode,
|
||||
type DOMNodeRecord,
|
||||
type DOMExtractionResult,
|
||||
} from './dom.js'
|
||||
|
||||
// Geometry extraction
|
||||
export {
|
||||
extractBoxModels,
|
||||
extractFragments,
|
||||
extractTransforms,
|
||||
type BoxRecord,
|
||||
type FragmentRecord,
|
||||
type TransformRecord,
|
||||
type GeometryExtractionResult,
|
||||
type CDPBoxModel,
|
||||
} from './geometry.js'
|
||||
|
||||
// Style extraction
|
||||
export {
|
||||
extractStyles,
|
||||
DisplayEnum,
|
||||
PositionEnum,
|
||||
OverflowEnum,
|
||||
VisibilityEnum,
|
||||
PointerEventsEnum,
|
||||
type StyleRecord,
|
||||
type StyleExtractionConfig,
|
||||
} from './styles.js'
|
||||
|
||||
// Topology extraction
|
||||
export {
|
||||
extractTopology,
|
||||
type ScrollRecord,
|
||||
type ClippingRecord,
|
||||
type TopologyRecord,
|
||||
type TopologyExtractionResult,
|
||||
} from './topology.js'
|
||||
|
||||
// Canonical adapter
|
||||
export {
|
||||
adaptSnapshotToCanonical,
|
||||
type CanonicalGeometryWorld,
|
||||
} from './canonical-adapter.js'
|
||||
|
||||
// Main orchestrator
|
||||
export {
|
||||
CDPExtractor,
|
||||
type ExtractorRequest,
|
||||
type ExtractorResponse,
|
||||
type ExtractorDiagnostic,
|
||||
type GeometryWorldSnapshot,
|
||||
type ProvenanceEntry,
|
||||
type ConfidenceEntry,
|
||||
type ExtractionTraceEntry,
|
||||
} from './extractor.js'
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* CDP Session Management
|
||||
*
|
||||
* Wraps Playwright's CDP session access to provide a typed, injectable
|
||||
* interface for sending Chrome DevTools Protocol commands.
|
||||
*
|
||||
* Design: dependency injection over optional imports. The extractor receives
|
||||
* a session factory rather than importing Playwright directly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal CDP session interface. Any object that can send CDP commands
|
||||
* and return JSON-like responses satisfies this contract.
|
||||
*/
|
||||
export interface CDPSession {
|
||||
send<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>
|
||||
on(event: string, handler: (payload: unknown) => void): void
|
||||
off(event: string, handler: (payload: unknown) => void): void
|
||||
detach(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that creates a CDP session for a given Playwright page.
|
||||
* This is injected so the package does not hard-depend on Playwright.
|
||||
*/
|
||||
export type CDPSessionFactory = (pageRef: unknown) => Promise<CDPSession>
|
||||
|
||||
/**
|
||||
* Playwright-backed session factory implementation.
|
||||
*
|
||||
* Usage:
|
||||
* const session = await createPlaywrightCDPSession(page)
|
||||
*
|
||||
* Where `page` is a Playwright Page object.
|
||||
*/
|
||||
export async function createPlaywrightCDPSession(
|
||||
page: unknown
|
||||
): Promise<CDPSession> {
|
||||
// Playwright pages expose context() and a CDP session method.
|
||||
// We access it dynamically to avoid a hard import dependency.
|
||||
const pwPage = page as {
|
||||
context: () => {
|
||||
newCDPSession: (p: unknown) => Promise<CDPSession>
|
||||
}
|
||||
}
|
||||
|
||||
const context = pwPage.context()
|
||||
const session = await context.newCDPSession(page)
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Session manager that owns the lifecycle of a CDP session.
|
||||
*
|
||||
* Handles attach, detach, and basic error recovery.
|
||||
* Caches document root nodeId and enabled domains to minimize round-trips.
|
||||
*/
|
||||
export class CDPSessionManager {
|
||||
private session: CDPSession | null = null
|
||||
private attachPromise: Promise<CDPSession> | null = null
|
||||
private readonly factory: CDPSessionFactory
|
||||
private readonly pageRef: unknown
|
||||
private documentRootNodeId: number | null = null
|
||||
private enabledDomains = new Set<string>()
|
||||
|
||||
constructor(factory: CDPSessionFactory, pageRef: unknown) {
|
||||
this.factory = factory
|
||||
this.pageRef = pageRef
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a session is attached. Idempotent and concurrency-safe.
|
||||
*/
|
||||
async attach(): Promise<CDPSession> {
|
||||
if (this.session) return this.session
|
||||
if (this.attachPromise) return this.attachPromise
|
||||
this.attachPromise = this.factory(this.pageRef).then((s) => {
|
||||
this.session = s
|
||||
this.documentRootNodeId = null
|
||||
this.enabledDomains.clear()
|
||||
return s
|
||||
})
|
||||
return this.attachPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached document root nodeId, or fetch and cache it.
|
||||
*/
|
||||
async getDocumentRootNodeId(): Promise<number> {
|
||||
if (this.documentRootNodeId !== null) {
|
||||
return this.documentRootNodeId
|
||||
}
|
||||
const s = await this.attach()
|
||||
const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument')
|
||||
this.documentRootNodeId = result.root.nodeId
|
||||
return this.documentRootNodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a CDP domain if not already enabled.
|
||||
*/
|
||||
async enableDomain(domain: string): Promise<void> {
|
||||
if (this.enabledDomains.has(domain)) {
|
||||
return
|
||||
}
|
||||
const s = await this.attach()
|
||||
await s.send(`${domain}.enable`)
|
||||
this.enabledDomains.add(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session, throwing if not attached.
|
||||
*/
|
||||
getSession(): CDPSession {
|
||||
if (!this.session) {
|
||||
throw new Error('CDP session not attached. Call attach() first.')
|
||||
}
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a CDP command, automatically attaching if needed.
|
||||
*/
|
||||
async send<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const s = await this.attach()
|
||||
return s.send<T>(method, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach and clean up the session.
|
||||
*/
|
||||
async detach(): Promise<void> {
|
||||
if (this.session) {
|
||||
await this.session.detach()
|
||||
this.session = null
|
||||
}
|
||||
this.attachPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenient builder for a manager using the Playwright factory.
|
||||
*/
|
||||
export function createSessionManager(page: unknown): CDPSessionManager {
|
||||
return new CDPSessionManager(createPlaywrightCDPSession, page)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Computed Style Extraction
|
||||
*
|
||||
* Captures only the style properties required by the active extraction plan.
|
||||
* Uses Runtime.callFunctionOn with objectIds resolved from backendNodeIds
|
||||
* to avoid CSS domain nodeId validity issues.
|
||||
*/
|
||||
|
||||
import type { CDPSession } from './session.js'
|
||||
|
||||
/**
|
||||
* Compact style record for a subject.
|
||||
* Mirrors the geometry world `styles` table schema.
|
||||
*/
|
||||
export interface StyleRecord {
|
||||
subjectId: number
|
||||
display: number
|
||||
position: number
|
||||
zIndexKind: number
|
||||
zIndexValue: number
|
||||
overflowX: number
|
||||
overflowY: number
|
||||
opacity: number
|
||||
visibility: number
|
||||
containFlags: number
|
||||
pointerEvents: number
|
||||
lineHeight: number
|
||||
fontFamilyStringId: number
|
||||
fontSize: number
|
||||
fontWeight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum mappings for compact storage.
|
||||
*/
|
||||
export const DisplayEnum: Record<string, number> = {
|
||||
none: 1,
|
||||
block: 2,
|
||||
inline: 3,
|
||||
'inline-block': 4,
|
||||
flex: 5,
|
||||
'inline-flex': 6,
|
||||
grid: 7,
|
||||
'inline-grid': 8,
|
||||
table: 9,
|
||||
'table-cell': 10,
|
||||
contents: 11,
|
||||
other: 99,
|
||||
}
|
||||
|
||||
export const PositionEnum: Record<string, number> = {
|
||||
static: 1,
|
||||
relative: 2,
|
||||
absolute: 3,
|
||||
fixed: 4,
|
||||
sticky: 5,
|
||||
}
|
||||
|
||||
export const OverflowEnum: Record<string, number> = {
|
||||
visible: 1,
|
||||
hidden: 2,
|
||||
clip: 3,
|
||||
scroll: 4,
|
||||
auto: 5,
|
||||
}
|
||||
|
||||
export const VisibilityEnum: Record<string, number> = {
|
||||
visible: 1,
|
||||
hidden: 2,
|
||||
collapse: 3,
|
||||
}
|
||||
|
||||
export const PointerEventsEnum: Record<string, number> = {
|
||||
auto: 1,
|
||||
none: 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Style extraction configuration.
|
||||
*/
|
||||
export interface StyleExtractionConfig {
|
||||
/** If true, extract all known style properties */
|
||||
all?: boolean
|
||||
/** Specific property names to extract */
|
||||
properties?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract computed styles for a list of backend node IDs.
|
||||
*
|
||||
* Resolves each backendNodeId to an objectId, then uses
|
||||
* Runtime.callFunctionOn to read computed styles via getComputedStyle
|
||||
* in the browser. This avoids CSS domain nodeId validity issues.
|
||||
*/
|
||||
export async function extractStyles(
|
||||
session: CDPSession,
|
||||
backendNodeIds: number[],
|
||||
subjectIds: number[],
|
||||
config: StyleExtractionConfig,
|
||||
strings: string[]
|
||||
): Promise<{
|
||||
styles: StyleRecord[]
|
||||
errors: Array<{ backendNodeId: number; reason: string }>
|
||||
strings: string[]
|
||||
}> {
|
||||
const styles: StyleRecord[] = []
|
||||
const errors: Array<{ backendNodeId: number; reason: string }> = []
|
||||
const stringIndex = new Map<string, number>()
|
||||
strings.forEach((s, i) => stringIndex.set(s, i))
|
||||
|
||||
function getStringId(value: string): number {
|
||||
if (stringIndex.has(value)) return stringIndex.get(value)!
|
||||
const id = strings.length
|
||||
strings.push(value)
|
||||
stringIndex.set(value, id)
|
||||
return id
|
||||
}
|
||||
|
||||
// Determine which properties we need.
|
||||
const wantAll = config.all ?? true
|
||||
const wantProps = new Set(config.properties ?? [])
|
||||
|
||||
// Resolve backendNodeIds to objectIds
|
||||
const objectIds: string[] = []
|
||||
for (const backendNodeId of backendNodeIds) {
|
||||
try {
|
||||
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
|
||||
backendNodeId,
|
||||
})
|
||||
objectIds.push(resolved.object.objectId)
|
||||
} catch {
|
||||
objectIds.push('')
|
||||
}
|
||||
}
|
||||
|
||||
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
|
||||
const objectId = objectIds[idx]
|
||||
if (!objectId) {
|
||||
return { status: 'error' as const, error: 'Failed to resolve backendNodeId to objectId', backendNodeId, idx }
|
||||
}
|
||||
try {
|
||||
const response = await session.send<{
|
||||
result: { value: Record<string, string> }
|
||||
}>('Runtime.callFunctionOn', {
|
||||
objectId,
|
||||
functionDeclaration: `function() {
|
||||
const s = window.getComputedStyle(this);
|
||||
return {
|
||||
display: s.display,
|
||||
position: s.position,
|
||||
zIndex: s.zIndex,
|
||||
overflowX: s.overflowX,
|
||||
overflowY: s.overflowY,
|
||||
opacity: s.opacity,
|
||||
visibility: s.visibility,
|
||||
contain: s.contain,
|
||||
pointerEvents: s.pointerEvents,
|
||||
lineHeight: s.lineHeight,
|
||||
fontFamily: s.fontFamily,
|
||||
fontSize: s.fontSize,
|
||||
fontWeight: s.fontWeight,
|
||||
};
|
||||
}`,
|
||||
returnByValue: true,
|
||||
})
|
||||
return { status: 'ok' as const, computedStyle: response.result.value, backendNodeId, idx }
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
return { status: 'error' as const, error: reason, backendNodeId, idx }
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'error') {
|
||||
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
|
||||
continue
|
||||
}
|
||||
|
||||
const subjectId = subjectIds[result.idx]
|
||||
const map = result.computedStyle
|
||||
|
||||
function get(name: string, defaultValue = ''): string {
|
||||
if (!wantAll && !wantProps.has(name)) return defaultValue
|
||||
return map[name] ?? defaultValue
|
||||
}
|
||||
|
||||
function parseNumber(value: string, defaultValue = 0): number {
|
||||
if (!value || value === 'none' || value === 'auto') return defaultValue
|
||||
const parsed = parseFloat(value)
|
||||
return Number.isNaN(parsed) ? defaultValue : parsed
|
||||
}
|
||||
|
||||
const displayRaw = get('display', 'block')
|
||||
const positionRaw = get('position', 'static')
|
||||
const zIndexRaw = get('zIndex', 'auto')
|
||||
const overflowXRaw = get('overflowX', 'visible')
|
||||
const overflowYRaw = get('overflowY', 'visible')
|
||||
const opacityRaw = get('opacity', '1')
|
||||
const visibilityRaw = get('visibility', 'visible')
|
||||
const containRaw = get('contain', 'none')
|
||||
const pointerEventsRaw = get('pointerEvents', 'auto')
|
||||
const lineHeightRaw = get('lineHeight', 'normal')
|
||||
const fontFamilyRaw = get('fontFamily', '')
|
||||
const fontSizeRaw = get('fontSize', '16px')
|
||||
const fontWeightRaw = get('fontWeight', '400')
|
||||
|
||||
// z-index kind: 0 = auto, 1 = integer
|
||||
const zIndexKind = zIndexRaw === 'auto' ? 0 : 1
|
||||
const zIndexValue = zIndexKind === 1 ? parseInt(zIndexRaw, 10) : 0
|
||||
|
||||
// contain flags bitfield
|
||||
let containFlags = 0
|
||||
if (containRaw !== 'none') {
|
||||
const parts = containRaw.split(' ')
|
||||
for (const part of parts) {
|
||||
if (part === 'layout') containFlags |= 1
|
||||
if (part === 'paint') containFlags |= 2
|
||||
if (part === 'size') containFlags |= 4
|
||||
if (part === 'style') containFlags |= 8
|
||||
if (part === 'strict') containFlags |= 15
|
||||
if (part === 'content') containFlags |= 7
|
||||
}
|
||||
}
|
||||
|
||||
styles.push({
|
||||
subjectId,
|
||||
display: DisplayEnum[displayRaw] ?? DisplayEnum.other,
|
||||
position: PositionEnum[positionRaw] ?? PositionEnum.static,
|
||||
zIndexKind,
|
||||
zIndexValue,
|
||||
overflowX: OverflowEnum[overflowXRaw] ?? OverflowEnum.visible,
|
||||
overflowY: OverflowEnum[overflowYRaw] ?? OverflowEnum.visible,
|
||||
opacity: parseNumber(opacityRaw, 1),
|
||||
visibility: VisibilityEnum[visibilityRaw] ?? VisibilityEnum.visible,
|
||||
containFlags,
|
||||
pointerEvents: PointerEventsEnum[pointerEventsRaw] ?? PointerEventsEnum.auto,
|
||||
lineHeight: parseNumber(lineHeightRaw, 1.2),
|
||||
fontFamilyStringId: getStringId(fontFamilyRaw),
|
||||
fontSize: parseNumber(fontSizeRaw, 16),
|
||||
fontWeight: parseInt(fontWeightRaw, 10) || 400,
|
||||
})
|
||||
}
|
||||
|
||||
return { styles, errors, strings }
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Topology Extraction
|
||||
*
|
||||
* Captures non-planar layout facts: scroll containers, clipping chains,
|
||||
* stacking contexts, containing blocks, and formatting contexts.
|
||||
*
|
||||
* We extract these in bulk via a single Runtime.evaluate script that
|
||||
* walks the DOM and computes topology relationships in the browser,
|
||||
* then returns compact tables.
|
||||
*/
|
||||
|
||||
import type { CDPSession } from './session.js'
|
||||
|
||||
/**
|
||||
* Scroll container record.
|
||||
*/
|
||||
export interface ScrollRecord {
|
||||
containerId: number
|
||||
scrollLeft: number
|
||||
scrollTop: number
|
||||
scrollWidth: number
|
||||
scrollHeight: number
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Clipping chain node.
|
||||
*/
|
||||
export interface ClippingRecord {
|
||||
clipNodeId: number
|
||||
subjectId: number
|
||||
clipKind: number
|
||||
clipLeft: number
|
||||
clipTop: number
|
||||
clipRight: number
|
||||
clipBottom: number
|
||||
parentClipNodeId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Topology relation record.
|
||||
*/
|
||||
export interface TopologyRecord {
|
||||
containingBlockOf: number[]
|
||||
nearestPositionedAncestorOf: number[]
|
||||
scrollContainerOf: number[]
|
||||
stackingContextOf: number[]
|
||||
formattingContextOf: number[]
|
||||
clippingRootOf: number[]
|
||||
paintOrderBucket: number[]
|
||||
paintOrderIndex: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Full topology extraction result.
|
||||
*/
|
||||
export interface TopologyExtractionResult {
|
||||
scroll: ScrollRecord[]
|
||||
clipping: ClippingRecord[]
|
||||
topology: TopologyRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract topology facts for a list of elements.
|
||||
*
|
||||
* Uses a single injected script to compute all topology relationships
|
||||
* in the browser, avoiding per-node round-trips.
|
||||
*/
|
||||
export async function extractTopology(
|
||||
session: CDPSession,
|
||||
backendNodeIds: number[],
|
||||
subjectIds: number[]
|
||||
): Promise<{
|
||||
result: TopologyExtractionResult
|
||||
errors: Array<{ backendNodeId: number; reason: string }>
|
||||
}> {
|
||||
const errors: Array<{ backendNodeId: number; reason: string }> = []
|
||||
|
||||
if (backendNodeIds.length === 0) {
|
||||
return {
|
||||
result: {
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
},
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// Inject temporary data attributes so the topology script can correlate
|
||||
// elements with their backendNodeIds, then remove them afterwards.
|
||||
const objectIds: string[] = []
|
||||
for (const backendNodeId of backendNodeIds) {
|
||||
try {
|
||||
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
|
||||
backendNodeId,
|
||||
})
|
||||
objectIds.push(resolved.object.objectId)
|
||||
} catch {
|
||||
objectIds.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// Set attributes
|
||||
for (let i = 0; i < backendNodeIds.length; i++) {
|
||||
const objectId = objectIds[i]
|
||||
if (!objectId) continue
|
||||
try {
|
||||
await session.send<unknown>('Runtime.callFunctionOn', {
|
||||
objectId,
|
||||
functionDeclaration: `function(id) { this.setAttribute('data-imhotep-backend-id', id) }`,
|
||||
arguments: [{ value: String(backendNodeIds[i]) }],
|
||||
})
|
||||
} catch {
|
||||
// Ignore injection errors
|
||||
}
|
||||
}
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const subjects = Array.from(document.querySelectorAll('[data-imhotep-backend-id]'))
|
||||
const idMap = new Map()
|
||||
for (const el of subjects) {
|
||||
idMap.set(el, parseInt(el.dataset.imhotepBackendId, 10))
|
||||
}
|
||||
|
||||
const results = {
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
}
|
||||
}
|
||||
|
||||
function isScrollContainer(el) {
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.overflowX !== 'visible' || style.overflowY !== 'visible'
|
||||
}
|
||||
|
||||
function isClippingElement(el) {
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.overflowX === 'hidden' || style.overflowX === 'scroll' || style.overflowX === 'auto' ||
|
||||
style.overflowY === 'hidden' || style.overflowY === 'scroll' || style.overflowY === 'auto' ||
|
||||
style.clipPath !== 'none'
|
||||
}
|
||||
|
||||
function createsStackingContext(el) {
|
||||
const style = window.getComputedStyle(el)
|
||||
if (style.position !== 'static' && style.zIndex !== 'auto') return true
|
||||
if (parseFloat(style.opacity) < 1) return true
|
||||
if (style.transform !== 'none') return true
|
||||
if (style.filter !== 'none') return true
|
||||
if (style.clipPath !== 'none') return true
|
||||
if (style.isolation === 'isolate') return true
|
||||
if (style.willChange.includes('transform') || style.willChange.includes('opacity')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isPositioned(el) {
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.position !== 'static'
|
||||
}
|
||||
|
||||
function getContainingBlock(el) {
|
||||
let current = el.parentElement
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current)
|
||||
if (style.position !== 'static') return current
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getNearestPositionedAncestor(el) {
|
||||
let current = el.parentElement
|
||||
while (current) {
|
||||
if (isPositioned(current)) return current
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getScrollContainer(el) {
|
||||
let current = el.parentElement
|
||||
while (current) {
|
||||
if (isScrollContainer(current)) return current
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getClippingRoot(el) {
|
||||
let current = el.parentElement
|
||||
while (current) {
|
||||
if (isClippingElement(current)) return current
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getStackingContextRoot(el) {
|
||||
let current = el.parentElement
|
||||
while (current) {
|
||||
if (createsStackingContext(current)) return current
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function rectFor(el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
|
||||
}
|
||||
|
||||
function getId(el) {
|
||||
return el ? (idMap.get(el) || 0) : 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < subjects.length; i++) {
|
||||
const el = subjects[i]
|
||||
const subjectId = idMap.get(el) || 0
|
||||
|
||||
if (isScrollContainer(el)) {
|
||||
const r = rectFor(el)
|
||||
results.scroll.push({
|
||||
containerId: subjectId,
|
||||
scrollLeft: el.scrollLeft,
|
||||
scrollTop: el.scrollTop,
|
||||
scrollWidth: el.scrollWidth,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})
|
||||
}
|
||||
|
||||
if (isClippingElement(el)) {
|
||||
const r = rectFor(el)
|
||||
results.clipping.push({
|
||||
clipNodeId: results.clipping.length,
|
||||
subjectId,
|
||||
clipKind: 1,
|
||||
clipLeft: r.left,
|
||||
clipTop: r.top,
|
||||
clipRight: r.right,
|
||||
clipBottom: r.bottom,
|
||||
parentClipNodeId: 0,
|
||||
})
|
||||
}
|
||||
|
||||
results.topology.containingBlockOf.push(getId(getContainingBlock(el)))
|
||||
results.topology.nearestPositionedAncestorOf.push(getId(getNearestPositionedAncestor(el)))
|
||||
results.topology.scrollContainerOf.push(getId(getScrollContainer(el)))
|
||||
results.topology.stackingContextOf.push(getId(getStackingContextRoot(el)))
|
||||
results.topology.paintOrderBucket.push(createsStackingContext(el) ? 2 : 1)
|
||||
results.topology.paintOrderIndex.push(i)
|
||||
results.topology.formattingContextOf.push(getId(el.parentElement))
|
||||
results.topology.clippingRootOf.push(getId(getClippingRoot(el)))
|
||||
}
|
||||
|
||||
return results
|
||||
})()
|
||||
`
|
||||
|
||||
let result: TopologyExtractionResult
|
||||
try {
|
||||
const response = await session.send<{
|
||||
result: { value: TopologyExtractionResult }
|
||||
}>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true,
|
||||
})
|
||||
result = response.result.value
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
for (const backendNodeId of backendNodeIds) {
|
||||
errors.push({ backendNodeId, reason })
|
||||
}
|
||||
result = {
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Remove temporary attributes
|
||||
for (let i = 0; i < backendNodeIds.length; i++) {
|
||||
const objectId = objectIds[i]
|
||||
if (!objectId) continue
|
||||
try {
|
||||
await session.send<unknown>('Runtime.callFunctionOn', {
|
||||
objectId,
|
||||
functionDeclaration: `function() { this.removeAttribute('data-imhotep-backend-id') }`,
|
||||
})
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return { result, errors }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {},
|
||||
"composite": false,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "imhotep-cli",
|
||||
"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",
|
||||
"bin": {
|
||||
"imhotep": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "node --test dist/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"imhotep-core": "^1.0.0",
|
||||
"imhotep-playwright": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.39",
|
||||
"playwright": "^1.59.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI entry point for imhotep command
|
||||
import { initProject } from './init.js';
|
||||
import { getPresetNames } from './presets/index.js';
|
||||
|
||||
function showHelp(): void {
|
||||
console.log(`
|
||||
Usage: imhotep init --preset <name> [--dir <path>]
|
||||
|
||||
Commands:
|
||||
init Scaffold a new Imhotep project
|
||||
|
||||
Options:
|
||||
--preset <name> Project preset (${getPresetNames().join(', ')})
|
||||
--dir <path> Target directory (default: current directory)
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
npx imhotep init --preset react
|
||||
npx imhotep init --preset next --dir ./next-tests
|
||||
npx imhotep init --preset nuxt --dir ./nuxt-tests
|
||||
npx imhotep init --preset vue --dir ./my-project
|
||||
npx imhotep init --preset storybook --dir ./storybook-tests
|
||||
`);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
|
||||
if (command === 'init') {
|
||||
const presetIndex = args.indexOf('--preset');
|
||||
const dirIndex = args.indexOf('--dir');
|
||||
|
||||
if (presetIndex === -1 || !args[presetIndex + 1]) {
|
||||
console.error('Error: --preset is required');
|
||||
console.error(`Available presets: ${getPresetNames().join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const preset = args[presetIndex + 1];
|
||||
const targetDir = dirIndex !== -1
|
||||
? args[dirIndex + 1]
|
||||
: process.cwd();
|
||||
|
||||
try {
|
||||
initProject({ preset, targetDir });
|
||||
console.log(`✓ Scaffolded ${preset} project in ${targetDir}`);
|
||||
console.log(` Run: cd ${targetDir} && npm install && npm test`);
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Unknown command: ${command}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,3 @@
|
||||
// Public API exports for imhotep-cli
|
||||
export { initProject, type InitOptions } from './init.js';
|
||||
export { presets, getPresetNames, getPreset, type PresetName } from './presets/index.js';
|
||||
@@ -0,0 +1,131 @@
|
||||
// RED phase: Test that scaffolding produces working projects
|
||||
import assert from 'node:assert';
|
||||
import { test, describe } from 'node:test';
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initProject } from './init.js';
|
||||
import { getPresetNames, getPreset } from './presets/index.js';
|
||||
|
||||
describe('imhotep init', () => {
|
||||
test('react-playwright preset creates expected files', () => {
|
||||
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
|
||||
|
||||
try {
|
||||
initProject({ preset: 'react', targetDir });
|
||||
|
||||
// Verify all expected files exist
|
||||
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), 'config file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), 'test file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), 'fixture file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'package.json')), 'package.json should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'README.md')), 'README should exist');
|
||||
|
||||
// Verify config has render target
|
||||
const config = readFileSync(join(targetDir, 'imhotep.config.js'), 'utf-8');
|
||||
assert.ok(config.includes('render'), 'config should mention render target');
|
||||
|
||||
// Verify test file has assertions
|
||||
const testFile = readFileSync(join(targetDir, 'tests', 'example.test.ts'), 'utf-8');
|
||||
assert.ok(testFile.includes('expect'), 'test should have assertions');
|
||||
|
||||
// Verify package.json has test scripts
|
||||
const pkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
|
||||
assert.ok(pkg.scripts.test, 'package.json should have test script');
|
||||
assert.ok(pkg.scripts['test:ci'], 'package.json should have ci script');
|
||||
} finally {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('vue preset creates expected files', () => {
|
||||
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
|
||||
|
||||
try {
|
||||
initProject({ preset: 'vue', targetDir });
|
||||
|
||||
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), 'config file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), 'test file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), 'fixture file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'package.json')), 'package.json should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'README.md')), 'README should exist');
|
||||
|
||||
const config = readFileSync(join(targetDir, 'imhotep.config.js'), 'utf-8');
|
||||
assert.ok(config.includes('render'), 'config should mention render target');
|
||||
} finally {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('storybook preset creates expected files', () => {
|
||||
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
|
||||
|
||||
try {
|
||||
initProject({ preset: 'storybook', targetDir });
|
||||
|
||||
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), 'config file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), 'test file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), 'fixture file should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'package.json')), 'package.json should exist');
|
||||
assert.ok(existsSync(join(targetDir, 'README.md')), 'README should exist');
|
||||
|
||||
const config = readFileSync(join(targetDir, 'imhotep.config.js'), 'utf-8');
|
||||
assert.ok(config.includes('render'), 'config should mention render target');
|
||||
} finally {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('throws on unknown preset', () => {
|
||||
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
|
||||
|
||||
try {
|
||||
assert.throws(() => {
|
||||
initProject({ preset: 'unknown-preset', targetDir });
|
||||
}, /Unknown preset/);
|
||||
} finally {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffolded react tests have passing assertions syntax', () => {
|
||||
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
|
||||
|
||||
try {
|
||||
initProject({ preset: 'react', targetDir });
|
||||
|
||||
const testFile = readFileSync(join(targetDir, 'tests', 'example.test.ts'), 'utf-8');
|
||||
|
||||
assert.ok(testFile.includes('const ui = await imhotep(page);'), 'template should await imhotep(page)');
|
||||
assert.ok(testFile.includes('await ui.checkAll();'), 'template should evaluate batched assertions with checkAll()');
|
||||
} finally {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('all presets use publish-safe dependency ranges (no workspace protocol)', () => {
|
||||
for (const name of getPresetNames()) {
|
||||
const preset = getPreset(name);
|
||||
const pkg = preset.packageJson;
|
||||
for (const [dep, version] of Object.entries(pkg.devDependencies ?? {})) {
|
||||
assert.ok(typeof version === 'string', `dependency ${dep} in ${name} should be a string`);
|
||||
assert.ok(!version.startsWith('workspace:'), `${name} must not use workspace protocol for ${dep}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('new framework presets scaffold expected files', () => {
|
||||
const extraPresets = ['next', 'nuxt', 'remix', 'astro'];
|
||||
for (const preset of extraPresets) {
|
||||
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
|
||||
try {
|
||||
initProject({ preset, targetDir });
|
||||
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), `${preset} config should exist`);
|
||||
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), `${preset} test should exist`);
|
||||
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), `${preset} fixture should exist`);
|
||||
} finally {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Core init logic for scaffolding Imhotep projects from presets
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getPreset } from './presets/index.js';
|
||||
|
||||
export interface InitOptions {
|
||||
preset: string;
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
export function initProject(options: InitOptions): void {
|
||||
const { preset, targetDir } = options;
|
||||
|
||||
// Resolve preset template
|
||||
const template = getPreset(preset);
|
||||
|
||||
// Create directory structure
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
mkdirSync(join(targetDir, 'tests'), { recursive: true });
|
||||
mkdirSync(join(targetDir, 'fixtures'), { recursive: true });
|
||||
|
||||
// Write config file
|
||||
writeFileSync(
|
||||
join(targetDir, 'imhotep.config.js'),
|
||||
template.configFile,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Write test file
|
||||
writeFileSync(
|
||||
join(targetDir, 'tests', 'example.test.ts'),
|
||||
template.testFile,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Write fixture file
|
||||
writeFileSync(
|
||||
join(targetDir, 'fixtures', 'example.html'),
|
||||
template.fixtureFile,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Write package.json
|
||||
writeFileSync(
|
||||
join(targetDir, 'package.json'),
|
||||
JSON.stringify(template.packageJson, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Write README
|
||||
writeFileSync(
|
||||
join(targetDir, 'README.md'),
|
||||
template.readme,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export const astroPlaywrightPreset = {
|
||||
name: 'astro',
|
||||
description: 'Astro page testing with Playwright + Imhotep',
|
||||
|
||||
configFile: `module.exports = {
|
||||
render: {
|
||||
type: 'page',
|
||||
framework: 'astro'
|
||||
},
|
||||
browser: {
|
||||
viewport: { width: 1280, height: 720 }
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('marketing hero contracts', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="hero-title"]').to.be.above('[data-testid="hero-cta"]', { minGap: 12 });
|
||||
ui.expect('[data-testid="hero-cta"]').to.be.atLeast(44, 'height');
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
body { margin: 0; font-family: ui-sans-serif, sans-serif; background: #f8fafc; }
|
||||
.hero { max-width: 760px; margin: 80px auto; background: #fff; padding: 32px; border-radius: 16px; }
|
||||
.title { margin: 0 0 20px; font-size: 36px; }
|
||||
.cta { min-height: 44px; padding: 12px 18px; border: none; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero">
|
||||
<h1 class="title" data-testid="hero-title">Ship faster</h1>
|
||||
<button class="cta" data-testid="hero-cta">Start now</button>
|
||||
</section>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-astro-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
imhotep: '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep Astro + Playwright preset
|
||||
|
||||
Scaffolded with \`imhotep init --preset astro\`.
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
// Central registry of all available presets
|
||||
import { reactPlaywrightPreset } from './react-playwright.js';
|
||||
import { vueVitestPreset } from './vue-vitest.js';
|
||||
import { storybookPreset } from './storybook.js';
|
||||
import { nextPlaywrightPreset } from './next-playwright.js';
|
||||
import { remixPlaywrightPreset } from './remix-playwright.js';
|
||||
import { astroPlaywrightPreset } from './astro-playwright.js';
|
||||
import { nuxtPlaywrightPreset } from './nuxt-playwright.js';
|
||||
|
||||
export const presets = {
|
||||
// Canonical framework-first preset names
|
||||
react: reactPlaywrightPreset,
|
||||
vue: vueVitestPreset,
|
||||
storybook: storybookPreset,
|
||||
next: nextPlaywrightPreset,
|
||||
nuxt: nuxtPlaywrightPreset,
|
||||
remix: remixPlaywrightPreset,
|
||||
astro: astroPlaywrightPreset,
|
||||
};
|
||||
|
||||
export type PresetName = keyof typeof presets;
|
||||
|
||||
export function getPresetNames(): string[] {
|
||||
return Object.keys(presets);
|
||||
}
|
||||
|
||||
export function getPreset(name: string) {
|
||||
const preset = presets[name as PresetName];
|
||||
if (!preset) {
|
||||
throw new Error(`Unknown preset: "${name}". Available presets: ${getPresetNames().join(', ')}`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export const nextPlaywrightPreset = {
|
||||
name: 'next',
|
||||
description: 'Next.js app testing with Playwright + Imhotep',
|
||||
|
||||
configFile: `module.exports = {
|
||||
render: {
|
||||
type: 'page',
|
||||
framework: 'next'
|
||||
},
|
||||
browser: {
|
||||
viewport: { width: 1280, height: 720 }
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('dashboard shell layout', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="nav"]').to.be.leftOf('[data-testid="content"]', { minGap: 16 });
|
||||
ui.expect('[data-testid="menu-button"]').to.be.atLeast(44, 'height');
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Next fixture</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.shell { display: grid; grid-template-columns: 240px 1fr; gap: 16px; min-height: 100vh; }
|
||||
.nav { background: #111827; color: #fff; padding: 16px; }
|
||||
.content { padding: 24px; }
|
||||
.menu { min-height: 44px; min-width: 44px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="nav" data-testid="nav"><button class="menu" data-testid="menu-button">Menu</button></aside>
|
||||
<main class="content" data-testid="content">Dashboard</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-next-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
imhotep: '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep Next.js + Playwright preset
|
||||
|
||||
Scaffolded with \`imhotep init --preset next\`.
|
||||
|
||||
Run:
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npx playwright install
|
||||
npm test
|
||||
\`\`\`
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
export const nuxtPlaywrightPreset = {
|
||||
name: 'nuxt',
|
||||
description: 'Nuxt ecommerce layout testing with Playwright + Imhotep',
|
||||
|
||||
configFile: `module.exports = {
|
||||
render: {
|
||||
type: 'page',
|
||||
framework: 'nuxt'
|
||||
},
|
||||
browser: {
|
||||
viewport: { width: 390, height: 844 }
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('product grid and buy button contracts', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="filter-rail"]').to.be.leftOf('[data-testid="product-grid"]', { minGap: 12 });
|
||||
ui.expect('[data-testid="buy-button"]').to.be.atLeast(44, 'height');
|
||||
ui.expect('[data-testid="buy-button"]').to.be.atLeast(44, 'width');
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.layout { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 16px; }
|
||||
.rail { position: sticky; top: 0; background: #f8fafc; padding: 12px; min-height: 200px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
|
||||
.buy { min-height: 44px; min-width: 44px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="rail" data-testid="filter-rail">Filters</aside>
|
||||
<section class="grid" data-testid="product-grid">
|
||||
<article class="card"><button class="buy" data-testid="buy-button">Buy</button></article>
|
||||
<article class="card"><button class="buy">Buy</button></article>
|
||||
<article class="card"><button class="buy">Buy</button></article>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-nuxt-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
imhotep: '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep Nuxt + Playwright preset
|
||||
|
||||
Scaffolded with \`imhotep init --preset nuxt\`.
|
||||
|
||||
This preset targets common ecommerce layout checks:
|
||||
- sticky filter rail relative position
|
||||
- product grid spacing invariants
|
||||
- 44x44 buy-button touch target baseline
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
// Preset template for React + Playwright projects
|
||||
export const reactPlaywrightPreset = {
|
||||
name: 'react',
|
||||
description: 'React component testing with Playwright',
|
||||
|
||||
configFile: `module.exports = {
|
||||
// Render target for React components
|
||||
render: {
|
||||
type: 'component',
|
||||
framework: 'react',
|
||||
// Component mount function path (relative to project root)
|
||||
mount: './tests/mount.js'
|
||||
},
|
||||
|
||||
// Playwright browser configuration
|
||||
browser: {
|
||||
viewport: { width: 1280, height: 720 }
|
||||
},
|
||||
|
||||
// Spatial assertion defaults
|
||||
defaults: {
|
||||
tolerance: 0.5
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('button layout assertions', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="primary-btn"]').to.be.above('[data-testid="secondary-btn"]', { minGap: 8 });
|
||||
ui.expect('[data-testid="primary-btn"]').to.be.atLeast(44, 'height');
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>React Playwright Fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="button-group" data-testid="button-group">
|
||||
<button class="btn btn-primary" data-testid="primary-btn">
|
||||
Primary Action
|
||||
</button>
|
||||
<button class="btn btn-secondary" data-testid="secondary-btn">
|
||||
Secondary Action
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-react-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list',
|
||||
'test:ui': 'playwright test --ui'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
'imhotep': '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep React + Playwright Project
|
||||
|
||||
This project was scaffolded with \`imhotep init --preset react\`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npx playwright install
|
||||
npm test
|
||||
\`\`\`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- \`imhotep.config.js\` - Imhotep configuration
|
||||
- \`tests/\` - Test files
|
||||
- \`fixtures/\` - HTML fixtures for spatial testing
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Use the Imhotep DSL for spatial assertions:
|
||||
|
||||
\`\`\`typescript
|
||||
const ui = imhotep(page);
|
||||
await ui.expect('.header').to.be.above('.content', { minGap: 16 });
|
||||
\`\`\`
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
export const remixPlaywrightPreset = {
|
||||
name: 'remix',
|
||||
description: 'Remix route layout testing with Playwright + Imhotep',
|
||||
|
||||
configFile: `module.exports = {
|
||||
render: {
|
||||
type: 'page',
|
||||
framework: 'remix'
|
||||
},
|
||||
browser: {
|
||||
viewport: { width: 1280, height: 720 }
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('route layout contracts', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="header"]').to.be.above('[data-testid="main"]', { minGap: 0 });
|
||||
ui.expect('[data-testid="sidebar"]').to.be.leftOf('[data-testid="main"]', { minGap: 12 });
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
header { height: 64px; background: #0f172a; color: #fff; padding: 16px; }
|
||||
.layout { display: grid; grid-template-columns: 220px 1fr; gap: 12px; }
|
||||
aside { background: #e2e8f0; min-height: calc(100vh - 64px); padding: 16px; }
|
||||
main { padding: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header data-testid="header">Header</header>
|
||||
<div class="layout">
|
||||
<aside data-testid="sidebar">Filters</aside>
|
||||
<main data-testid="main">Content</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-remix-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
imhotep: '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep Remix + Playwright preset
|
||||
|
||||
Scaffolded with \`imhotep init --preset remix\`.
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
// Preset template for Storybook projects
|
||||
export const storybookPreset = {
|
||||
name: 'storybook',
|
||||
description: 'Storybook component testing with spatial assertions',
|
||||
|
||||
configFile: `module.exports = {
|
||||
// Render target for Storybook stories
|
||||
render: {
|
||||
type: 'storybook',
|
||||
// Storybook URL (local dev server)
|
||||
url: 'http://localhost:6006',
|
||||
// Story patterns to test
|
||||
stories: './src/**/*.stories.@(js|jsx|ts|tsx)'
|
||||
},
|
||||
|
||||
// Browser configuration
|
||||
browser: {
|
||||
viewport: { width: 1280, height: 720 }
|
||||
},
|
||||
|
||||
// Spatial assertion defaults
|
||||
defaults: {
|
||||
tolerance: 0.5
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('storybook story layout', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="sidebar"]').to.be.leftOf('[data-testid="main-content"]', { minGap: 0 });
|
||||
ui.expect('[data-testid="header"]').to.be.above('[data-testid="main-content"]', { minGap: 0 });
|
||||
ui.expect('[data-testid="story-canvas"]').to.be.contains('[data-testid="story-component"]');
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Storybook Fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"sidebar main";
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-rows: 48px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
.header {
|
||||
grid-area: header;
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
}
|
||||
.sidebar-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.main {
|
||||
grid-area: main;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
.story-canvas {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
min-height: 200px;
|
||||
}
|
||||
.story-component {
|
||||
padding: 16px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="header" data-testid="header">
|
||||
Storybook
|
||||
</header>
|
||||
<nav class="sidebar" data-testid="sidebar">
|
||||
<div class="sidebar-item">Buttons</div>
|
||||
<div class="sidebar-item">Cards</div>
|
||||
<div class="sidebar-item">Forms</div>
|
||||
</nav>
|
||||
<main class="main" data-testid="main-content">
|
||||
<div class="story-canvas" data-testid="story-canvas">
|
||||
<div class="story-component" data-testid="story-component">
|
||||
Example Story Component
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-storybook-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
'storybook': 'storybook dev -p 6006',
|
||||
'build-storybook': 'storybook build',
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list',
|
||||
'test:visual': 'playwright test --update-snapshots'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
'@storybook/react': '^7.0.0',
|
||||
'storybook': '^7.0.0',
|
||||
'imhotep': '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep Storybook Project
|
||||
|
||||
This project was scaffolded with \`imhotep init --preset storybook\`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm run storybook # Start Storybook dev server
|
||||
npm test # Run spatial tests
|
||||
\`\`\`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- \`imhotep.config.js\` - Imhotep configuration
|
||||
- \`tests/\` - Spatial test files
|
||||
- \`fixtures/\` - HTML fixtures for offline testing
|
||||
- \`src/**/*.stories.*\` - Storybook stories
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Use the Imhotep DSL for spatial assertions against stories:
|
||||
|
||||
\`\`\`typescript
|
||||
const ui = imhotep(page);
|
||||
await ui.expect('.sidebar').to.be.leftOf('.main-content');
|
||||
\`\`\`
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
// Preset template for Vue + Vitest projects
|
||||
export const vueVitestPreset = {
|
||||
name: 'vue',
|
||||
description: 'Vue component testing with Vitest',
|
||||
|
||||
configFile: `module.exports = {
|
||||
// Render target for Vue components
|
||||
render: {
|
||||
type: 'component',
|
||||
framework: 'vue',
|
||||
// Component mount function path (relative to project root)
|
||||
mount: './tests/mount.js'
|
||||
},
|
||||
|
||||
// Browser configuration for Vitest browser mode
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium',
|
||||
viewport: { width: 1280, height: 720 }
|
||||
},
|
||||
|
||||
// Spatial assertion defaults
|
||||
defaults: {
|
||||
tolerance: 0.5
|
||||
}
|
||||
};`,
|
||||
|
||||
testFile: `import { test, expect } from '@playwright/test';
|
||||
import { imhotep } from 'imhotep';
|
||||
|
||||
test('card layout assertions', async ({ page }) => {
|
||||
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
|
||||
|
||||
const ui = await imhotep(page);
|
||||
ui.expect('[data-testid="card-title"]').to.be.above('[data-testid="card-content"]', { minGap: 12 });
|
||||
ui.expect('[data-testid="card"]').to.be.atLeast(300, 'width');
|
||||
|
||||
const result = await ui.checkAll();
|
||||
expect(result.passed).toBe(true);
|
||||
});`,
|
||||
|
||||
fixtureFile: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vue Vitest Fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card {
|
||||
width: 320px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #4a4a68;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" data-testid="card">
|
||||
<h2 class="card-title" data-testid="card-title">
|
||||
Vue Component
|
||||
</h2>
|
||||
<div class="card-content" data-testid="card-content">
|
||||
This fixture demonstrates spatial testing for Vue components using Vitest.
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary">Confirm</button>
|
||||
<button class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
packageJson: {
|
||||
name: 'imhotep-vue-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'playwright test',
|
||||
'test:ci': 'playwright test --reporter=list',
|
||||
'test:ui': 'playwright test --ui'
|
||||
},
|
||||
devDependencies: {
|
||||
'@playwright/test': '^1.59.1',
|
||||
'imhotep': '^1.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
readme: `# Imhotep Vue + Vitest Project
|
||||
|
||||
This project was scaffolded with \`imhotep init --preset vue\`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm test
|
||||
\`\`\`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- \`imhotep.config.js\` - Imhotep configuration
|
||||
- \`tests/\` - Test files
|
||||
- \`fixtures/\` - HTML fixtures for spatial testing
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Use the Imhotep DSL for spatial assertions:
|
||||
|
||||
\`\`\`typescript
|
||||
const ui = imhotep(page);
|
||||
await ui.expect('.card').to.be.centeredWithin('viewport');
|
||||
\`\`\`
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "imhotep-core",
|
||||
"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 -p tsconfig.build.json",
|
||||
"test": "tsc -p tsconfig.test.json && node --test dist-test/**/*.test.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./scene-target": {
|
||||
"types": "./dist/scene-target.d.ts",
|
||||
"default": "./dist/scene-target.js"
|
||||
},
|
||||
"./property-contracts": {
|
||||
"types": "./dist/property-contracts.d.ts",
|
||||
"default": "./dist/property-contracts.js"
|
||||
},
|
||||
"./property-results": {
|
||||
"types": "./dist/property-results.d.ts",
|
||||
"default": "./dist/property-results.js"
|
||||
},
|
||||
"./world": {
|
||||
"types": "./dist/world.d.ts",
|
||||
"default": "./dist/world.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AST node definitions for Imhotep authoring syntax.
|
||||
*
|
||||
* All AST nodes follow a unist-style shape:
|
||||
* {
|
||||
* type: string,
|
||||
* position?: Position,
|
||||
* data?: Record<string, unknown>,
|
||||
* children?: AstNode[]
|
||||
* }
|
||||
*
|
||||
* This structure is intentionally rich for tooling, transforms, and diagnostics.
|
||||
*/
|
||||
|
||||
import type { Position, LiteralValue } from './types.js'
|
||||
|
||||
import type {
|
||||
FormulaNode,
|
||||
PropertyRunBlock,
|
||||
SampledRunBlock,
|
||||
} from './logic-ast.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base Node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AstNode {
|
||||
type: string
|
||||
position?: Position
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ParentNode extends AstNode {
|
||||
children: AstNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Program
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Program extends ParentNode {
|
||||
type: 'Program'
|
||||
children: BlockNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block-Level Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BlockNode =
|
||||
| FrameBlock
|
||||
| AcrossBlock
|
||||
| AlwaysBlock
|
||||
| WhenBlock
|
||||
| FormulaNode
|
||||
| PropertyRunBlock
|
||||
| SampledRunBlock
|
||||
|
||||
export interface FrameBlock extends ParentNode {
|
||||
type: 'FrameBlock'
|
||||
frame: FrameRef
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
export interface AcrossBlock extends ParentNode {
|
||||
type: 'AcrossBlock'
|
||||
axes: AcrossAxis[]
|
||||
children: BlockNode[]
|
||||
}
|
||||
|
||||
export interface AcrossAxis {
|
||||
name: string
|
||||
values: (string | number)[]
|
||||
}
|
||||
|
||||
export interface AlwaysBlock extends ParentNode {
|
||||
type: 'AlwaysBlock'
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
export interface WhenBlock extends ParentNode {
|
||||
type: 'WhenBlock'
|
||||
guard: GuardExpression
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
export interface GuardExpression extends AstNode {
|
||||
type: 'GuardExpression'
|
||||
op: string
|
||||
left: string | number
|
||||
right?: string | number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AssertionNode =
|
||||
| RelationAssertion
|
||||
| TopologyAssertion
|
||||
| SizeAssertion
|
||||
| StateAssertion
|
||||
| TemporalAssertion
|
||||
| CompoundAssertion
|
||||
| FormulaNode
|
||||
|
||||
export interface RelationAssertion extends AstNode {
|
||||
type: 'RelationAssertion'
|
||||
relation: string
|
||||
subject: SelectorRef
|
||||
reference: SelectorRef
|
||||
options: RelationOptions
|
||||
}
|
||||
|
||||
export interface RelationOptions extends AstNode {
|
||||
type: 'RelationOptions'
|
||||
minGap?: LiteralValue
|
||||
maxGap?: LiteralValue
|
||||
tolerance?: LiteralValue
|
||||
inStackingContext?: boolean
|
||||
}
|
||||
|
||||
export interface TopologyAssertion extends AstNode {
|
||||
type: 'TopologyAssertion'
|
||||
predicate: string
|
||||
subject: SelectorRef
|
||||
reference?: SelectorRef
|
||||
options?: TopologyOptions
|
||||
}
|
||||
|
||||
export interface TopologyOptions extends AstNode {
|
||||
type: 'TopologyOptions'
|
||||
tolerance?: LiteralValue
|
||||
}
|
||||
|
||||
export interface SizeAssertion extends AstNode {
|
||||
type: 'SizeAssertion'
|
||||
property: string
|
||||
subject: SelectorRef
|
||||
bounds: SizeBounds
|
||||
}
|
||||
|
||||
export interface SizeBounds extends AstNode {
|
||||
type: 'SizeBounds'
|
||||
min?: LiteralValue
|
||||
max?: LiteralValue
|
||||
exact?: LiteralValue
|
||||
}
|
||||
|
||||
export interface StateAssertion extends AstNode {
|
||||
type: 'StateAssertion'
|
||||
stateSelector: StateSelector
|
||||
assertion: AssertionNode
|
||||
}
|
||||
|
||||
export interface TemporalAssertion extends AstNode {
|
||||
type: 'TemporalAssertion'
|
||||
timelineSelector: TimelineSelector
|
||||
assertion: AssertionNode
|
||||
}
|
||||
|
||||
export interface CompoundAssertion extends ParentNode {
|
||||
type: 'CompoundAssertion'
|
||||
quantifier?: QuantifierNode
|
||||
operator?: 'and' | 'or'
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selector and Reference Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SelectorRef extends AstNode {
|
||||
type: 'SelectorRef'
|
||||
value: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic Subject Reference (Stream 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SubjectReference =
|
||||
| { kind: 'css'; selector: string }
|
||||
| { kind: 'role'; role: string; name?: string }
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'labelText'; label: string }
|
||||
| { kind: 'testId'; testId: string }
|
||||
|
||||
export function subjectReferenceToString(ref: SubjectReference): string {
|
||||
switch (ref.kind) {
|
||||
case 'css':
|
||||
return `css='${ref.selector}'`
|
||||
case 'role':
|
||||
return ref.name
|
||||
? `role='${ref.role}' name='${ref.name}'`
|
||||
: `role='${ref.role}'`
|
||||
case 'text':
|
||||
return `text='${ref.text}'`
|
||||
case 'labelText':
|
||||
return `labelText='${ref.label}'`
|
||||
case 'testId':
|
||||
return `testId='${ref.testId}'`
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export interface StateSelector extends AstNode {
|
||||
type: 'StateSelector'
|
||||
kind: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface TimelineSelector extends AstNode {
|
||||
type: 'TimelineSelector'
|
||||
mode: string
|
||||
samples?: number[]
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Literal Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LiteralNode extends AstNode {
|
||||
type: 'Literal'
|
||||
kind: 'length' | 'range' | 'tolerance' | 'string' | 'number' | 'boolean'
|
||||
value: unknown
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export interface RangeLiteralNode extends AstNode {
|
||||
type: 'RangeLiteral'
|
||||
min: LiteralNode
|
||||
max: LiteralNode
|
||||
}
|
||||
|
||||
export interface ToleranceLiteralNode extends AstNode {
|
||||
type: 'ToleranceLiteral'
|
||||
value: number
|
||||
unit: 'jnd' | 'px'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quantifier Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface QuantifierNode extends AstNode {
|
||||
type: 'Quantifier'
|
||||
kind: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame Reference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FrameRef extends AstNode {
|
||||
type: 'FrameRef'
|
||||
kind: string
|
||||
selector?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isAstNode(value: unknown): value is AstNode {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'type' in value &&
|
||||
typeof (value as Record<string, unknown>).type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function isParentNode(node: AstNode): node is ParentNode {
|
||||
return 'children' in node && Array.isArray((node as ParentNode).children)
|
||||
}
|
||||
|
||||
export function isProgram(node: AstNode): node is Program {
|
||||
return node.type === 'Program'
|
||||
}
|
||||
|
||||
export function isFrameBlock(node: AstNode): node is FrameBlock {
|
||||
return node.type === 'FrameBlock'
|
||||
}
|
||||
|
||||
export function isRelationAssertion(node: AstNode): node is RelationAssertion {
|
||||
return node.type === 'RelationAssertion'
|
||||
}
|
||||
|
||||
export function isCompoundAssertion(node: AstNode): node is CompoundAssertion {
|
||||
return node.type === 'CompoundAssertion'
|
||||
}
|
||||
|
||||
export function isFormulaNode(node: AstNode): node is FormulaNode {
|
||||
return node.type === 'FormulaNode'
|
||||
}
|
||||
|
||||
export function isPropertyRunBlock(node: AstNode): node is PropertyRunBlock {
|
||||
return node.type === 'PropertyRunBlock'
|
||||
}
|
||||
|
||||
export function isSampledRunBlock(node: AstNode): node is SampledRunBlock {
|
||||
return node.type === 'SampledRunBlock'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Determinism proof tests — RED phase.
|
||||
*
|
||||
* These tests assert that Imhotep execution is deterministic when a
|
||||
* seeded context is provided. They currently FAIL because the codebase
|
||||
* relies on global mutable state (module-global id counter, Date.now,
|
||||
* Math.random).
|
||||
*
|
||||
* Run: npm test -w packages/imhotep-core
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {
|
||||
createDeterministicContext,
|
||||
createVolatileContext,
|
||||
getDefaultContext,
|
||||
setDefaultContext,
|
||||
resetDefaultContext,
|
||||
} from './context.js'
|
||||
|
||||
import { compile } from 'imhotep-dsl'
|
||||
import { createSnapshotId } from 'imhotep-state'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ExecutionContext', () => {
|
||||
beforeEach(() => {
|
||||
resetDefaultContext()
|
||||
})
|
||||
|
||||
it('deterministic context produces identical ids across calls', () => {
|
||||
const ctx = createDeterministicContext(42)
|
||||
const a = ctx.idGenerator()
|
||||
const b = ctx.idGenerator()
|
||||
const c = ctx.idGenerator()
|
||||
assert.deepStrictEqual([a, b, c], ['id-1', 'id-2', 'id-3'])
|
||||
})
|
||||
|
||||
it('deterministic context with same seed produces same rng sequence', () => {
|
||||
const ctx1 = createDeterministicContext(123)
|
||||
const ctx2 = createDeterministicContext(123)
|
||||
const seq1 = Array.from({ length: 5 }, () => ctx1.rng())
|
||||
const seq2 = Array.from({ length: 5 }, () => ctx2.rng())
|
||||
assert.deepStrictEqual(seq1, seq2)
|
||||
})
|
||||
|
||||
it('deterministic context with different seeds produces different rng sequence', () => {
|
||||
const ctx1 = createDeterministicContext(123)
|
||||
const ctx2 = createDeterministicContext(456)
|
||||
const seq1 = Array.from({ length: 5 }, () => ctx1.rng())
|
||||
const seq2 = Array.from({ length: 5 }, () => ctx2.rng())
|
||||
assert.notDeepStrictEqual(seq1, seq2)
|
||||
})
|
||||
|
||||
it('deterministic context clock is stable', () => {
|
||||
const ctx = createDeterministicContext()
|
||||
assert.strictEqual(ctx.clock(), 0)
|
||||
assert.strictEqual(ctx.clock(), 0)
|
||||
})
|
||||
|
||||
it('volatile context clock returns real time', () => {
|
||||
const before = Date.now()
|
||||
const ctx = createVolatileContext()
|
||||
const t = ctx.clock()
|
||||
const after = Date.now()
|
||||
assert.ok(t >= before)
|
||||
assert.ok(t <= after)
|
||||
})
|
||||
|
||||
it('default context is deterministic in test mode', () => {
|
||||
const ctx1 = getDefaultContext()
|
||||
const ctx2 = getDefaultContext()
|
||||
// In test mode, each call returns a fresh deterministic context.
|
||||
assert.notStrictEqual(ctx1, ctx2)
|
||||
assert.strictEqual(typeof ctx1.seed, 'number')
|
||||
assert.strictEqual(ctx1.clock(), 0)
|
||||
assert.strictEqual(ctx2.clock(), 0)
|
||||
})
|
||||
|
||||
it('setDefaultContext overrides the global default', () => {
|
||||
const det = createDeterministicContext(99)
|
||||
setDefaultContext(det)
|
||||
assert.strictEqual(getDefaultContext(), det)
|
||||
assert.strictEqual(getDefaultContext().seed, 99)
|
||||
})
|
||||
|
||||
it('parallel deterministic contexts do not interfere', () => {
|
||||
const ctxA = createDeterministicContext(1)
|
||||
const ctxB = createDeterministicContext(1)
|
||||
const idsA = [ctxA.idGenerator(), ctxA.idGenerator()]
|
||||
const idsB = [ctxB.idGenerator(), ctxB.idGenerator(), ctxB.idGenerator()]
|
||||
assert.deepStrictEqual(idsA, ['id-1', 'id-2'])
|
||||
assert.deepStrictEqual(idsB, ['id-1', 'id-2', 'id-3'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-package determinism proof tests (currently RED)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Determinism across packages', () => {
|
||||
it('default compiler ids are non-deterministic across runs', () => {
|
||||
// RED: defaultNextId uses a module-global counter.
|
||||
// Two compile() calls without options produce different ids.
|
||||
const ast = [
|
||||
{
|
||||
type: 'RelationAssertion' as const,
|
||||
subject: { type: 'SelectorRef' as const, value: '.a' },
|
||||
relation: 'leftOf' as const,
|
||||
reference: { type: 'SelectorRef' as const, value: '.b' },
|
||||
options: { type: 'RelationOptions' as const },
|
||||
position: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 10, offset: 10 } },
|
||||
},
|
||||
] as const
|
||||
|
||||
const result1 = compile(ast as any)
|
||||
const result2 = compile(ast as any)
|
||||
|
||||
const ids1 = Array.from(result1.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
const ids2 = Array.from(result2.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
|
||||
// This assertion documents the desired deterministic behavior.
|
||||
// It currently FAILS because the global counter advances between calls.
|
||||
assert.deepStrictEqual(ids1, ids2, 'clause ids must be deterministic with same context')
|
||||
})
|
||||
|
||||
it('injected compiler ids are deterministic across runs', () => {
|
||||
// GREEN preview: when generateId is injected, ids are deterministic.
|
||||
const ctx1 = createDeterministicContext(7)
|
||||
const ctx2 = createDeterministicContext(7)
|
||||
const ast = [
|
||||
{
|
||||
type: 'RelationAssertion' as const,
|
||||
subject: { type: 'SelectorRef' as const, value: '.a' },
|
||||
relation: 'leftOf' as const,
|
||||
reference: { type: 'SelectorRef' as const, value: '.b' },
|
||||
options: { type: 'RelationOptions' as const },
|
||||
position: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 10, offset: 10 } },
|
||||
},
|
||||
] as const
|
||||
|
||||
const result1 = compile(ast as any, { generateId: () => ctx1.idGenerator() })
|
||||
const result2 = compile(ast as any, { generateId: () => ctx2.idGenerator() })
|
||||
|
||||
const ids1 = Array.from(result1.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
const ids2 = Array.from(result2.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
|
||||
assert.deepStrictEqual(ids1, ids2, 'injected ids must be deterministic with same seed')
|
||||
})
|
||||
|
||||
it('default snapshot ids are deterministic and unique', () => {
|
||||
// With the monotonic counter in createSnapshotId, consecutive calls
|
||||
// produce predictable but unique ids in deterministic test contexts.
|
||||
const id1 = createSnapshotId()
|
||||
const id2 = createSnapshotId()
|
||||
assert.strictEqual(id1, 'snap_0_id-1_1')
|
||||
assert.strictEqual(id2, 'snap_0_id-1_2')
|
||||
assert.notStrictEqual(id1, id2)
|
||||
})
|
||||
|
||||
it('snapshot timestamps are stable with deterministic clock', () => {
|
||||
const ctx = createDeterministicContext(7)
|
||||
const t1 = ctx.clock()
|
||||
const t2 = ctx.clock()
|
||||
assert.strictEqual(t1, t2)
|
||||
assert.strictEqual(t1, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Execution context for deterministic and reproducible Imhotep runs.
|
||||
*
|
||||
* Every source of non-determinism (id generation, timestamps, randomness)
|
||||
* is captured behind injectable functions so tests can control them.
|
||||
*
|
||||
* In production, use `createVolatileContext()`. In tests, use
|
||||
* `createDeterministicContext(seed)`.
|
||||
*/
|
||||
|
||||
export interface ExecutionContext {
|
||||
/** Stable id generator (e.g. counter-based or UUID). */
|
||||
idGenerator: () => string
|
||||
/** Stable clock (e.g. fixed start time). */
|
||||
clock: () => number
|
||||
/** Seeded random number generator returning values in [0, 1). */
|
||||
rng: () => number
|
||||
/** The seed driving rng; stored in replay metadata. */
|
||||
seed: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic default (test mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Simple linear congruential generator for deterministic random. */
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0
|
||||
return () => {
|
||||
s = (1664525 * s + 1013904223) >>> 0
|
||||
return s / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Counter-based id generator. */
|
||||
function counterIdGenerator(prefix = 'id'): () => string {
|
||||
let n = 0
|
||||
return () => `${prefix}-${++n}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fully deterministic execution context.
|
||||
*
|
||||
* IDs are counter-based, timestamps start at 0, and rng is a seeded LCG.
|
||||
*/
|
||||
export function createDeterministicContext(seed = 42): ExecutionContext {
|
||||
return {
|
||||
idGenerator: counterIdGenerator('id'),
|
||||
clock: () => 0,
|
||||
rng: lcg(seed),
|
||||
seed,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volatile context (production)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a random 32-bit integer seed. */
|
||||
export function generateSeed(): number {
|
||||
return Math.floor(Math.random() * 1_000_000_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a volatile execution context using real clocks and Math.random.
|
||||
*
|
||||
* This is the production default. The returned seed can be captured in
|
||||
* replay metadata.
|
||||
*/
|
||||
export function createVolatileContext(seed?: number): ExecutionContext {
|
||||
const actualSeed = seed ?? generateSeed()
|
||||
// Use a fresh LCG even in volatile mode so that the seed is meaningful.
|
||||
// If caller passes a seed we honour it; otherwise we use Math.random.
|
||||
const rng = lcg(actualSeed)
|
||||
return {
|
||||
idGenerator: () => {
|
||||
// Simple nanoid-like fallback (21 chars, base-64 alphabet)
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'
|
||||
let id = ''
|
||||
for (let i = 0; i < 21; i++) {
|
||||
id += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
}
|
||||
return id
|
||||
},
|
||||
clock: () => Date.now(),
|
||||
rng,
|
||||
seed: actualSeed,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global default (backward compatible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _defaultContext: ExecutionContext | undefined
|
||||
|
||||
function isTestEnvironment(): boolean {
|
||||
return (
|
||||
typeof process !== 'undefined' &&
|
||||
!!process.env.NODE_TEST_CONTEXT
|
||||
)
|
||||
}
|
||||
|
||||
/** Return the current default context.
|
||||
*
|
||||
* In production, a single volatile context is created and cached.
|
||||
* In tests (detected via NODE_TEST_CONTEXT), a fresh deterministic
|
||||
* context is returned on each call unless one has been explicitly set
|
||||
* via setDefaultContext().
|
||||
*/
|
||||
export function getDefaultContext(): ExecutionContext {
|
||||
if (_defaultContext) {
|
||||
return _defaultContext
|
||||
}
|
||||
if (isTestEnvironment()) {
|
||||
return createDeterministicContext()
|
||||
}
|
||||
_defaultContext = createVolatileContext()
|
||||
return _defaultContext
|
||||
}
|
||||
|
||||
/** Set the global default context (useful for test setup). */
|
||||
export function setDefaultContext(ctx: ExecutionContext): void {
|
||||
_defaultContext = ctx
|
||||
}
|
||||
|
||||
/** Reset the global default context so the next call creates a fresh one. */
|
||||
export function resetDefaultContext(): void {
|
||||
_defaultContext = undefined
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Extractor and solver contract interfaces for Imhotep.
|
||||
*
|
||||
* The extractor is a compiler target: it consumes a fact request plan and
|
||||
* produces a geometry world. The solver operates on explicit clause families
|
||||
* with declared fact requirements and deterministic outputs.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ImhotepId,
|
||||
Environment,
|
||||
StateSnapshot,
|
||||
TimelineSnapshot,
|
||||
Subject,
|
||||
ClauseResult,
|
||||
GroupResult,
|
||||
Proof,
|
||||
} from './types.js'
|
||||
|
||||
import type { GeometryWorld, FactRequestPlan } from './world.js'
|
||||
|
||||
import type { ExtractionTrace, EvaluationTrace, Diagnostic } from './diagnostics.js'
|
||||
|
||||
import type { SceneTarget, RenderCase } from './scene-target.js'
|
||||
|
||||
export type { SceneTarget, RenderCase }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorRequest {
|
||||
requestId: ImhotepId
|
||||
sceneTarget: SceneTarget
|
||||
env: Environment
|
||||
statePlan: {
|
||||
snapshots: string[]
|
||||
}
|
||||
timelinePlan: {
|
||||
mode: string
|
||||
}
|
||||
subjects: Array<{
|
||||
id: ImhotepId
|
||||
selector: string
|
||||
}>
|
||||
requiredFacts: FactRequestPlan
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorResponse {
|
||||
requestId: ImhotepId
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
snapshots: GeometryWorld[]
|
||||
diagnostics: Diagnostic[]
|
||||
extractionTrace: ExtractionTrace
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorContract {
|
||||
/**
|
||||
* Consume a fact request plan and produce a geometry world.
|
||||
*/
|
||||
extract(request: ExtractorRequest, runtime: unknown): Promise<ExtractorResponse>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Adapter Interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorAdapter {
|
||||
name: string
|
||||
version: string
|
||||
supportedFacts: string[]
|
||||
execute(request: ExtractorRequest): Promise<AdapterExtractionResult>
|
||||
}
|
||||
|
||||
export interface AdapterExtractionResult {
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
world: GeometryWorld
|
||||
diagnostics: Diagnostic[]
|
||||
trace: ExtractionTrace
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solver Clause Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SolverClauseContract {
|
||||
/**
|
||||
* Descriptor declaring what facts this clause family requires.
|
||||
*/
|
||||
descriptor: ClauseDescriptor
|
||||
|
||||
/**
|
||||
* Evaluate a single clause against the geometry world.
|
||||
*/
|
||||
evaluate(world: GeometryWorld, clauseId: ImhotepId, traceBuilder: TraceBuilder): ClauseResult
|
||||
}
|
||||
|
||||
export interface ClauseDescriptor {
|
||||
clauseKind: string
|
||||
version: number
|
||||
requiredFacts: string[]
|
||||
}
|
||||
|
||||
export interface TraceBuilder {
|
||||
addStep(step: unknown): void
|
||||
build(): unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evaluation Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EvaluationResult {
|
||||
clauseResults: ClauseResult[]
|
||||
groupResults: GroupResult[]
|
||||
proofs: Proof[]
|
||||
diagnostics: Diagnostic[]
|
||||
trace: EvaluationTrace
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solver Evaluation Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SolverContract {
|
||||
/**
|
||||
* Evaluate compiled execution IR against a set of geometry worlds.
|
||||
*/
|
||||
evaluate(executionIr: unknown, worldSet: GeometryWorld[]): EvaluationResult
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compiler Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CompilerContract {
|
||||
compile(input: string | unknown): CompilerOutput
|
||||
}
|
||||
|
||||
export interface CompilerOutput {
|
||||
ast: unknown
|
||||
semanticIr: unknown
|
||||
executionIr: unknown
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporter Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReporterContract {
|
||||
report(evaluationResult: EvaluationResult, options: ReporterOptions): ReporterOutput
|
||||
}
|
||||
|
||||
export interface ReporterOptions {
|
||||
format: 'text' | 'json' | 'html'
|
||||
includeTrace: boolean
|
||||
includeProofs: boolean
|
||||
}
|
||||
|
||||
export interface ReporterOutput {
|
||||
text: string
|
||||
json: string
|
||||
overlays: unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fact Requirement Analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FactRequirementAnalysis {
|
||||
requiredFacts: string[]
|
||||
optionalFacts: string[]
|
||||
unsupportedFacts: string[]
|
||||
plan: FactRequestPlan
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene Closure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SceneClosure {
|
||||
subjects: Map<ImhotepId, Subject>
|
||||
frames: Map<ImhotepId, unknown>
|
||||
states: Map<ImhotepId, StateSnapshot>
|
||||
timelines: Map<ImhotepId, TimelineSnapshot>
|
||||
resolved: boolean
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ValidationContract {
|
||||
validateAst(ast: unknown): ValidationResult
|
||||
validateSemanticIr(semanticIr: unknown): ValidationResult
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Diagnostic and trace types for Imhotep.
|
||||
*
|
||||
* Imhotep maintains two major error classes:
|
||||
* 1. Contract failure diagnostics — the UI violated a declared law
|
||||
* 2. System use diagnostics — the user or engine could not formulate
|
||||
* or evaluate the request correctly
|
||||
*
|
||||
* The diagnostic model is inspired by vfile / vfile-message ecosystems.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, Position, ClauseResult, Proof } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DiagnosticCategory =
|
||||
| 'parse-error'
|
||||
| 'validation-error'
|
||||
| 'resolution-error'
|
||||
| 'extraction-error'
|
||||
| 'contract-failure'
|
||||
| 'indeterminate-result'
|
||||
| 'internal-error'
|
||||
|
||||
export type DiagnosticSeverity = 'error' | 'warning' | 'info'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Error Codes (Canonical Registry)
|
||||
// ---------------------------------------------------------------------------
|
||||
// All packages MUST use codes from this union. No package may invent a
|
||||
// code that is not listed here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DiagnosticCode =
|
||||
// -------------------------------------------------------------------------
|
||||
// Parse errors (imhotep-dsl)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_PARSE_UNEXPECTED_TOKEN'
|
||||
| 'IMH_PARSE_INVALID_SYNTAX'
|
||||
| 'IMH_PARSE_UNTERMINATED_BLOCK'
|
||||
| 'IMH_PARSE_ERROR'
|
||||
| 'IMH_PARSE_IS_KEYWORD'
|
||||
| 'IMH_PARSE_MISSING_QUOTES'
|
||||
| 'IMH_PARSE_WRONG_QUOTE_STYLE'
|
||||
| 'IMH_PARSE_MISSING_UNIT'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation errors (imhotep-dsl)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_VALID_ILLEGAL_RELATION_OPTION'
|
||||
| 'IMH_VALID_INVALID_UNIT'
|
||||
| 'IMH_VALID_INVALID_STATE_TIMELINE'
|
||||
| 'IMH_VALID_INVALID_ENV_GUARD'
|
||||
| 'IMH_VALID_INVALID_QUANTIFIER_NESTING'
|
||||
| 'IMH_VALID_CONTRADICTION'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolution errors (imhotep-playwright, imhotep-extractor)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_SELECTOR_ZERO_MATCHES'
|
||||
| 'IMH_SELECTOR_AMBIGUOUS'
|
||||
| 'IMH_SELECTOR_NO_MATCH'
|
||||
| 'IMH_SELECTOR_RESOLUTION_FAILED'
|
||||
| 'IMH_FRAME_AMBIGUOUS'
|
||||
| 'IMH_FRAME_UNSUPPORTED'
|
||||
| 'IMH_TOPOLOGY_UNSUPPORTED'
|
||||
| 'IMH_STATE_MATERIALIZATION_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extraction errors (imhotep-cdp, imhotep-extractor)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_EXTRACT_PARTIAL'
|
||||
| 'IMH_EXTRACT_UNAVAILABLE_FACT'
|
||||
| 'IMH_EXTRACT_PROTOCOL_ERROR'
|
||||
| 'IMH_CDP_SESSION_ATTACH_FAILED'
|
||||
| 'IMH_DOM_EXTRACTION_FAILED'
|
||||
| 'IMH_BOX_MODEL_PARTIAL'
|
||||
| 'IMH_BOX_MODEL_FAILED'
|
||||
| 'IMH_VISUAL_BOX_PARTIAL'
|
||||
| 'IMH_VISUAL_BOX_FAILED'
|
||||
| 'IMH_FRAGMENT_PARTIAL'
|
||||
| 'IMH_FRAGMENT_FAILED'
|
||||
| 'IMH_TRANSFORM_PARTIAL'
|
||||
| 'IMH_TRANSFORM_FAILED'
|
||||
| 'IMH_STYLE_PARTIAL'
|
||||
| 'IMH_STYLE_FAILED'
|
||||
| 'IMH_TOPOLOGY_PARTIAL'
|
||||
| 'IMH_TOPOLOGY_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extractor planner errors (imhotep-extractor)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_EXTRACTOR_EMPTY_SELECTOR'
|
||||
| 'IMH_EXTRACTOR_MISSING_ENV_GUARD'
|
||||
| 'IMH_EXTRACTOR_UNRESOLVED_ENV_GUARD'
|
||||
| 'IMH_EXTRACTOR_MISSING_STATE'
|
||||
| 'IMH_EXTRACTOR_UNRESOLVED_STATE'
|
||||
| 'IMH_EXTRACTOR_UNSUPPORTED_STATE'
|
||||
| 'IMH_EXTRACTOR_NON_STATIC_TIMELINE'
|
||||
| 'IMH_EXTRACTOR_UNSUPPORTED_CLAUSE_TYPE'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contract failures (imhotep-solver, imhotep-playwright)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_RELATION_LEFT_OF_FAILED'
|
||||
| 'IMH_RELATION_RIGHT_OF_FAILED'
|
||||
| 'IMH_RELATION_ABOVE_FAILED'
|
||||
| 'IMH_RELATION_BELOW_FAILED'
|
||||
| 'IMH_RELATION_ALIGNED_FAILED'
|
||||
| 'IMH_RELATION_CENTERED_FAILED'
|
||||
| 'IMH_RELATION_INSIDE_FAILED'
|
||||
| 'IMH_RELATION_CONTAINS_FAILED'
|
||||
| 'IMH_RELATION_OVERLAPS_FAILED'
|
||||
| 'IMH_RELATION_BESIDE_FAILED'
|
||||
| 'IMH_RELATION_ADJACENT_FAILED'
|
||||
| 'IMH_RELATION_NEAR_FAILED'
|
||||
| 'IMH_RELATION_FAILED'
|
||||
| 'IMH_SIZE_AT_LEAST_FAILED'
|
||||
| 'IMH_SIZE_AT_MOST_FAILED'
|
||||
| 'IMH_SIZE_BETWEEN_FAILED'
|
||||
| 'IMH_ALIGNMENT_FAILED'
|
||||
| 'IMH_TOPOLOGY_CLIPPED_FAILED'
|
||||
| 'IMH_TOPOLOGY_STACKING_FAILED'
|
||||
| 'IMH_VISIBILITY_FAILED'
|
||||
| 'IMH_PREDICATE_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cardinality failures (imhotep-playwright)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_CARDINALITY_EXACTLYONE_FAILED'
|
||||
| 'IMH_CARDINALITY_ATLEASTN_FAILED'
|
||||
| 'IMH_CARDINALITY_ATMOSTN_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Indeterminate results
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_INDETERMINATE_MISSING_FACT'
|
||||
| 'IMH_INDETERMINATE_UNSTABLE_INPUT'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal errors
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND'
|
||||
| 'IMH_INTERNAL_EVALUATION_EXCEPTION'
|
||||
| 'IMH_FEATURE_NOT_YET_IMPLEMENTED'
|
||||
| 'IMH_UNKNOWN_FAILURE'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// System use / fact observation diagnostics
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_FACT_OBSERVED_GAP'
|
||||
| 'IMH_FACT_OBSERVED_SIZE'
|
||||
| 'IMH_FACT_OBSERVED_TOPOLOGY'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logic engine errors (imhotep-solver)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_LOGIC_UNKNOWN_FORMULA_KIND'
|
||||
| 'IMH_LOGIC_DOMAIN_UNRESOLVED'
|
||||
| 'IMH_LOGIC_VACUOUS_FORALL'
|
||||
| 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS'
|
||||
| 'IMH_LOGIC_PREDICATE_MISSING'
|
||||
| 'IMH_LOGIC_UNBOUND_VARIABLE'
|
||||
| 'IMH_LOGIC_UNSUPPORTED_TERM'
|
||||
| 'IMH_LOGIC_UNKNOWN_NODE'
|
||||
| 'IMH_LOGIC_ARITY_MISMATCH'
|
||||
| 'IMH_LOGIC_UNKNOWN_PREDICATE'
|
||||
| 'IMH_LOGIC_FREE_VARIABLE'
|
||||
| 'IMH_LOGIC_EMPTY_DOMAIN'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Solver engine errors (imhotep-solver)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_EVALUATOR_MISSING'
|
||||
| 'IMH_EVALUATOR_EXCEPTION'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Quantifier errors (imhotep-solver)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_QUANTIFIER_NO_SUBCLAUSES'
|
||||
| 'IMH_PAIRWISE_INSUFFICIENT'
|
||||
| 'IMH_UNKNOWN_QUANTIFIER'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Property-run diagnostics (imhotep-playwright)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_ENUMERATED_RUN_ERROR'
|
||||
| 'IMH_SAMPLED_RUN_ERROR'
|
||||
| 'IMH_PROPERTY_RUN_FAILED'
|
||||
| 'IMH_PROPERTY_PASSED'
|
||||
| 'IMH_PROPERTY_FAILED'
|
||||
| 'IMH_PROPERTY_REPLAY'
|
||||
| 'IMH_PROPERTY_SHRUNK'
|
||||
| 'IMH_ENUMERATED_PASSED'
|
||||
| 'IMH_ENUMERATED_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reporter internal diagnostics (imhotep-reporter)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_FRAME_CONTEXT'
|
||||
| 'IMH_WITNESS_ENV'
|
||||
| 'IMH_WITNESS_SNAPSHOT'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Related Diagnostic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RelatedDiagnostic {
|
||||
code?: DiagnosticCode
|
||||
message: string
|
||||
position?: Position
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V1 Diagnostic Schema Extensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SourceRef {
|
||||
file?: string
|
||||
line?: number
|
||||
column?: number
|
||||
selector?: string
|
||||
}
|
||||
|
||||
export interface SuggestedFix {
|
||||
action: 'remove' | 'replace' | 'insert' | 'wrap'
|
||||
target?: string
|
||||
value?: string
|
||||
rationale: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Diagnostic {
|
||||
code: DiagnosticCode
|
||||
severity: DiagnosticSeverity
|
||||
category: DiagnosticCategory
|
||||
message: string
|
||||
position?: Position
|
||||
source: string
|
||||
clauseId?: ImhotepId
|
||||
sceneId?: ImhotepId
|
||||
snapshotId?: ImhotepId
|
||||
envCaseId?: ImhotepId
|
||||
/** Subject resolution origin for semantic selectors (Stream 7). */
|
||||
subjectOrigin?: string
|
||||
related: RelatedDiagnostic[]
|
||||
traceRef?: ImhotepId
|
||||
fixHints: string[]
|
||||
/** V1 required: structured metrics associated with this diagnostic. */
|
||||
metrics: Record<string, number>
|
||||
/** V1 required: canonical source reference. */
|
||||
sourceRef: SourceRef | import('./types.js').SourceReference
|
||||
/** V1 optional: automated fix suggestion. */
|
||||
suggestedFix?: SuggestedFix
|
||||
/** Human-readable label derived from the normalized contract (workstream C). */
|
||||
clauseLabel?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trace Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TracePhase =
|
||||
| 'parse-started'
|
||||
| 'ast-created'
|
||||
| 'ir-normalized'
|
||||
| 'fact-requirements-computed'
|
||||
| 'extraction-started'
|
||||
| 'extraction-step-completed'
|
||||
| 'world-normalized'
|
||||
| 'clause-evaluated'
|
||||
| 'proof-created'
|
||||
| 'witness-shrunk'
|
||||
| 'diagnostic-emitted'
|
||||
|
||||
export interface TraceEvent {
|
||||
traceEventId: ImhotepId
|
||||
phase: TracePhase
|
||||
at: number
|
||||
refs: {
|
||||
clauseId?: ImhotepId
|
||||
proofId?: ImhotepId
|
||||
snapshotId?: ImhotepId
|
||||
diagnosticId?: ImhotepId
|
||||
}
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface EvaluationTrace {
|
||||
events: TraceEvent[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Witness Shrinking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ShrinkAxis {
|
||||
name: 'environment' | 'state' | 'timeline' | 'subject' | 'facts' | 'clauseGroup'
|
||||
reduced: boolean
|
||||
remainingValue: unknown
|
||||
}
|
||||
|
||||
export interface ShrinkResult {
|
||||
originalWitness: Proof['witness']
|
||||
shrunkWitness: Proof['witness']
|
||||
axes: ShrinkAxis[]
|
||||
preserved: boolean
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction Trace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractionTrace {
|
||||
steps: ExtractionStep[]
|
||||
timings: TimingEntry[]
|
||||
protocolCalls: ProtocolCall[]
|
||||
}
|
||||
|
||||
export interface ExtractionStep {
|
||||
stepId: ImhotepId
|
||||
factKind: string
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
}
|
||||
|
||||
export interface TimingEntry {
|
||||
stepId: ImhotepId
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
|
||||
export interface ProtocolCall {
|
||||
stepId: ImhotepId
|
||||
protocol: string
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createDiagnostic(
|
||||
code: DiagnosticCode,
|
||||
category: DiagnosticCategory,
|
||||
severity: DiagnosticSeverity,
|
||||
message: string,
|
||||
opts?: Partial<Omit<Diagnostic, 'code' | 'category' | 'severity' | 'message'>>,
|
||||
): Diagnostic {
|
||||
return {
|
||||
code,
|
||||
severity,
|
||||
category,
|
||||
message,
|
||||
source: opts?.source ?? 'imhotep-core',
|
||||
related: opts?.related ?? [],
|
||||
fixHints: opts?.fixHints ?? getDefaultFixHints(code),
|
||||
metrics: opts?.metrics ?? {},
|
||||
sourceRef: opts?.sourceRef ?? {},
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default Severity / Category / Fix-Hint Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the default severity for a canonical diagnostic code.
|
||||
* This is a fallback; callers should override when context demands it.
|
||||
*/
|
||||
export function getDefaultSeverity(code: DiagnosticCode): DiagnosticSeverity {
|
||||
switch (code) {
|
||||
// Warnings
|
||||
case 'IMH_SELECTOR_AMBIGUOUS':
|
||||
case 'IMH_FRAME_AMBIGUOUS':
|
||||
case 'IMH_SELECTOR_NO_MATCH':
|
||||
case 'IMH_EXTRACT_PARTIAL':
|
||||
case 'IMH_BOX_MODEL_PARTIAL':
|
||||
case 'IMH_VISUAL_BOX_PARTIAL':
|
||||
case 'IMH_FRAGMENT_PARTIAL':
|
||||
case 'IMH_TRANSFORM_PARTIAL':
|
||||
case 'IMH_STYLE_PARTIAL':
|
||||
case 'IMH_TOPOLOGY_PARTIAL':
|
||||
case 'IMH_INDETERMINATE_MISSING_FACT':
|
||||
case 'IMH_INDETERMINATE_UNSTABLE_INPUT':
|
||||
return 'warning'
|
||||
|
||||
// Info
|
||||
case 'IMH_LOGIC_VACUOUS_FORALL':
|
||||
case 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS':
|
||||
case 'IMH_FACT_OBSERVED_GAP':
|
||||
case 'IMH_FACT_OBSERVED_SIZE':
|
||||
case 'IMH_FACT_OBSERVED_TOPOLOGY':
|
||||
case 'IMH_PROPERTY_PASSED':
|
||||
case 'IMH_PROPERTY_REPLAY':
|
||||
case 'IMH_ENUMERATED_PASSED':
|
||||
case 'IMH_FRAME_CONTEXT':
|
||||
case 'IMH_WITNESS_ENV':
|
||||
case 'IMH_WITNESS_SNAPSHOT':
|
||||
return 'info'
|
||||
|
||||
// Everything else defaults to error
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default category for a canonical diagnostic code.
|
||||
*/
|
||||
export function getDefaultCategory(code: DiagnosticCode): DiagnosticCategory {
|
||||
if (code.startsWith('IMH_PARSE_')) return 'parse-error'
|
||||
if (code.startsWith('IMH_VALID_')) return 'validation-error'
|
||||
if (code.startsWith('IMH_SELECTOR_') || code.startsWith('IMH_FRAME_') || code.startsWith('IMH_STATE_MATERIALIZATION')) return 'resolution-error'
|
||||
if (code.startsWith('IMH_EXTRACTOR_')) return 'resolution-error'
|
||||
if (code.startsWith('IMH_EXTRACT_') || code.startsWith('IMH_CDP_') || code.startsWith('IMH_DOM_') || code.startsWith('IMH_BOX_MODEL') || code.startsWith('IMH_VISUAL_BOX') || code.startsWith('IMH_FRAGMENT') || code.startsWith('IMH_TRANSFORM') || code.startsWith('IMH_STYLE') || code.startsWith('IMH_TOPOLOGY_PARTIAL') || code.startsWith('IMH_TOPOLOGY_FAILED')) return 'extraction-error'
|
||||
if (code.startsWith('IMH_RELATION_') || code.startsWith('IMH_SIZE_') || code.startsWith('IMH_ALIGNMENT') || code.startsWith('IMH_TOPOLOGY_CLIPPED') || code.startsWith('IMH_TOPOLOGY_STACKING') || code.startsWith('IMH_VISIBILITY') || code.startsWith('IMH_PREDICATE') || code.startsWith('IMH_CARDINALITY')) return 'contract-failure'
|
||||
if (code.startsWith('IMH_FACT_OBSERVED_')) return 'contract-failure'
|
||||
if (code.startsWith('IMH_PROPERTY_') || code.startsWith('IMH_ENUMERATED_') || code === 'IMH_PROPERTY_RUN_FAILED') return 'contract-failure'
|
||||
if (code.startsWith('IMH_INDETERMINATE_')) return 'indeterminate-result'
|
||||
if (code.startsWith('IMH_INTERNAL_') || code.startsWith('IMH_FEATURE_') || code === 'IMH_UNKNOWN_FAILURE') return 'internal-error'
|
||||
if (code.startsWith('IMH_LOGIC_') || code.startsWith('IMH_EVALUATOR_') || code.startsWith('IMH_QUANTIFIER_') || code === 'IMH_UNKNOWN_QUANTIFIER') return 'internal-error'
|
||||
if (code.startsWith('IMH_FRAME_CONTEXT') || code.startsWith('IMH_WITNESS_')) return 'internal-error'
|
||||
return 'internal-error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return default fix-hints for a canonical diagnostic code.
|
||||
* Callers should augment or override these with context-specific hints.
|
||||
*/
|
||||
export function getDefaultFixHints(code: DiagnosticCode): string[] {
|
||||
const hints: string[] = []
|
||||
|
||||
if (code.startsWith('IMH_PARSE_')) {
|
||||
hints.push('Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.')
|
||||
hints.push('Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.')
|
||||
hints.push('Gap values require units: e.g. gap 8px.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_SELECTOR_ZERO_MATCHES' || code === 'IMH_SELECTOR_NO_MATCH') {
|
||||
hints.push('Check that the selector is valid and matches an element in the DOM.')
|
||||
hints.push('Use ui.extract(selector) to verify the selector resolves to at least one element.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_SELECTOR_AMBIGUOUS') {
|
||||
hints.push('The selector matches more than one element. Use a more specific selector or add a quantifier.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_EXTRACT_PROTOCOL_ERROR' || code === 'IMH_CDP_SESSION_ATTACH_FAILED') {
|
||||
hints.push('Verify the page is fully loaded before running assertions.')
|
||||
hints.push('Check that selectors are valid CSS selectors or semantic references.')
|
||||
hints.push('Use ui.extract(selector) to debug selector resolution.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_FEATURE_NOT_YET_IMPLEMENTED') {
|
||||
hints.push('This assertion uses a relation that is documented but not yet implemented.')
|
||||
hints.push('Use a supported relation such as leftOf, rightOf, above, below, inside, overlaps, alignedWith, or centeredWithin.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_RELATION_') || code === 'IMH_ALIGNMENT_FAILED' || code === 'IMH_PREDICATE_FAILED') {
|
||||
hints.push('Verify the expected layout and consider adjusting thresholds.')
|
||||
hints.push('Check element positions using ui.extract(selector) to inspect actual geometry.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_SIZE_')) {
|
||||
hints.push('Check the expected size threshold and the actual element dimensions using ui.extract(selector).')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_CARDINALITY_')) {
|
||||
hints.push('Verify the selector matches the expected number of elements.')
|
||||
hints.push('Use a more specific selector or adjust the cardinality bound.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_LOGIC_')) {
|
||||
hints.push('Check formula syntax and ensure all referenced predicates and domains are defined.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_TOPOLOGY_')) {
|
||||
hints.push('Check CSS properties that affect topology (position, overflow, contain, z-index).')
|
||||
}
|
||||
|
||||
if (code === 'IMH_PROPERTY_RUN_FAILED') {
|
||||
hints.push('Review the failing counterexample and replay with the same seed.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_ENUMERATED_RUN_ERROR') || code.startsWith('IMH_SAMPLED_RUN_ERROR')) {
|
||||
hints.push('Check that the property callback does not throw for the reported input.')
|
||||
}
|
||||
|
||||
return hints
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category Classification Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isParseError(diagnostic: Diagnostic): boolean {
|
||||
return diagnostic.category === 'parse-error'
|
||||
}
|
||||
|
||||
export function isContractFailure(diagnostic: Diagnostic): boolean {
|
||||
return diagnostic.category === 'contract-failure'
|
||||
}
|
||||
|
||||
export function isSystemUseError(diagnostic: Diagnostic): boolean {
|
||||
return (
|
||||
diagnostic.category === 'parse-error' ||
|
||||
diagnostic.category === 'validation-error' ||
|
||||
diagnostic.category === 'resolution-error' ||
|
||||
diagnostic.category === 'extraction-error' ||
|
||||
diagnostic.category === 'internal-error'
|
||||
)
|
||||
}
|
||||
|
||||
export function isIndeterminate(diagnostic: Diagnostic): boolean {
|
||||
return diagnostic.category === 'indeterminate-result'
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Domain definitions for deterministic scene logic.
|
||||
*
|
||||
* A domain is a finite set of geometry subjects extracted from a scene.
|
||||
* Domains are the ground over which quantifiers range.
|
||||
*
|
||||
* Invariant: every domain is enumerable and closed within a single
|
||||
* materialized GeometryWorld. No domain may silently default to an
|
||||
* empty set; empty domains must be reported explicitly.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, SubjectKind } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Descriptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainDescriptor {
|
||||
domainId: ImhotepId
|
||||
kind: DomainKind
|
||||
selector?: string
|
||||
parentVar?: string
|
||||
subjectKind?: SubjectKind
|
||||
}
|
||||
|
||||
export type DomainKind =
|
||||
| 'elements'
|
||||
| 'descendants'
|
||||
| 'lineBoxes'
|
||||
| 'textRuns'
|
||||
| 'fragments'
|
||||
| 'frames'
|
||||
| 'custom'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Value (materialized)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainValue {
|
||||
domainId: ImhotepId
|
||||
/** Stable numeric subject ids, stored as a flat array for fast iteration. */
|
||||
subjectIds: Uint32Array
|
||||
/** Source selector or domain expression that produced this set. */
|
||||
provenance: string
|
||||
/** If true, the domain is known to be complete for the scene. */
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in Domain Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainFunctions {
|
||||
/** Select elements matching a CSS selector. */
|
||||
elements(selector: string): DomainDescriptor
|
||||
|
||||
/** Select descendants of a bound parent variable matching a selector. */
|
||||
descendants(parentVar: string, selector: string): DomainDescriptor
|
||||
|
||||
/** Select line boxes for a given text node subject. */
|
||||
lineBoxes(textNodeId: ImhotepId): DomainDescriptor
|
||||
|
||||
/** Select text runs for a given text node subject. */
|
||||
textRuns(textNodeId: ImhotepId): DomainDescriptor
|
||||
|
||||
/** Select fragment boxes for a given subject. */
|
||||
fragments(subjectId: ImhotepId): DomainDescriptor
|
||||
|
||||
/** Select frames of a given kind. */
|
||||
frames(frameKind: string): DomainDescriptor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Enumeration Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainEnumeration {
|
||||
domainId: ImhotepId
|
||||
subjectIds: Uint32Array
|
||||
empty: boolean
|
||||
diagnostic?: DomainDiagnostic
|
||||
}
|
||||
|
||||
export interface DomainDiagnostic {
|
||||
code: string
|
||||
message: string
|
||||
domainId: ImhotepId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default Domain Registry Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class DomainRegistry {
|
||||
private registry = new Map<ImhotepId, DomainDescriptor>()
|
||||
|
||||
register(descriptor: DomainDescriptor): void {
|
||||
this.registry.set(descriptor.domainId, descriptor)
|
||||
}
|
||||
|
||||
lookup(domainId: ImhotepId): DomainDescriptor | undefined {
|
||||
return this.registry.get(domainId)
|
||||
}
|
||||
|
||||
list(): DomainDescriptor[] {
|
||||
return Array.from(this.registry.values())
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.registry.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Global default instance for backward compatibility.
|
||||
const globalDomainRegistry = new DomainRegistry()
|
||||
|
||||
export function registerDomain(descriptor: DomainDescriptor): void {
|
||||
globalDomainRegistry.register(descriptor)
|
||||
}
|
||||
|
||||
export function lookupDomain(domainId: ImhotepId): DomainDescriptor | undefined {
|
||||
return globalDomainRegistry.lookup(domainId)
|
||||
}
|
||||
|
||||
export function listDomains(): DomainDescriptor[] {
|
||||
return globalDomainRegistry.list()
|
||||
}
|
||||
|
||||
export function clearDomainRegistry(): void {
|
||||
globalDomainRegistry.clear()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createElementsDomain(
|
||||
domainId: ImhotepId,
|
||||
selector: string,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'elements',
|
||||
selector,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDescendantsDomain(
|
||||
domainId: ImhotepId,
|
||||
parentVar: string,
|
||||
selector: string,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'descendants',
|
||||
parentVar,
|
||||
selector,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineBoxesDomain(
|
||||
domainId: ImhotepId,
|
||||
textNodeId: ImhotepId,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'lineBoxes',
|
||||
selector: textNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextRunsDomain(
|
||||
domainId: ImhotepId,
|
||||
textNodeId: ImhotepId,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'textRuns',
|
||||
selector: textNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createFragmentsDomain(
|
||||
domainId: ImhotepId,
|
||||
subjectId: ImhotepId,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'fragments',
|
||||
selector: subjectId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createFramesDomain(
|
||||
domainId: ImhotepId,
|
||||
frameKind: string,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'frames',
|
||||
selector: frameKind,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Value Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createDomainValue(
|
||||
domainId: ImhotepId,
|
||||
subjectIds: Uint32Array,
|
||||
provenance: string,
|
||||
closed = true,
|
||||
): DomainValue {
|
||||
return {
|
||||
domainId,
|
||||
subjectIds,
|
||||
provenance,
|
||||
closed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Tests for geometry cache serialization and deserialization.
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
serializeGeometryWorld,
|
||||
deserializeGeometryWorld,
|
||||
computeGeometryCacheKey,
|
||||
readCachedWorld,
|
||||
writeCachedWorld,
|
||||
clearGeometryCache,
|
||||
readCachedExtractionResult,
|
||||
writeCachedExtractionResult,
|
||||
} from './geometry-cache.js'
|
||||
import { createEmptyWorld } from './world.js'
|
||||
import type { GeometryWorld } from './world.js'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('geometry cache', () => {
|
||||
function createTestWorld(): GeometryWorld {
|
||||
const world = createEmptyWorld('scene_1', 'snap_1')
|
||||
world.env.viewportWidth = 1280
|
||||
world.env.viewportHeight = 720
|
||||
world.source.url = 'https://example.com'
|
||||
world.strings.values = ['hello', 'world']
|
||||
|
||||
// Populate subjects table with some data
|
||||
world.subjects.ids = new Uint32Array([1, 2, 3])
|
||||
world.subjects.domNodeId = new Uint32Array([10, 20, 30])
|
||||
world.subjects.subjectKind = new Uint16Array([1, 1, 2])
|
||||
world.subjects.primaryBoxId = new Uint32Array([100, 200, 300])
|
||||
|
||||
// Populate boxes with geometry
|
||||
world.boxes.boxId = new Uint32Array([100, 200])
|
||||
world.boxes.subjectId = new Uint32Array([1, 2])
|
||||
world.boxes.frameId = new Uint32Array([1, 1])
|
||||
world.boxes.borderLeft = new Float64Array([0, 110])
|
||||
world.boxes.borderTop = new Float64Array([0, 0])
|
||||
world.boxes.borderRight = new Float64Array([100, 210])
|
||||
world.boxes.borderBottom = new Float64Array([40, 40])
|
||||
world.boxes.paddingLeft = new Float64Array([0, 0])
|
||||
world.boxes.paddingTop = new Float64Array([0, 0])
|
||||
world.boxes.paddingRight = new Float64Array([0, 0])
|
||||
world.boxes.paddingBottom = new Float64Array([0, 0])
|
||||
world.boxes.contentLeft = new Float64Array([0, 0])
|
||||
world.boxes.contentTop = new Float64Array([0, 0])
|
||||
world.boxes.contentRight = new Float64Array([0, 0])
|
||||
world.boxes.contentBottom = new Float64Array([0, 0])
|
||||
|
||||
// Populate topology
|
||||
world.topology.containingBlockOf = new Uint32Array([0, 1, 2])
|
||||
world.topology.nearestPositionedAncestorOf = new Uint32Array([0, 0, 0])
|
||||
world.topology.scrollContainerOf = new Uint32Array([0, 0, 0])
|
||||
world.topology.stackingContextOf = new Uint32Array([1, 1, 1])
|
||||
world.topology.formattingContextOf = new Uint32Array([1, 1, 1])
|
||||
world.topology.clippingRootOf = new Uint32Array([0, 0, 0])
|
||||
world.topology.paintOrderBucket = new Uint16Array([1, 1, 1])
|
||||
world.topology.paintOrderIndex = new Uint32Array([0, 1, 2])
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
test('round-trip serialization preserves primitive fields', () => {
|
||||
const world = createTestWorld()
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.strictEqual(restored.sceneId, 'scene_1')
|
||||
assert.strictEqual(restored.snapshotId, 'snap_1')
|
||||
assert.strictEqual(restored.env.viewportWidth, 1280)
|
||||
assert.strictEqual(restored.env.viewportHeight, 720)
|
||||
assert.strictEqual(restored.source.url, 'https://example.com')
|
||||
assert.deepStrictEqual(restored.strings.values, ['hello', 'world'])
|
||||
})
|
||||
|
||||
test('round-trip preserves TypedArray data and types', () => {
|
||||
const world = createTestWorld()
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
// Verify subjects table
|
||||
assert.ok(restored.subjects.ids instanceof Uint32Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.subjects.ids),
|
||||
[1, 2, 3],
|
||||
)
|
||||
|
||||
assert.ok(restored.subjects.subjectKind instanceof Uint16Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.subjects.subjectKind),
|
||||
[1, 1, 2],
|
||||
)
|
||||
|
||||
// Verify boxes table
|
||||
assert.ok(restored.boxes.borderLeft instanceof Float64Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.boxes.borderLeft),
|
||||
[0, 110],
|
||||
)
|
||||
|
||||
assert.ok(restored.boxes.borderRight instanceof Float64Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.boxes.borderRight),
|
||||
[100, 210],
|
||||
)
|
||||
})
|
||||
|
||||
test('round-trip preserves topology data', () => {
|
||||
const world = createTestWorld()
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.topology.stackingContextOf),
|
||||
[1, 1, 1],
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.topology.paintOrderIndex),
|
||||
[0, 1, 2],
|
||||
)
|
||||
})
|
||||
|
||||
test('empty world round-trips', () => {
|
||||
const world = createEmptyWorld('empty_scene', 'empty_snap')
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.strictEqual(restored.sceneId, 'empty_scene')
|
||||
assert.strictEqual(restored.snapshotId, 'empty_snap')
|
||||
assert.strictEqual(restored.subjects.ids.length, 0)
|
||||
assert.strictEqual(restored.boxes.boxId.length, 0)
|
||||
})
|
||||
|
||||
test('world with undefined tables round-trips without crash', () => {
|
||||
const world = createEmptyWorld('partial', 'snap') as any
|
||||
// Simulate a world where some tables are undefined (common after extraction)
|
||||
world.frames = undefined
|
||||
world.fragments = undefined
|
||||
world.styles = undefined
|
||||
world.text = undefined
|
||||
world.paint = undefined
|
||||
world.provenance = undefined
|
||||
world.confidence = undefined
|
||||
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.strictEqual(restored.sceneId, 'partial')
|
||||
assert.ok(restored.frames)
|
||||
assert.ok(restored.fragments)
|
||||
assert.ok(restored.styles)
|
||||
assert.ok(restored.text)
|
||||
assert.ok(restored.paint)
|
||||
assert.ok(restored.provenance)
|
||||
assert.ok(restored.confidence)
|
||||
})
|
||||
|
||||
test('cache key is stable for identical inputs', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], { viewportWidth: 1280 })
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], { viewportWidth: 1280 })
|
||||
assert.strictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('cache key differs for different selectors', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], {})
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.a', '.c'], {})
|
||||
assert.notStrictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('cache key is order-independent for selectors', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b', '.c'], {})
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.c', '.a', '.b'], {})
|
||||
assert.strictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('cache key differs for different environments', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1280 })
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1920 })
|
||||
assert.notStrictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('write and read cached world', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createTestWorld()
|
||||
const cacheKey = 'test-key-123'
|
||||
|
||||
await writeCachedWorld(cacheDir, cacheKey, world)
|
||||
const restored = await readCachedWorld(cacheDir, cacheKey)
|
||||
|
||||
assert.ok(restored, 'should read back cached world')
|
||||
assert.strictEqual(restored.sceneId, 'scene_1')
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.subjects.ids),
|
||||
[1, 2, 3],
|
||||
)
|
||||
|
||||
// Cache miss for unknown key
|
||||
const miss = await readCachedWorld(cacheDir, 'unknown-key')
|
||||
assert.strictEqual(miss, null)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('clear cache removes all entries', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createEmptyWorld('s', 'sn')
|
||||
await writeCachedWorld(cacheDir, 'key1', world)
|
||||
await writeCachedWorld(cacheDir, 'key2', world)
|
||||
|
||||
assert.ok(await readCachedWorld(cacheDir, 'key1'))
|
||||
assert.ok(await readCachedWorld(cacheDir, 'key2'))
|
||||
|
||||
clearGeometryCache(cacheDir)
|
||||
|
||||
assert.strictEqual(await readCachedWorld(cacheDir, 'key1'), null)
|
||||
assert.strictEqual(await readCachedWorld(cacheDir, 'key2'), null)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('write and read cached extraction result with selector mappings', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createTestWorld()
|
||||
const selectorToIds = new Map<string, number[]>([
|
||||
['.button', [1, 2]],
|
||||
['.label', [3]],
|
||||
])
|
||||
const errors = [
|
||||
{
|
||||
code: 'IMH_TEST_ERROR',
|
||||
severity: 'error' as const,
|
||||
category: 'test-error' as const,
|
||||
message: 'Test error',
|
||||
source: 'test',
|
||||
related: [] as string[],
|
||||
fixHints: [] as string[],
|
||||
metrics: {} as Record<string, unknown>,
|
||||
sourceRef: {} as Record<string, unknown>,
|
||||
},
|
||||
]
|
||||
|
||||
await writeCachedExtractionResult(cacheDir, 'extract-1', world, selectorToIds, errors)
|
||||
const cached = await readCachedExtractionResult(cacheDir, 'extract-1')
|
||||
|
||||
assert.ok(cached, 'should read back cached extraction result')
|
||||
assert.strictEqual(cached.world.sceneId, 'scene_1')
|
||||
assert.deepStrictEqual(
|
||||
Array.from(cached.selectorToIds.entries()),
|
||||
[['.button', [1, 2]], ['.label', [3]]],
|
||||
)
|
||||
assert.strictEqual(cached.errors.length, 1)
|
||||
assert.strictEqual(cached.errors[0].code, 'IMH_TEST_ERROR')
|
||||
|
||||
// Cache miss for unknown key
|
||||
assert.strictEqual(await readCachedExtractionResult(cacheDir, 'unknown-key'), null)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cached extraction result round-trips empty maps and arrays', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createEmptyWorld('empty', 'snap')
|
||||
const selectorToIds = new Map<string, number[]>()
|
||||
const errors: any[] = []
|
||||
|
||||
await writeCachedExtractionResult(cacheDir, 'empty-extract', world, selectorToIds, errors)
|
||||
const cached = await readCachedExtractionResult(cacheDir, 'empty-extract')
|
||||
|
||||
assert.ok(cached)
|
||||
assert.strictEqual(cached.selectorToIds.size, 0)
|
||||
assert.strictEqual(cached.errors.length, 0)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cache read is fast for large worlds', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
// Build a world with 1000 subjects and boxes
|
||||
const world = createEmptyWorld('bench', 'snap')
|
||||
const n = 1000
|
||||
world.subjects.ids = new Uint32Array(Array.from({ length: n }, (_, i) => i + 1))
|
||||
world.subjects.domNodeId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 10))
|
||||
world.subjects.subjectKind = new Uint16Array(Array.from({ length: n }, () => 1))
|
||||
world.subjects.primaryBoxId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 100))
|
||||
world.boxes.boxId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 100))
|
||||
world.boxes.subjectId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 1))
|
||||
world.boxes.frameId = new Uint32Array(Array.from({ length: n }, () => 1))
|
||||
world.boxes.borderLeft = new Float64Array(Array.from({ length: n }, (_, i) => i * 10))
|
||||
world.boxes.borderTop = new Float64Array(Array.from({ length: n }, () => 0))
|
||||
world.boxes.borderRight = new Float64Array(Array.from({ length: n }, (_, i) => i * 10 + 100))
|
||||
world.boxes.borderBottom = new Float64Array(Array.from({ length: n }, () => 50))
|
||||
|
||||
const selectorToIds = new Map<string, number[]>([['.item', Array.from({ length: n }, (_, i) => i + 1)]])
|
||||
const errors: any[] = []
|
||||
|
||||
await writeCachedExtractionResult(cacheDir, 'bench-extract', world, selectorToIds, errors)
|
||||
|
||||
const start = performance.now()
|
||||
const cached = await readCachedExtractionResult(cacheDir, 'bench-extract')
|
||||
const duration = performance.now() - start
|
||||
|
||||
assert.ok(cached, 'should read cached large world')
|
||||
assert.strictEqual(cached.world.subjects.ids.length, n)
|
||||
assert.ok(duration < 100, `cache read should be under 100ms, took ${duration.toFixed(2)}ms`)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Geometry snapshot caching for Imhotep.
|
||||
*
|
||||
* Serializes and deserializes GeometryWorld instances so extracted
|
||||
* browser geometry can be cached to disk and reused across assertion
|
||||
* batches without re-extraction.
|
||||
*/
|
||||
|
||||
import type { GeometryWorld, WorldEnvironment, WorldSource } from './world.js'
|
||||
import type { ImhotepId } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SerializedTypedArray {
|
||||
type: 'Uint32Array' | 'Uint16Array' | 'Uint8Array' | 'Int32Array' | 'Int16Array' | 'Float64Array' | 'Float32Array'
|
||||
data: number[]
|
||||
}
|
||||
|
||||
interface SerializedTable {
|
||||
[column: string]: SerializedTypedArray | string[] | number[]
|
||||
}
|
||||
|
||||
interface SerializedWorld {
|
||||
version: number
|
||||
sceneId: ImhotepId
|
||||
snapshotId: ImhotepId
|
||||
env: WorldEnvironment
|
||||
source: WorldSource
|
||||
strings: { values: string[] }
|
||||
subjects: SerializedTable
|
||||
dom: SerializedTable
|
||||
frames: SerializedTable
|
||||
matrices: SerializedTable
|
||||
rects: SerializedTable
|
||||
boxes: SerializedTable
|
||||
visualBoxes: SerializedTable
|
||||
fragments: SerializedTable
|
||||
transforms: SerializedTable
|
||||
styles: SerializedTable
|
||||
text: SerializedTable
|
||||
topology: SerializedTable
|
||||
scroll: SerializedTable
|
||||
clipping: SerializedTable
|
||||
paint: SerializedTable
|
||||
visibility: SerializedTable
|
||||
provenance: SerializedTable
|
||||
confidence: SerializedTable
|
||||
}
|
||||
|
||||
function serializeTypedArray(arr: TypedArray): SerializedTypedArray {
|
||||
const ctor = arr.constructor.name as SerializedTypedArray['type']
|
||||
return { type: ctor, data: Array.from(arr) }
|
||||
}
|
||||
|
||||
function deserializeTypedArray(serialized: SerializedTypedArray): TypedArray {
|
||||
const ctor = globalThis[serialized.type] as new (data: number[]) => TypedArray
|
||||
return new ctor(serialized.data)
|
||||
}
|
||||
|
||||
type TypedArray =
|
||||
| Uint32Array
|
||||
| Uint16Array
|
||||
| Uint8Array
|
||||
| Int32Array
|
||||
| Int16Array
|
||||
| Float64Array
|
||||
| Float32Array
|
||||
|
||||
function isTypedArray(value: unknown): value is TypedArray {
|
||||
return (
|
||||
value instanceof Uint32Array ||
|
||||
value instanceof Uint16Array ||
|
||||
value instanceof Uint8Array ||
|
||||
value instanceof Int32Array ||
|
||||
value instanceof Int16Array ||
|
||||
value instanceof Float64Array ||
|
||||
value instanceof Float32Array
|
||||
)
|
||||
}
|
||||
|
||||
function serializeTable(table: Record<string, unknown>): SerializedTable {
|
||||
const result: SerializedTable = {}
|
||||
if (!table) return result
|
||||
for (const [key, value] of Object.entries(table)) {
|
||||
if (isTypedArray(value)) {
|
||||
result[key] = serializeTypedArray(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value as number[] | string[]
|
||||
} else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
||||
result[key] = [value] as number[] | string[]
|
||||
} else {
|
||||
result[key] = value as SerializedTable[string]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function deserializeTable(serialized: SerializedTable): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(serialized)) {
|
||||
if (value && typeof value === 'object' && 'type' in value && 'data' in value) {
|
||||
result[key] = deserializeTypedArray(value as SerializedTypedArray)
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a GeometryWorld to a JSON string.
|
||||
*/
|
||||
export function serializeGeometryWorld(world: GeometryWorld): string {
|
||||
const serialized: SerializedWorld = {
|
||||
version: 1,
|
||||
sceneId: world.sceneId,
|
||||
snapshotId: world.snapshotId,
|
||||
env: world.env,
|
||||
source: world.source,
|
||||
strings: world.strings,
|
||||
subjects: serializeTable(world.subjects as unknown as Record<string, unknown>),
|
||||
dom: serializeTable(world.dom as unknown as Record<string, unknown>),
|
||||
frames: serializeTable(world.frames as unknown as Record<string, unknown>),
|
||||
matrices: serializeTable(world.matrices as unknown as Record<string, unknown>),
|
||||
rects: serializeTable(world.rects as unknown as Record<string, unknown>),
|
||||
boxes: serializeTable(world.boxes as unknown as Record<string, unknown>),
|
||||
visualBoxes: serializeTable(world.visualBoxes as unknown as Record<string, unknown>),
|
||||
fragments: serializeTable(world.fragments as unknown as Record<string, unknown>),
|
||||
transforms: serializeTable(world.transforms as unknown as Record<string, unknown>),
|
||||
styles: serializeTable(world.styles as unknown as Record<string, unknown>),
|
||||
text: serializeTable(world.text as unknown as Record<string, unknown>),
|
||||
topology: serializeTable(world.topology as unknown as Record<string, unknown>),
|
||||
scroll: serializeTable(world.scroll as unknown as Record<string, unknown>),
|
||||
clipping: serializeTable(world.clipping as unknown as Record<string, unknown>),
|
||||
paint: serializeTable(world.paint as unknown as Record<string, unknown>),
|
||||
visibility: serializeTable(world.visibility as unknown as Record<string, unknown>),
|
||||
provenance: serializeTable(world.provenance as unknown as Record<string, unknown>),
|
||||
confidence: serializeTable(world.confidence as unknown as Record<string, unknown>),
|
||||
}
|
||||
return JSON.stringify(serialized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a JSON string back into a GeometryWorld.
|
||||
*/
|
||||
export function deserializeGeometryWorld(json: string): GeometryWorld {
|
||||
const serialized = JSON.parse(json) as SerializedWorld
|
||||
|
||||
if (serialized.version !== 1) {
|
||||
throw new Error(`Unsupported geometry cache version: ${serialized.version}`)
|
||||
}
|
||||
|
||||
return {
|
||||
sceneId: serialized.sceneId,
|
||||
snapshotId: serialized.snapshotId,
|
||||
env: serialized.env,
|
||||
source: serialized.source,
|
||||
strings: serialized.strings,
|
||||
subjects: deserializeTable(serialized.subjects) as unknown as GeometryWorld['subjects'],
|
||||
dom: deserializeTable(serialized.dom) as unknown as GeometryWorld['dom'],
|
||||
frames: deserializeTable(serialized.frames) as unknown as GeometryWorld['frames'],
|
||||
matrices: deserializeTable(serialized.matrices) as unknown as GeometryWorld['matrices'],
|
||||
rects: deserializeTable(serialized.rects) as unknown as GeometryWorld['rects'],
|
||||
boxes: deserializeTable(serialized.boxes) as unknown as GeometryWorld['boxes'],
|
||||
visualBoxes: deserializeTable(serialized.visualBoxes) as unknown as GeometryWorld['visualBoxes'],
|
||||
fragments: deserializeTable(serialized.fragments) as unknown as GeometryWorld['fragments'],
|
||||
transforms: deserializeTable(serialized.transforms) as unknown as GeometryWorld['transforms'],
|
||||
styles: deserializeTable(serialized.styles) as unknown as GeometryWorld['styles'],
|
||||
text: deserializeTable(serialized.text) as unknown as GeometryWorld['text'],
|
||||
topology: deserializeTable(serialized.topology) as unknown as GeometryWorld['topology'],
|
||||
scroll: deserializeTable(serialized.scroll) as unknown as GeometryWorld['scroll'],
|
||||
clipping: deserializeTable(serialized.clipping) as unknown as GeometryWorld['clipping'],
|
||||
paint: deserializeTable(serialized.paint) as unknown as GeometryWorld['paint'],
|
||||
visibility: deserializeTable(serialized.visibility) as unknown as GeometryWorld['visibility'],
|
||||
provenance: deserializeTable(serialized.provenance) as unknown as GeometryWorld['provenance'],
|
||||
confidence: deserializeTable(serialized.confidence) as unknown as GeometryWorld['confidence'],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache key computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function djb2Hash(str: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xffffffff
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a stable cache key for a geometry extraction.
|
||||
*
|
||||
* The key incorporates:
|
||||
* - page URL
|
||||
* - sorted selectors (so order doesn't matter)
|
||||
* - environment (viewport dimensions, color scheme, etc.)
|
||||
*/
|
||||
export function computeGeometryCacheKey(
|
||||
pageUrl: string,
|
||||
selectors: string[],
|
||||
env: Partial<WorldEnvironment>,
|
||||
): string {
|
||||
const selectorsHash = djb2Hash([...selectors].sort().join('\x00'))
|
||||
const envHash = djb2Hash(
|
||||
JSON.stringify({
|
||||
viewportWidth: env.viewportWidth ?? 0,
|
||||
viewportHeight: env.viewportHeight ?? 0,
|
||||
deviceScaleFactor: env.deviceScaleFactor ?? 1,
|
||||
colorScheme: env.colorScheme ?? 'light',
|
||||
pointer: env.pointer ?? 'fine',
|
||||
hover: env.hover ?? false,
|
||||
reducedMotion: env.reducedMotion ?? 'no-preference',
|
||||
locale: env.locale ?? 'en',
|
||||
writingMode: env.writingMode ?? 'horizontal-tb',
|
||||
}),
|
||||
)
|
||||
const urlHash = djb2Hash(pageUrl)
|
||||
return `${urlHash}-${selectorsHash}-${envHash}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-based cache storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const DEFAULT_CACHE_DIR = join(process.cwd(), '.imhotep-cache')
|
||||
const DEFAULT_MAX_CACHE_ENTRIES = 100
|
||||
|
||||
function cacheFilePath(cacheDir: string, cacheKey: string): string {
|
||||
return join(cacheDir, `${cacheKey}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest cache entries when exceeding maxEntries.
|
||||
*/
|
||||
function evictOldestEntries(cacheDir: string, maxEntries: number): void {
|
||||
if (!existsSync(cacheDir)) return
|
||||
const files = readdirSync(cacheDir)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.map((f) => {
|
||||
const fullPath = join(cacheDir, f)
|
||||
try {
|
||||
return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((f): f is NonNullable<typeof f> => f !== null)
|
||||
.sort((a, b) => a.mtime - b.mtime)
|
||||
|
||||
if (files.length > maxEntries) {
|
||||
const toRemove = files.length - maxEntries
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
try {
|
||||
rmSync(files[i].path)
|
||||
} catch {
|
||||
// ignore deletion errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached GeometryWorld from disk (async, non-blocking).
|
||||
*/
|
||||
export async function readCachedWorld(cacheDir: string, cacheKey: string): Promise<GeometryWorld | null> {
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
try {
|
||||
const json = await readFile(path, 'utf-8')
|
||||
return deserializeGeometryWorld(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a GeometryWorld to the cache on disk (async, non-blocking).
|
||||
*/
|
||||
export async function writeCachedWorld(cacheDir: string, cacheKey: string, world: GeometryWorld): Promise<void> {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
evictOldestEntries(cacheDir, DEFAULT_MAX_CACHE_ENTRIES)
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
await writeFile(path, serializeGeometryWorld(world))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached geometry snapshots.
|
||||
*/
|
||||
export function clearGeometryCache(cacheDir: string = DEFAULT_CACHE_DIR): void {
|
||||
if (!existsSync(cacheDir)) {
|
||||
return
|
||||
}
|
||||
for (const file of readdirSync(cacheDir)) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
rmSync(join(cacheDir, file))
|
||||
} catch {
|
||||
// ignore deletion errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full extraction result caching (world + selector mapping + errors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CachedExtractionResult {
|
||||
version: number
|
||||
world: string
|
||||
selectorToIds: [string, number[]][]
|
||||
errors: Array<{
|
||||
code: string
|
||||
severity: string
|
||||
category: string
|
||||
message: string
|
||||
source: string
|
||||
related: string[]
|
||||
fixHints: string[]
|
||||
metrics: Record<string, unknown>
|
||||
sourceRef: Record<string, unknown>
|
||||
clauseLabel?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a full extraction result (world + selector mappings + errors).
|
||||
*/
|
||||
export function serializeExtractionResult(
|
||||
world: GeometryWorld,
|
||||
selectorToIds: Map<string, number[]>,
|
||||
errors: Array<{
|
||||
code: string
|
||||
severity: string
|
||||
category: string
|
||||
message: string
|
||||
source: string
|
||||
related: string[]
|
||||
fixHints: string[]
|
||||
metrics: Record<string, unknown>
|
||||
sourceRef: Record<string, unknown>
|
||||
clauseLabel?: string
|
||||
}>,
|
||||
): string {
|
||||
const cached: CachedExtractionResult = {
|
||||
version: 1,
|
||||
world: serializeGeometryWorld(world),
|
||||
selectorToIds: Array.from(selectorToIds.entries()),
|
||||
errors,
|
||||
}
|
||||
return JSON.stringify(cached)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a full extraction result from JSON.
|
||||
*/
|
||||
export function deserializeExtractionResult(json: string): {
|
||||
world: GeometryWorld
|
||||
selectorToIds: Map<string, number[]>
|
||||
errors: CachedExtractionResult['errors']
|
||||
} {
|
||||
const cached = JSON.parse(json) as CachedExtractionResult
|
||||
if (cached.version !== 1) {
|
||||
throw new Error(`Unsupported extraction cache version: ${cached.version}`)
|
||||
}
|
||||
return {
|
||||
world: deserializeGeometryWorld(cached.world),
|
||||
selectorToIds: new Map(cached.selectorToIds),
|
||||
errors: cached.errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached extraction result from disk (async, non-blocking).
|
||||
*/
|
||||
export async function readCachedExtractionResult(
|
||||
cacheDir: string,
|
||||
cacheKey: string,
|
||||
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: CachedExtractionResult['errors'] } | null> {
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
try {
|
||||
const json = await readFile(path, 'utf-8')
|
||||
return deserializeExtractionResult(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an extraction result to the cache on disk (async, non-blocking).
|
||||
*/
|
||||
export async function writeCachedExtractionResult(
|
||||
cacheDir: string,
|
||||
cacheKey: string,
|
||||
world: GeometryWorld,
|
||||
selectorToIds: Map<string, number[]>,
|
||||
errors: CachedExtractionResult['errors'],
|
||||
): Promise<void> {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
evictOldestEntries(cacheDir, DEFAULT_MAX_CACHE_ENTRIES)
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
await writeFile(path, serializeExtractionResult(world, selectorToIds, errors))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default cache directory path.
|
||||
*/
|
||||
export function getDefaultCacheDir(): string {
|
||||
return DEFAULT_CACHE_DIR
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Imhotep Core — foundational contracts and type system.
|
||||
*
|
||||
* Exported modules:
|
||||
* types — Core type definitions (positions, IDs, results, proofs)
|
||||
* ast — Unist-style AST node definitions
|
||||
* ir — Semantic IR and Execution IR definitions
|
||||
* world — Geometry world schema types
|
||||
* contracts — Extractor, solver, compiler, reporter interfaces
|
||||
* diagnostics — Diagnostic categories, error codes, traces, shrinking
|
||||
*/
|
||||
|
||||
export * from './types.js'
|
||||
export * from './ast.js'
|
||||
export * from './ir.js'
|
||||
export { FragmentKind, createEmptyWorld } from './world.js'
|
||||
export type {
|
||||
GeometryWorld,
|
||||
WorldEnvironment,
|
||||
WorldSource,
|
||||
StringTable,
|
||||
SubjectsTable,
|
||||
DomTable,
|
||||
FramesTable,
|
||||
MatricesTable,
|
||||
RectsTable,
|
||||
BoxesTable,
|
||||
FragmentsTable,
|
||||
TransformsTable,
|
||||
StylesTable,
|
||||
TextTable,
|
||||
TopologyTable,
|
||||
ScrollTable,
|
||||
ClippingTable,
|
||||
PaintTable,
|
||||
VisibilityTable,
|
||||
ProvenanceTable,
|
||||
ConfidenceTable,
|
||||
FactRequestPlan,
|
||||
} from './world.js'
|
||||
export * from './contracts.js'
|
||||
export * from './diagnostics.js'
|
||||
export * from './pipeline.js'
|
||||
export * from './logic-ast.js'
|
||||
export * from './logic-ir.js'
|
||||
export * from './domains.js'
|
||||
export * from './scene-target.js'
|
||||
export * from './property-contracts.js'
|
||||
export * from './property-results.js'
|
||||
export * from './canonical.js'
|
||||
export * from './context.js'
|
||||
export {
|
||||
serializeGeometryWorld,
|
||||
deserializeGeometryWorld,
|
||||
computeGeometryCacheKey,
|
||||
readCachedWorld,
|
||||
writeCachedWorld,
|
||||
clearGeometryCache,
|
||||
getDefaultCacheDir,
|
||||
readCachedExtractionResult,
|
||||
writeCachedExtractionResult,
|
||||
} from './geometry-cache.js'
|
||||
|
||||
// Public API type aliases for high-level evaluation results
|
||||
import type { ClauseResult, ImhotepId } from './types.js'
|
||||
import type { Diagnostic } from './diagnostics.js'
|
||||
|
||||
export interface NormalizedContract {
|
||||
clauseId: ImhotepId
|
||||
clauseLabel: string
|
||||
relation: string
|
||||
subject: string
|
||||
reference: string
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ImhotepResult = {
|
||||
schemaVersion: string
|
||||
passed: boolean
|
||||
summary: string
|
||||
clauseResults: ClauseResult[]
|
||||
diagnostics: ImhotepDiagnostic[]
|
||||
normalizedContracts?: NormalizedContract[]
|
||||
}
|
||||
|
||||
export type ImhotepDiagnostic = Diagnostic
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* DSL -> Geometry integration tests.
|
||||
*
|
||||
* Verifies that fluent assertions compile to IR that geometry worlds can satisfy,
|
||||
* that frame references resolve correctly, and that tolerance values propagate.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { expect, frame, compile as dslCompile } from 'imhotep-dsl'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
import { createEmptySemanticIr } from './ir.js'
|
||||
import type { ExecutionIr } from './ir.js'
|
||||
import type { ExecutionContext } from 'imhotep-extractor'
|
||||
import { ExtractionPlanner, FactRequirementRegistry, FACT_FAMILY } from 'imhotep-extractor'
|
||||
import {
|
||||
buildMockGeometryWorld,
|
||||
mapSemanticIrToClauses,
|
||||
} from './integration-mocks.js'
|
||||
|
||||
describe('DSL -> Geometry', () => {
|
||||
before(() => {
|
||||
registerDefaultClauses()
|
||||
})
|
||||
|
||||
it('fluent assertions compile to IR that geometry world can satisfy', () => {
|
||||
// Arrange: build DSL assertion via fluent API
|
||||
const fluent = expect('.tooltip').to.be.above('.trigger')
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile to semantic + execution IR with deterministic ids
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Assert compilation succeeded
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
assert.strictEqual(result.executionIr.clauseCount, 1)
|
||||
|
||||
// Arrange: build geometry world with matching subjects in compiler order
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.trigger',
|
||||
box: { top: 20, bottom: 30, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Map semantic IR subject ids to world subject ids (compiler order)
|
||||
const subjectIdMap = new Map<string, number>()
|
||||
let worldIndex = 0
|
||||
for (const [id] of result.semanticIr.subjects) {
|
||||
subjectIdMap.set(id, worldIndex++)
|
||||
}
|
||||
|
||||
// Act: build clause descriptors and evaluate
|
||||
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
|
||||
const evalResult = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(evalResult.clauseResults.length, 1)
|
||||
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(evalResult.clauseResults[0].truth, 'determinate')
|
||||
})
|
||||
|
||||
it('frame references in DSL resolve correctly in geometry world', () => {
|
||||
// Arrange: build DSL assertion with nearestPositionedAncestor frame
|
||||
const fluent = expect('.tooltip')
|
||||
.in(frame.nearestPositionedAncestor('.trigger'))
|
||||
.to.be.above('.trigger')
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Assert: semantic IR has a non-default frame
|
||||
const nonDefaultFrames: Array<[string, { kind: string }]> = []
|
||||
for (const [, frame] of result.semanticIr.frames.entries()) {
|
||||
if (frame.kind !== 'viewport') {
|
||||
nonDefaultFrames.push(['', frame])
|
||||
}
|
||||
}
|
||||
assert.strictEqual(nonDefaultFrames.length, 1)
|
||||
assert.strictEqual(nonDefaultFrames[0][1].kind, 'nearestPositionedAncestor')
|
||||
|
||||
// Arrange: build world with frame topology
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.trigger',
|
||||
box: { top: 20, bottom: 30, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
nearestPositionedAncestorOf: [1],
|
||||
},
|
||||
})
|
||||
|
||||
// Map subjects
|
||||
const subjectIdMap = new Map<string, number>()
|
||||
let worldIndex = 0
|
||||
for (const [id] of result.semanticIr.subjects) {
|
||||
subjectIdMap.set(id, worldIndex++)
|
||||
}
|
||||
|
||||
// Act
|
||||
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
|
||||
const evalResult = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(evalResult.clauseResults.length, 1)
|
||||
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(evalResult.clauseResults[0].truth, 'determinate')
|
||||
})
|
||||
|
||||
it('tolerance values propagate to solver correctly', () => {
|
||||
// Arrange: build DSL assertion with tolerance
|
||||
const fluent = expect('.tooltip').to.be.inside('.container', {
|
||||
tolerance: 5,
|
||||
})
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Assert: compiler produced a tolerance entry
|
||||
const nonDefaultTolerances: Array<[string, { value: number; unit: string }]> = []
|
||||
for (const [, tol] of result.semanticIr.tolerances.entries()) {
|
||||
if (!(tol.value === 0 && tol.unit === 'px')) {
|
||||
nonDefaultTolerances.push(['', tol])
|
||||
}
|
||||
}
|
||||
assert.strictEqual(nonDefaultTolerances.length, 1)
|
||||
assert.strictEqual(nonDefaultTolerances[0][1].value, 5)
|
||||
assert.strictEqual(nonDefaultTolerances[0][1].unit, 'px')
|
||||
|
||||
// Arrange: build world where tooltip is almost inside container
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 2, bottom: 8, left: 2, right: 48 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.container',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Map subjects
|
||||
const subjectIdMap = new Map<string, number>()
|
||||
let worldIndex = 0
|
||||
for (const [id] of result.semanticIr.subjects) {
|
||||
subjectIdMap.set(id, worldIndex++)
|
||||
}
|
||||
|
||||
// Act
|
||||
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
|
||||
const evalResult = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(evalResult.clauseResults.length, 1)
|
||||
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
|
||||
assert.ok(evalResult.clauseResults[0].metrics)
|
||||
})
|
||||
|
||||
it('execution IR produces extractable fact requirements', () => {
|
||||
// Arrange: build DSL assertion
|
||||
const fluent = expect('.a').to.be.leftOf('.b')
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Arrange: build execution context for planner
|
||||
const semanticIr = result.semanticIr
|
||||
const executionIr = result.executionIr
|
||||
|
||||
const subjectIds: string[] = []
|
||||
for (const [id] of semanticIr.subjects) {
|
||||
subjectIds.push(id)
|
||||
}
|
||||
const frameIds: string[] = []
|
||||
for (const [id] of semanticIr.frames) {
|
||||
frameIds.push(id)
|
||||
}
|
||||
const stateIds: string[] = []
|
||||
for (const [id] of semanticIr.states) {
|
||||
stateIds.push(id)
|
||||
}
|
||||
const timelineIds: string[] = []
|
||||
for (const [id] of semanticIr.timelines) {
|
||||
timelineIds.push(id)
|
||||
}
|
||||
const envGuardIds: string[] = []
|
||||
for (const [id] of semanticIr.envGuards) {
|
||||
envGuardIds.push(id)
|
||||
}
|
||||
const toleranceIds: string[] = []
|
||||
for (const [id] of semanticIr.tolerances) {
|
||||
toleranceIds.push(id)
|
||||
}
|
||||
|
||||
const context: ExecutionContext = {
|
||||
executionIr,
|
||||
semanticIr,
|
||||
subjectIds,
|
||||
frameIds,
|
||||
stateIds,
|
||||
timelineIds,
|
||||
envGuardIds,
|
||||
toleranceIds,
|
||||
}
|
||||
|
||||
// Arrange: register fact requirements for leftOf
|
||||
const registry = new FactRequirementRegistry()
|
||||
registry.register(1, [{ family: FACT_FAMILY.GEOMETRY }])
|
||||
|
||||
// Act: create extraction plan
|
||||
const planner = new ExtractionPlanner({ factRegistry: registry })
|
||||
const plan = planner.createPlan(context)
|
||||
|
||||
// Assert: plan requests geometry facts
|
||||
assert.strictEqual(plan.requests.length >= 1, true)
|
||||
assert.strictEqual(plan.requests[0].requiredFacts.geometry, true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Extractor -> Solver integration tests.
|
||||
*
|
||||
* Verifies that raw extraction results normalize into worlds solvers can
|
||||
* evaluate, that topology facts enable topology assertions, and that missing
|
||||
* facts produce indeterminate results.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
ExtractionPlanner,
|
||||
FactRequirementRegistry,
|
||||
FACT_FAMILY,
|
||||
} from 'imhotep-extractor'
|
||||
import type { ExecutionContext } from 'imhotep-extractor'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
import { createEmptySemanticIr } from './ir.js'
|
||||
import type { ExecutionIr } from './ir.js'
|
||||
import {
|
||||
buildMockGeometryWorld,
|
||||
buildClauseDescriptor,
|
||||
} from './integration-mocks.js'
|
||||
|
||||
describe('Extractor -> Solver', () => {
|
||||
before(() => {
|
||||
registerDefaultClauses()
|
||||
})
|
||||
|
||||
it('raw extraction results normalize into worlds solvers can evaluate', () => {
|
||||
// Arrange: create semantic IR with two subjects and an above relation
|
||||
const semanticIr = createEmptySemanticIr()
|
||||
const subjectId = 'subject_1'
|
||||
const refId = 'subject_2'
|
||||
semanticIr.subjects.set(subjectId, {
|
||||
id: subjectId,
|
||||
selector: '.a',
|
||||
kind: 'element',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
semanticIr.subjects.set(refId, {
|
||||
id: refId,
|
||||
selector: '.b',
|
||||
kind: 'element',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
const clauseId = 'clause_1'
|
||||
semanticIr.clauses.set(clauseId, {
|
||||
id: clauseId,
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
subjectRef: subjectId,
|
||||
referenceRef: refId,
|
||||
relation: 'above',
|
||||
frameRef: 'frame_default',
|
||||
stateRef: 'state_default',
|
||||
timelineRef: 'timeline_default',
|
||||
envGuardRef: 'guard_default',
|
||||
toleranceRef: 'tolerance_default',
|
||||
bounds: {},
|
||||
})
|
||||
|
||||
const executionIr: ExecutionIr = {
|
||||
clauseCount: 1,
|
||||
clauseType: new Uint16Array([3]),
|
||||
clauseSubject: new Uint32Array([0]),
|
||||
clauseReference: new Uint32Array([1]),
|
||||
clauseFrame: new Uint32Array([0]),
|
||||
clauseState: new Uint32Array([0]),
|
||||
clauseTimeline: new Uint32Array([0]),
|
||||
clauseTolerance: new Uint32Array([0]),
|
||||
clauseEnvGuard: new Uint32Array([0]),
|
||||
clauseArg0: new Float64Array([Number.NEGATIVE_INFINITY]),
|
||||
clauseArg1: new Float64Array([Number.POSITIVE_INFINITY]),
|
||||
clauseFlags: new Uint32Array([0]),
|
||||
clauseOrigin: new Uint32Array([0]),
|
||||
}
|
||||
|
||||
const context: ExecutionContext = {
|
||||
executionIr,
|
||||
semanticIr,
|
||||
subjectIds: [subjectId, refId],
|
||||
frameIds: ['frame_default'],
|
||||
stateIds: ['state_default'],
|
||||
timelineIds: ['timeline_default'],
|
||||
envGuardIds: ['guard_default'],
|
||||
toleranceIds: ['tolerance_default'],
|
||||
}
|
||||
|
||||
// Arrange: create fact registry and planner
|
||||
const registry = new FactRequirementRegistry()
|
||||
registry.register(3, [{ family: FACT_FAMILY.GEOMETRY }])
|
||||
|
||||
const planner = new ExtractionPlanner({ factRegistry: registry })
|
||||
|
||||
// Act: create extraction plan
|
||||
const plan = planner.createPlan(context)
|
||||
|
||||
// Assert: plan has one request requiring geometry
|
||||
assert.strictEqual(plan.requests.length, 1)
|
||||
assert.strictEqual(plan.requests[0].requiredFacts.geometry, true)
|
||||
|
||||
// Act: build mock extracted world and evaluate
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.a',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.b',
|
||||
box: { top: 20, bottom: 30, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'relation.above',
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
})
|
||||
|
||||
it('topology facts from extractor enable topology assertions', () => {
|
||||
// Arrange: build world with topology data
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.child',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 10 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.scroll',
|
||||
box: { top: 0, bottom: 100, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
scrollContainerOf: [1],
|
||||
},
|
||||
})
|
||||
|
||||
// Act: evaluate topology clause
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'topology.attachedToScrollContainer',
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(result.clauseResults[0].truth, 'determinate')
|
||||
assert.strictEqual(
|
||||
result.clauseResults[0].metrics?.attached,
|
||||
1,
|
||||
)
|
||||
})
|
||||
|
||||
it('stacking context topology facts enable stacking assertions', () => {
|
||||
// Arrange: build world with shared stacking context
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.layer-a',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 10 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.layer-b',
|
||||
box: { top: 5, bottom: 15, left: 5, right: 15 },
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
stackingContextOf: [42, 42],
|
||||
},
|
||||
})
|
||||
|
||||
// Act: evaluate stacking context clause
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'topology.inStackingContext',
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
|
||||
// Assert: same stacking context id means pass
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(result.clauseResults[0].truth, 'determinate')
|
||||
})
|
||||
|
||||
it('missing facts produce indeterminate results', () => {
|
||||
// Arrange: build world with only one subject
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.a',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 10 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: evaluate clause referencing non-existent subject
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'relation.above',
|
||||
subjectRef: 0,
|
||||
referenceRef: 99,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
|
||||
// Assert: indeterminate because reference box is missing
|
||||
assert.strictEqual(result.clauseResults[0].status, 'error')
|
||||
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
||||
})
|
||||
|
||||
it('unsupported clause types emit extraction diagnostics', () => {
|
||||
// Arrange: create execution IR with unregistered clause type
|
||||
const semanticIr = createEmptySemanticIr()
|
||||
const executionIr: ExecutionIr = {
|
||||
clauseCount: 1,
|
||||
clauseType: new Uint16Array([999]),
|
||||
clauseSubject: new Uint32Array([0]),
|
||||
clauseReference: new Uint32Array([0]),
|
||||
clauseFrame: new Uint32Array([0]),
|
||||
clauseState: new Uint32Array([0]),
|
||||
clauseTimeline: new Uint32Array([0]),
|
||||
clauseTolerance: new Uint32Array([0]),
|
||||
clauseEnvGuard: new Uint32Array([0]),
|
||||
clauseArg0: new Float64Array([0]),
|
||||
clauseArg1: new Float64Array([0]),
|
||||
clauseFlags: new Uint32Array([0]),
|
||||
clauseOrigin: new Uint32Array([0]),
|
||||
}
|
||||
|
||||
const context: ExecutionContext = {
|
||||
executionIr,
|
||||
semanticIr,
|
||||
subjectIds: [],
|
||||
frameIds: [],
|
||||
stateIds: [],
|
||||
timelineIds: [],
|
||||
envGuardIds: [],
|
||||
toleranceIds: [],
|
||||
}
|
||||
|
||||
const registry = new FactRequirementRegistry()
|
||||
const planner = new ExtractionPlanner({ factRegistry: registry })
|
||||
|
||||
// Act: create plan
|
||||
const plan = planner.createPlan(context)
|
||||
|
||||
// Assert: diagnostic emitted for unsupported clause
|
||||
assert.ok(
|
||||
plan.diagnostics.some(
|
||||
(d) => d.code === 'IMH_EXTRACTOR_UNSUPPORTED_CLAUSE_TYPE',
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Mock helpers for cross-package integration tests.
|
||||
*
|
||||
* Provides dependency-injected builders for geometry worlds,
|
||||
* extraction results, solver results, and complete mock scenes.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GeometryWorld,
|
||||
ClauseDescriptor,
|
||||
ClauseResult,
|
||||
} from 'imhotep-solver'
|
||||
import type { EvaluationOutput } from 'imhotep-solver'
|
||||
import type { GeometrySnapshot, SnapshotMetadata } from 'imhotep-state'
|
||||
import type { Diagnostic as CoreDiagnostic } from './diagnostics.js'
|
||||
import type { ImhotepId } from './types.js'
|
||||
import type { SemanticIr } from './ir.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry World Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MockSubject {
|
||||
id: number
|
||||
selector: string
|
||||
box: {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MockFrame {
|
||||
id: number
|
||||
kind: string
|
||||
subjectId: number
|
||||
}
|
||||
|
||||
export interface MockWorldOptions {
|
||||
sceneId?: string
|
||||
snapshotId?: string
|
||||
subjects: MockSubject[]
|
||||
frames?: MockFrame[]
|
||||
topology?: {
|
||||
nearestPositionedAncestorOf?: number[]
|
||||
scrollContainerOf?: number[]
|
||||
stackingContextOf?: number[]
|
||||
containingBlockOf?: number[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a solver-compatible geometry world from a declarative description.
|
||||
*/
|
||||
export function buildMockGeometryWorld(opts: MockWorldOptions): GeometryWorld {
|
||||
const subjectCount = opts.subjects.length
|
||||
|
||||
const world: GeometryWorld = {
|
||||
sceneId: opts.sceneId ?? 'scene_test',
|
||||
snapshotId: opts.snapshotId ?? 'snap_test',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: opts.subjects.map((s) => s.id),
|
||||
domNodeId: new Array(subjectCount).fill(0),
|
||||
subjectKind: new Array(subjectCount).fill(0),
|
||||
primaryBoxId: opts.subjects.map((_, i) => i),
|
||||
firstFragmentId: new Array(subjectCount).fill(0),
|
||||
fragmentCount: new Array(subjectCount).fill(0),
|
||||
},
|
||||
dom: {
|
||||
nodeId: opts.subjects.map((s) => s.id),
|
||||
parentNodeId: new Array(subjectCount).fill(0),
|
||||
childCount: new Array(subjectCount).fill(0),
|
||||
tagNameStringId: new Array(subjectCount).fill(0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: opts.subjects.map((_, i) => i),
|
||||
subjectId: opts.subjects.map((s) => s.id),
|
||||
frameId: new Array(subjectCount).fill(0),
|
||||
borderLeft: opts.subjects.map((s) => s.box.left),
|
||||
borderTop: opts.subjects.map((s) => s.box.top),
|
||||
borderRight: opts.subjects.map((s) => s.box.right),
|
||||
borderBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
paddingLeft: opts.subjects.map((s) => s.box.left),
|
||||
paddingTop: opts.subjects.map((s) => s.box.top),
|
||||
paddingRight: opts.subjects.map((s) => s.box.right),
|
||||
paddingBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
contentLeft: opts.subjects.map((s) => s.box.left),
|
||||
contentTop: opts.subjects.map((s) => s.box.top),
|
||||
contentRight: opts.subjects.map((s) => s.box.right),
|
||||
contentBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
},
|
||||
rects: {
|
||||
rectId: opts.subjects.map((_, i) => i),
|
||||
left: opts.subjects.map((s) => s.box.left),
|
||||
top: opts.subjects.map((s) => s.box.top),
|
||||
right: opts.subjects.map((s) => s.box.right),
|
||||
bottom: opts.subjects.map((s) => s.box.bottom),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: opts.topology?.containingBlockOf ?? [],
|
||||
nearestPositionedAncestorOf:
|
||||
opts.topology?.nearestPositionedAncestorOf ?? [],
|
||||
scrollContainerOf: opts.topology?.scrollContainerOf ?? [],
|
||||
stackingContextOf: opts.topology?.stackingContextOf ?? [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: {
|
||||
containerId: [],
|
||||
scrollLeft: [],
|
||||
scrollTop: [],
|
||||
scrollWidth: [],
|
||||
scrollHeight: [],
|
||||
clientWidth: [],
|
||||
clientHeight: [],
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: opts.subjects.map((_, i) => i),
|
||||
subjectId: opts.subjects.map((s) => s.id),
|
||||
frameId: new Array(subjectCount).fill(0),
|
||||
borderLeft: opts.subjects.map((s) => s.box.left),
|
||||
borderTop: opts.subjects.map((s) => s.box.top),
|
||||
borderRight: opts.subjects.map((s) => s.box.right),
|
||||
borderBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
paddingLeft: opts.subjects.map((s) => s.box.left),
|
||||
paddingTop: opts.subjects.map((s) => s.box.top),
|
||||
paddingRight: opts.subjects.map((s) => s.box.right),
|
||||
paddingBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
contentLeft: opts.subjects.map((s) => s.box.left),
|
||||
contentTop: opts.subjects.map((s) => s.box.top),
|
||||
contentRight: opts.subjects.map((s) => s.box.right),
|
||||
contentBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
matrices: {
|
||||
values: [],
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: [],
|
||||
subjectId: [],
|
||||
clipKind: [],
|
||||
clipLeft: [],
|
||||
clipTop: [],
|
||||
clipRight: [],
|
||||
clipBottom: [],
|
||||
parentClipNodeId: [],
|
||||
},
|
||||
visibility: {
|
||||
subjectId: [],
|
||||
isRendered: [],
|
||||
isVisible: [],
|
||||
visibleArea: [],
|
||||
clippedArea: [],
|
||||
},
|
||||
}
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clause Descriptor Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildClauseDescriptor(opts: {
|
||||
clauseId: string
|
||||
clauseKind: string
|
||||
subjectRef: number
|
||||
referenceRef?: number
|
||||
frameRef?: number
|
||||
bounds?: { minGap?: number; maxGap?: number; tolerance?: number; min?: number; max?: number }
|
||||
options?: Record<string, unknown>
|
||||
}): ClauseDescriptor {
|
||||
return {
|
||||
clauseId: opts.clauseId,
|
||||
clauseKind: opts.clauseKind,
|
||||
version: 1,
|
||||
subjectRef: opts.subjectRef,
|
||||
referenceRef: opts.referenceRef,
|
||||
frameRef: opts.frameRef,
|
||||
bounds: opts.bounds ?? {},
|
||||
options: opts.options ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solver Result Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMockSolverResult(opts: {
|
||||
clauseResults?: ClauseResult[]
|
||||
diagnostics?: Array<{
|
||||
code: string
|
||||
severity: 'error' | 'warning' | 'info'
|
||||
category: string
|
||||
message: string
|
||||
clauseId?: string
|
||||
}>
|
||||
}): EvaluationOutput {
|
||||
return {
|
||||
clauseResults: opts.clauseResults ?? [],
|
||||
groupResults: [],
|
||||
proofs: [],
|
||||
diagnostics: opts.diagnostics ?? [],
|
||||
trace: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction Result Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMockExtractionResult(opts: {
|
||||
requestId?: string
|
||||
worlds: GeometryWorld[]
|
||||
diagnostics?: CoreDiagnostic[]
|
||||
}): {
|
||||
requestId: string
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
worlds: GeometryWorld[]
|
||||
diagnostics: CoreDiagnostic[]
|
||||
} {
|
||||
return {
|
||||
requestId: opts.requestId ?? 'req_1',
|
||||
status: 'ok',
|
||||
worlds: opts.worlds,
|
||||
diagnostics: opts.diagnostics ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MockScene {
|
||||
world: GeometryWorld
|
||||
clauses: ClauseDescriptor[]
|
||||
}
|
||||
|
||||
export function createMockScene(opts: {
|
||||
subjects: MockSubject[]
|
||||
relations: Array<{
|
||||
kind: string
|
||||
subjectId: number
|
||||
referenceId: number
|
||||
bounds?: { minGap?: number; maxGap?: number }
|
||||
}>
|
||||
}): MockScene {
|
||||
const world = buildMockGeometryWorld({ subjects: opts.subjects })
|
||||
const clauses = opts.relations.map((r, i) =>
|
||||
buildClauseDescriptor({
|
||||
clauseId: `clause_${i + 1}`,
|
||||
clauseKind: r.kind,
|
||||
subjectRef: r.subjectId,
|
||||
referenceRef: r.referenceId,
|
||||
bounds: r.bounds,
|
||||
}),
|
||||
)
|
||||
return { world, clauses }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic IR to Solver Clause Mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a compiled Semantic IR to solver ClauseDescriptors.
|
||||
*
|
||||
* This bridges the DSL compiler output to the solver evaluation input.
|
||||
* The `subjectIdMap` translates semantic IR subject ids to world subject ids.
|
||||
*/
|
||||
export function mapSemanticIrToClauses(
|
||||
semanticIr: SemanticIr,
|
||||
subjectIdMap: Map<ImhotepId, number>,
|
||||
): ClauseDescriptor[] {
|
||||
const clauses: ClauseDescriptor[] = []
|
||||
|
||||
for (const [, clause] of semanticIr.clauses) {
|
||||
const subjectRef = subjectIdMap.get(clause.subjectRef)
|
||||
const referenceRef = clause.referenceRef
|
||||
? subjectIdMap.get(clause.referenceRef)
|
||||
: undefined
|
||||
|
||||
if (subjectRef === undefined) continue
|
||||
|
||||
const tolerance = semanticIr.tolerances.get(clause.toleranceRef)
|
||||
const bounds: Record<string, number> = {}
|
||||
|
||||
if (clause.bounds.minGap) {
|
||||
bounds.minGap = clause.bounds.minGap.value
|
||||
}
|
||||
if (clause.bounds.maxGap) {
|
||||
bounds.maxGap = clause.bounds.maxGap.value
|
||||
}
|
||||
if (tolerance) {
|
||||
bounds.tolerance = tolerance.value
|
||||
}
|
||||
|
||||
clauses.push({
|
||||
clauseId: clause.id,
|
||||
clauseKind: `relation.${clause.relation}`,
|
||||
version: 1,
|
||||
subjectRef,
|
||||
referenceRef,
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
return clauses
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMockSnapshot(opts: {
|
||||
id?: string
|
||||
stateKind?: string
|
||||
stateSource?: string
|
||||
selector?: string
|
||||
world: GeometryWorld
|
||||
}): GeometrySnapshot {
|
||||
const metadata: SnapshotMetadata = {
|
||||
snapshotId: opts.id ?? 'snap_1',
|
||||
stateKind: (opts.stateKind as any) ?? 'default',
|
||||
stateSource: (opts.stateSource as any) ?? 'synthetic',
|
||||
selector: opts.selector,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return {
|
||||
id: opts.id ?? 'snap_1',
|
||||
metadata,
|
||||
world: opts.world,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Reporter -> Diagnostics integration tests.
|
||||
*
|
||||
* Verifies that solver results produce correct diagnostics, that proof traces
|
||||
* map to human-readable output, and that witness shrinking works end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
makeDiagnostic,
|
||||
diagnosticFromProof,
|
||||
renderHumanReport,
|
||||
renderJsonReport,
|
||||
buildJsonReport,
|
||||
shrinkWitness,
|
||||
createTraceBuilder,
|
||||
findClauseTraces,
|
||||
formatDiagnosticCompact,
|
||||
} from 'imhotep-reporter'
|
||||
import type {
|
||||
ProofLike,
|
||||
Witness,
|
||||
ShrinkResult,
|
||||
} from 'imhotep-reporter'
|
||||
import { buildMockSolverResult } from './integration-mocks.js'
|
||||
|
||||
describe('Reporter -> Diagnostics', () => {
|
||||
it('solver results produce correct diagnostics from failing proofs', () => {
|
||||
// Arrange: create a failing proof with predicate and witness
|
||||
const proof: ProofLike = {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
failedPredicate: { op: '>=', left: 10, right: 24 },
|
||||
witness: {
|
||||
subjectId: 1,
|
||||
referenceId: 2,
|
||||
envCaseId: 'env_1',
|
||||
snapshotId: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
// Act: convert proof to diagnostic
|
||||
const diagnostic = diagnosticFromProof(proof, {
|
||||
idGen: () => 'd1',
|
||||
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
|
||||
messageForClause: () => 'Expected .tooltip to be above .trigger',
|
||||
fixHintsForClause: () => ['Increase vertical gap to at least 24px'],
|
||||
})
|
||||
|
||||
// Assert
|
||||
assert.ok(diagnostic)
|
||||
assert.strictEqual(diagnostic!.code, 'IMH_RELATION_ABOVE_FAILED')
|
||||
assert.strictEqual(diagnostic!.category, 'contract-failure')
|
||||
assert.strictEqual(diagnostic!.severity, 'error')
|
||||
assert.ok(
|
||||
diagnostic!.related.some((rel) =>
|
||||
rel.message.includes('Predicate'),
|
||||
),
|
||||
)
|
||||
assert.ok(
|
||||
diagnostic!.fixHints.some((hint) =>
|
||||
hint.includes('gap'),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('indeterminate proofs produce indeterminate-result diagnostics', () => {
|
||||
// Arrange: create an indeterminate failing proof
|
||||
const proof: ProofLike = {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'indeterminate',
|
||||
witness: { envCaseId: 'env_1' },
|
||||
}
|
||||
|
||||
// Act
|
||||
const diagnostic = diagnosticFromProof(proof, {
|
||||
idGen: () => 'd1',
|
||||
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
|
||||
messageForClause: () => 'Could not determine relation',
|
||||
fixHintsForClause: () => [],
|
||||
})
|
||||
|
||||
// Assert
|
||||
assert.ok(diagnostic)
|
||||
assert.strictEqual(diagnostic!.category, 'indeterminate-result')
|
||||
})
|
||||
|
||||
it('passing proofs produce no diagnostics', () => {
|
||||
// Arrange: create a passing proof
|
||||
const proof: ProofLike = {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'pass',
|
||||
truth: 'determinate',
|
||||
}
|
||||
|
||||
// Act
|
||||
const diagnostic = diagnosticFromProof(proof, {
|
||||
idGen: () => 'd1',
|
||||
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
|
||||
messageForClause: () => 'Should not be called',
|
||||
fixHintsForClause: () => [],
|
||||
})
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(diagnostic, null)
|
||||
})
|
||||
|
||||
it('proof traces map to human-readable output', () => {
|
||||
// Arrange: create diagnostic linked to a trace
|
||||
const diagnostic = makeDiagnostic(
|
||||
{
|
||||
code: 'IMH_RELATION_LEFT_OF_FAILED',
|
||||
category: 'contract-failure',
|
||||
message: 'Button is not left of field',
|
||||
traceRef: 't1',
|
||||
position: {
|
||||
start: { line: 3, column: 5, offset: 20 },
|
||||
end: { line: 3, column: 40, offset: 55 },
|
||||
},
|
||||
},
|
||||
{ idGen: () => 'd1' },
|
||||
)
|
||||
|
||||
const traces = [
|
||||
{
|
||||
traceEventId: 't1',
|
||||
phase: 'clause-evaluated' as const,
|
||||
at: 1000,
|
||||
refs: { clauseId: 'c1', diagnosticId: 'd1' },
|
||||
payload: { status: 'fail' },
|
||||
},
|
||||
{
|
||||
traceEventId: 't2',
|
||||
phase: 'proof-created' as const,
|
||||
at: 1001,
|
||||
refs: { clauseId: 'c1', proofId: 'p1' },
|
||||
payload: {},
|
||||
},
|
||||
]
|
||||
|
||||
// Act: render human report with traces
|
||||
const report = renderHumanReport(
|
||||
[diagnostic],
|
||||
traces,
|
||||
new Map(),
|
||||
{ showTraces: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
assert.ok(report.includes('Button is not left of field'))
|
||||
assert.ok(report.includes('clause-evaluated'))
|
||||
assert.ok(report.includes('IMH_RELATION_LEFT_OF_FAILED'))
|
||||
assert.ok(report.includes('line 3'))
|
||||
})
|
||||
|
||||
it('trace builder emits events findable by clause id', () => {
|
||||
// Arrange: create trace builder
|
||||
const builder = createTraceBuilder({
|
||||
idGen: () => 't1',
|
||||
now: () => Date.now(),
|
||||
})
|
||||
|
||||
// Act: emit events
|
||||
builder.emit({
|
||||
phase: 'clause-evaluated',
|
||||
refs: { clauseId: 'c1' },
|
||||
payload: { status: 'pass' },
|
||||
})
|
||||
builder.emit({
|
||||
phase: 'clause-evaluated',
|
||||
refs: { clauseId: 'c2' },
|
||||
payload: { status: 'fail' },
|
||||
})
|
||||
builder.emit({
|
||||
phase: 'proof-created',
|
||||
refs: { clauseId: 'c1', proofId: 'p1' },
|
||||
payload: {},
|
||||
})
|
||||
|
||||
// Assert: findClauseTraces returns correct subset
|
||||
const found = findClauseTraces(builder.events(), 'c1')
|
||||
assert.strictEqual(found.length, 2)
|
||||
assert.ok(
|
||||
found.every((ev) => ev.refs.clauseId === 'c1'),
|
||||
)
|
||||
})
|
||||
|
||||
it('witness shrinking works end-to-end', () => {
|
||||
// Arrange: create a witness with redundant env cases
|
||||
const witness: Witness = {
|
||||
proof: {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
},
|
||||
envCases: ['env_1', 'env_2', 'env_3'],
|
||||
snapshots: ['default', 'hover'],
|
||||
subjects: [1, 2, 3],
|
||||
facts: [100, 101, 102],
|
||||
}
|
||||
|
||||
// Act: shrink witness — only env_2 and default snapshot are needed
|
||||
const stillFails = (w: Witness) =>
|
||||
w.envCases.includes('env_2') && w.snapshots.includes('default')
|
||||
|
||||
const result = shrinkWitness(witness, stillFails)
|
||||
|
||||
// Assert: witness was reduced
|
||||
assert.strictEqual(result.reduced, true)
|
||||
assert.deepStrictEqual(result.witness.envCases, ['env_2'])
|
||||
assert.deepStrictEqual(result.witness.snapshots, ['default'])
|
||||
assert.ok(result.axes.includes('env-case'))
|
||||
assert.ok(result.axes.includes('snapshot'))
|
||||
assert.ok(result.steps > 0)
|
||||
})
|
||||
|
||||
it('shrink results render in JSON report', () => {
|
||||
// Arrange: create a shrunk witness
|
||||
const witness: Witness = {
|
||||
proof: {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
},
|
||||
envCases: ['env_1'],
|
||||
snapshots: ['default'],
|
||||
subjects: [1],
|
||||
facts: [100],
|
||||
}
|
||||
|
||||
const shrinkResult: ShrinkResult = {
|
||||
reduced: true,
|
||||
witness,
|
||||
axes: ['env-case', 'subject'],
|
||||
steps: 3,
|
||||
}
|
||||
|
||||
const shrinkMap = new Map<string, ShrinkResult>()
|
||||
shrinkMap.set('c1', shrinkResult)
|
||||
|
||||
const diagnostic = makeDiagnostic(
|
||||
{
|
||||
code: 'IMH_TEST',
|
||||
category: 'contract-failure',
|
||||
message: 'Fail',
|
||||
clauseId: 'c1',
|
||||
},
|
||||
{ idGen: () => 'd1' },
|
||||
)
|
||||
|
||||
// Act: build JSON report with shrink
|
||||
const report = buildJsonReport(
|
||||
[diagnostic],
|
||||
[],
|
||||
shrinkMap,
|
||||
{ includeShrink: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(report.summary.errorCount, 1)
|
||||
assert.ok(report.shrinkResults)
|
||||
assert.ok(report.shrinkResults!.c1)
|
||||
assert.strictEqual(report.shrinkResults!.c1.reduced, true)
|
||||
assert.ok(report.shrinkResults!.c1.axes.includes('env-case'))
|
||||
|
||||
// Act: stringify
|
||||
const json = renderJsonReport(
|
||||
[diagnostic],
|
||||
[],
|
||||
shrinkMap,
|
||||
{ includeShrink: true, indent: 2 },
|
||||
)
|
||||
assert.ok(json.includes('shrinkResults'))
|
||||
assert.ok(json.includes('env-case'))
|
||||
})
|
||||
|
||||
it('solver diagnostics flow through to reporter formatting', () => {
|
||||
// Arrange: create solver result with diagnostics
|
||||
const solverResult = buildMockSolverResult({
|
||||
clauseResults: [
|
||||
{
|
||||
clauseId: 'c1',
|
||||
status: 'fail',
|
||||
truth: 'determinate',
|
||||
metrics: { observedGap: 4, minGap: 16 },
|
||||
witness: { subjectId: 1, referenceId: 2 },
|
||||
},
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND',
|
||||
severity: 'error',
|
||||
category: 'internal-error',
|
||||
message: 'Evaluation error',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: convert solver diagnostics to reporter diagnostics
|
||||
const diagnostics = solverResult.diagnostics.map((d) =>
|
||||
makeDiagnostic(
|
||||
{
|
||||
code: d.code,
|
||||
category: d.category as any,
|
||||
message: d.message,
|
||||
severity: d.severity as any,
|
||||
},
|
||||
{ idGen: () => 'd1' },
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(diagnostics.length, 1)
|
||||
assert.strictEqual(diagnostics[0].code, 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND')
|
||||
|
||||
// Act: render compact
|
||||
const compact = formatDiagnosticCompact(diagnostics[0])
|
||||
assert.ok(compact.includes('IMH_INTERNAL_UNKNOWN_CLAUSE_KIND'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* State -> Solver integration tests.
|
||||
*
|
||||
* Verifies that state snapshots create distinct geometry worlds,
|
||||
* that state comparison assertions evaluate correctly, and that
|
||||
* transition sampling produces valid worlds.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
import type { GeometryWorld } from 'imhotep-solver'
|
||||
import {
|
||||
compareSnapshots,
|
||||
createSnapshotStore,
|
||||
} from 'imhotep-state'
|
||||
import type { GeometrySnapshot } from 'imhotep-state'
|
||||
import {
|
||||
buildMockGeometryWorld,
|
||||
buildMockSnapshot,
|
||||
buildClauseDescriptor,
|
||||
} from './integration-mocks.js'
|
||||
|
||||
describe('State -> Solver', () => {
|
||||
before(() => {
|
||||
registerDefaultClauses()
|
||||
})
|
||||
|
||||
it('state snapshots create distinct geometry worlds', () => {
|
||||
// Arrange: build two worlds that differ only in box height
|
||||
const defaultWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_default',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 30, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const hoverWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_hover',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 32, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: wrap as snapshots
|
||||
const defaultSnap = buildMockSnapshot({
|
||||
id: 'snap_default',
|
||||
stateKind: 'default',
|
||||
world: defaultWorld,
|
||||
})
|
||||
|
||||
const hoverSnap = buildMockSnapshot({
|
||||
id: 'snap_hover',
|
||||
stateKind: 'hover',
|
||||
world: hoverWorld,
|
||||
})
|
||||
|
||||
// Assert: snapshots are distinct
|
||||
const diff = compareSnapshots(defaultSnap, hoverSnap)
|
||||
assert.strictEqual(diff.identical, false)
|
||||
assert.ok(
|
||||
diff.differences.some((d) => d.includes('world content differs')),
|
||||
)
|
||||
})
|
||||
|
||||
it('state comparison assertions evaluate correctly across snapshots', () => {
|
||||
// Arrange: default and hover worlds with different sizes
|
||||
const defaultWorld = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 30, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const hoverWorld = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 32, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: evaluate size assertion (height >= 31) on both worlds
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'size.atLeast',
|
||||
subjectRef: 0,
|
||||
bounds: { min: 31 },
|
||||
options: { dimension: 'height' },
|
||||
}),
|
||||
]
|
||||
|
||||
const defaultResult = evaluate(defaultWorld, clauses)
|
||||
const hoverResult = evaluate(hoverWorld, clauses)
|
||||
|
||||
// Assert: default fails (30 < 31), hover passes (32 >= 31)
|
||||
assert.strictEqual(defaultResult.clauseResults[0].status, 'fail')
|
||||
assert.strictEqual(hoverResult.clauseResults[0].status, 'pass')
|
||||
})
|
||||
|
||||
it('snapshot store maintains distinct worlds per state', () => {
|
||||
// Arrange: create store and add multiple state snapshots
|
||||
const store = createSnapshotStore()
|
||||
|
||||
const defaultWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_default',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.input',
|
||||
box: { top: 0, bottom: 20, left: 0, right: 200 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const focusWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_focus',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.input',
|
||||
box: { top: 0, bottom: 22, left: 0, right: 200 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.add(
|
||||
buildMockSnapshot({
|
||||
id: 'snap_default',
|
||||
stateKind: 'default',
|
||||
world: defaultWorld,
|
||||
}),
|
||||
)
|
||||
store.add(
|
||||
buildMockSnapshot({
|
||||
id: 'snap_focus',
|
||||
stateKind: 'focus',
|
||||
world: focusWorld,
|
||||
}),
|
||||
)
|
||||
|
||||
// Act: retrieve snapshots
|
||||
const snapshots = store.list()
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(snapshots.length, 2)
|
||||
assert.strictEqual(snapshots[0].metadata.stateKind, 'default')
|
||||
assert.strictEqual(snapshots[1].metadata.stateKind, 'focus')
|
||||
|
||||
// Assert: worlds are distinct
|
||||
const diff = compareSnapshots(snapshots[0], snapshots[1])
|
||||
assert.strictEqual(diff.identical, false)
|
||||
})
|
||||
|
||||
it('transition sampling produces valid worlds for solver', () => {
|
||||
// Arrange: create a store to hold transition samples
|
||||
const store = createSnapshotStore()
|
||||
|
||||
// Simulate 3 transition samples with increasing vertical offset
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const world = buildMockGeometryWorld({
|
||||
snapshotId: `snap_transition_${i}`,
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.box',
|
||||
box: {
|
||||
top: i * 10,
|
||||
bottom: i * 10 + 20,
|
||||
left: 0,
|
||||
right: 50,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const snap = buildMockSnapshot({
|
||||
id: `snap_transition_${i}`,
|
||||
stateKind: 'default',
|
||||
stateSource: 'synthetic',
|
||||
world,
|
||||
})
|
||||
store.add(snap)
|
||||
}
|
||||
|
||||
// Act: retrieve and evaluate each snapshot
|
||||
const snapshots = store.list()
|
||||
assert.strictEqual(snapshots.length, 3)
|
||||
|
||||
for (const snap of snapshots) {
|
||||
const world = snap.world as GeometryWorld
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'size.atLeast',
|
||||
subjectRef: 0,
|
||||
bounds: { min: 20 },
|
||||
options: { dimension: 'height' },
|
||||
}),
|
||||
]
|
||||
const result = evaluate(world, clauses)
|
||||
assert.strictEqual(
|
||||
result.clauseResults[0].status,
|
||||
'pass',
|
||||
`Sample ${snap.id} should pass height >= 20`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('state worlds carry correct metadata for diagnostics', () => {
|
||||
// Arrange: build snapshot with full metadata
|
||||
const world = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_hover',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const snap = buildMockSnapshot({
|
||||
id: 'snap_hover',
|
||||
stateKind: 'hover',
|
||||
stateSource: 'interaction',
|
||||
selector: '.tooltip',
|
||||
world,
|
||||
})
|
||||
|
||||
// Assert: metadata is preserved
|
||||
assert.strictEqual(snap.metadata.stateKind, 'hover')
|
||||
assert.strictEqual(snap.metadata.stateSource, 'interaction')
|
||||
assert.strictEqual(snap.metadata.selector, '.tooltip')
|
||||
assert.strictEqual(snap.id, 'snap_hover')
|
||||
|
||||
// Act: evaluate a relation on the state world
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'size.atLeast',
|
||||
subjectRef: 0,
|
||||
bounds: { min: 5 },
|
||||
options: { dimension: 'height' },
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(snap.world as GeometryWorld, clauses)
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Semantic IR and Execution IR definitions for Imhotep.
|
||||
*
|
||||
* Semantic IR:
|
||||
* Normalized graph of explicit obligations, frames, selectors, states,
|
||||
* environments, and tolerances. Stored as tables keyed by stable id.
|
||||
*
|
||||
* Execution IR:
|
||||
* Flattened, data-oriented arrays optimized for bulk evaluation against
|
||||
* a geometry world. This is the hot-path representation.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ImhotepId,
|
||||
SourceOrigin,
|
||||
LengthLiteral,
|
||||
Environment,
|
||||
StateSnapshot,
|
||||
TimelineSnapshot,
|
||||
Frame,
|
||||
Subject,
|
||||
} from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic IR Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SemanticIr {
|
||||
subjects: Map<ImhotepId, SemanticSubject>
|
||||
frames: Map<ImhotepId, SemanticFrame>
|
||||
states: Map<ImhotepId, SemanticState>
|
||||
timelines: Map<ImhotepId, SemanticTimeline>
|
||||
tolerances: Map<ImhotepId, SemanticTolerance>
|
||||
envGuards: Map<ImhotepId, SemanticEnvGuard>
|
||||
clauses: Map<ImhotepId, SemanticClause>
|
||||
groups: Map<ImhotepId, SemanticGroup>
|
||||
diagnosticMetadata: Map<ImhotepId, SemanticDiagnosticMeta>
|
||||
}
|
||||
|
||||
export interface SemanticSubject {
|
||||
id: ImhotepId
|
||||
selector: string
|
||||
kind: Subject['kind']
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticFrame {
|
||||
id: ImhotepId
|
||||
kind: Frame['kind']
|
||||
selector?: string
|
||||
name?: string
|
||||
originX: number
|
||||
originY: number
|
||||
writingMode: Frame['writingMode']
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticState {
|
||||
id: ImhotepId
|
||||
kind: StateSnapshot['kind']
|
||||
name?: string
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticTimeline {
|
||||
id: ImhotepId
|
||||
mode: TimelineSnapshot['mode']
|
||||
samples?: number[]
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticTolerance {
|
||||
id: ImhotepId
|
||||
value: number
|
||||
unit: 'jnd' | 'px'
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticEnvGuard {
|
||||
id: ImhotepId
|
||||
expression: string
|
||||
normalizedCases: Environment[]
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticClause {
|
||||
id: ImhotepId
|
||||
origin: SourceOrigin
|
||||
subjectRef: ImhotepId
|
||||
referenceRef?: ImhotepId
|
||||
relation: string
|
||||
frameRef: ImhotepId
|
||||
stateRef: ImhotepId
|
||||
timelineRef: ImhotepId
|
||||
envGuardRef: ImhotepId
|
||||
toleranceRef: ImhotepId
|
||||
bounds: {
|
||||
minGap?: LengthLiteral
|
||||
maxGap?: LengthLiteral
|
||||
}
|
||||
}
|
||||
|
||||
export interface SemanticGroup {
|
||||
id: ImhotepId
|
||||
origin: SourceOrigin
|
||||
operator: 'and' | 'or' | 'not' | 'quantifier'
|
||||
quantifier?: {
|
||||
kind: string
|
||||
count?: number
|
||||
}
|
||||
clauseRefs: ImhotepId[]
|
||||
groupRefs?: ImhotepId[]
|
||||
}
|
||||
|
||||
export interface SemanticDiagnosticMeta {
|
||||
id: ImhotepId
|
||||
clauseRef?: ImhotepId
|
||||
messageTemplate: string
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution IR Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Execution IR stores clause data in parallel typed arrays for bulk
|
||||
* evaluation. Every array is indexed by clauseIndex.
|
||||
*/
|
||||
export interface ExecutionIr {
|
||||
clauseCount: number
|
||||
|
||||
// Clause classification and cross-references (all Uint32Array)
|
||||
clauseType: Uint16Array
|
||||
clauseSubject: Uint32Array
|
||||
clauseReference: Uint32Array
|
||||
clauseFrame: Uint32Array
|
||||
clauseState: Uint32Array
|
||||
clauseTimeline: Uint32Array
|
||||
clauseTolerance: Uint32Array
|
||||
clauseEnvGuard: Uint32Array
|
||||
|
||||
// Numeric bounds (Float64Array)
|
||||
clauseArg0: Float64Array
|
||||
clauseArg1: Float64Array
|
||||
|
||||
// Flags and origin (Uint32Array)
|
||||
clauseFlags: Uint32Array
|
||||
clauseOrigin: Uint32Array
|
||||
}
|
||||
|
||||
export interface ExecutionIrBuilder {
|
||||
addClause(descriptor: ExecutionClauseDescriptor): number
|
||||
build(): ExecutionIr
|
||||
}
|
||||
|
||||
export interface ExecutionClauseDescriptor {
|
||||
clauseType: number
|
||||
subjectIndex: number
|
||||
referenceIndex: number
|
||||
frameIndex: number
|
||||
stateIndex: number
|
||||
timelineIndex: number
|
||||
toleranceIndex: number
|
||||
envGuardIndex: number
|
||||
arg0: number
|
||||
arg1: number
|
||||
flags: number
|
||||
originIndex: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IR Lowering Pipeline Stages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LoweringStage =
|
||||
| 'parse'
|
||||
| 'validate'
|
||||
| 'normalize'
|
||||
| 'resolve-defaults'
|
||||
| 'compile'
|
||||
| 'derive-facts'
|
||||
|
||||
export interface LoweringPipeline {
|
||||
stages: LoweringStage[]
|
||||
currentStage: LoweringStage
|
||||
diagnostics: unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clause Family Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClauseFamilyDescriptor {
|
||||
clauseKind: string
|
||||
version: number
|
||||
requiredFacts: string[]
|
||||
}
|
||||
|
||||
export interface ClauseFamilyRegistry {
|
||||
register(descriptor: ClauseFamilyDescriptor): void
|
||||
lookup(clauseKind: string): ClauseFamilyDescriptor | undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Create empty Semantic IR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptySemanticIr(): SemanticIr {
|
||||
return {
|
||||
subjects: new Map(),
|
||||
frames: new Map(),
|
||||
states: new Map(),
|
||||
timelines: new Map(),
|
||||
tolerances: new Map(),
|
||||
envGuards: new Map(),
|
||||
clauses: new Map(),
|
||||
groups: new Map(),
|
||||
diagnosticMetadata: new Map(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Pure first-order logic AST types for Imhotep V1.1.
|
||||
*
|
||||
* These nodes represent the deterministic scene logic layer:
|
||||
* quantifiers, boolean connectives, predicate calls, and terms.
|
||||
*
|
||||
* All nodes follow the unist-style shape with source spans.
|
||||
*/
|
||||
|
||||
import type { AstNode } from './ast.js'
|
||||
import type { Position } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula Union
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FormulaNode =
|
||||
| ForAllFormula
|
||||
| ExistsFormula
|
||||
| AndFormula
|
||||
| OrFormula
|
||||
| NotFormula
|
||||
| ImpliesFormula
|
||||
| PredicateCall
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quantifier Formulas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ForAllFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'forall'
|
||||
bindings: TupleBinding[]
|
||||
body: FormulaNode
|
||||
}
|
||||
|
||||
export interface ExistsFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'exists'
|
||||
bindings: TupleBinding[]
|
||||
body: FormulaNode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boolean Connective Formulas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AndFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'and'
|
||||
left: FormulaNode
|
||||
right: FormulaNode
|
||||
}
|
||||
|
||||
export interface OrFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'or'
|
||||
left: FormulaNode
|
||||
right: FormulaNode
|
||||
}
|
||||
|
||||
export interface NotFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'not'
|
||||
operand: FormulaNode
|
||||
}
|
||||
|
||||
export interface ImpliesFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'implies'
|
||||
antecedent: FormulaNode
|
||||
consequent: FormulaNode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atomic Formula: Predicate Call
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PredicateCall extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'predicate'
|
||||
predicate: string
|
||||
args: TermNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TermNode = VariableRef | DomainRef | AccessorTerm
|
||||
|
||||
export interface VariableRef extends AstNode {
|
||||
type: 'VariableRef'
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DomainRef extends AstNode {
|
||||
type: 'DomainRef'
|
||||
domain: string
|
||||
selector?: string
|
||||
parentVar?: string
|
||||
}
|
||||
|
||||
export interface AccessorTerm extends AstNode {
|
||||
type: 'AccessorTerm'
|
||||
variable: string
|
||||
property: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tuple Binding (for multi-variable quantification)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TupleBinding extends AstNode {
|
||||
type: 'TupleBinding'
|
||||
variables: string[]
|
||||
domain: DomainRef
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property Run Blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PropertyRunBlock extends AstNode {
|
||||
type: 'PropertyRunBlock'
|
||||
mode: 'sampled' | 'enumerated'
|
||||
inputDomain: unknown
|
||||
body: AstNode[]
|
||||
}
|
||||
|
||||
export interface SampledRunBlock extends AstNode {
|
||||
type: 'SampledRunBlock'
|
||||
arbitrary: unknown
|
||||
numRuns?: number
|
||||
seed?: number
|
||||
body: AstNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isForAllFormula(node: FormulaNode): node is ForAllFormula {
|
||||
return node.kind === 'forall'
|
||||
}
|
||||
|
||||
export function isExistsFormula(node: FormulaNode): node is ExistsFormula {
|
||||
return node.kind === 'exists'
|
||||
}
|
||||
|
||||
export function isAndFormula(node: FormulaNode): node is AndFormula {
|
||||
return node.kind === 'and'
|
||||
}
|
||||
|
||||
export function isOrFormula(node: FormulaNode): node is OrFormula {
|
||||
return node.kind === 'or'
|
||||
}
|
||||
|
||||
export function isNotFormula(node: FormulaNode): node is NotFormula {
|
||||
return node.kind === 'not'
|
||||
}
|
||||
|
||||
export function isImpliesFormula(node: FormulaNode): node is ImpliesFormula {
|
||||
return node.kind === 'implies'
|
||||
}
|
||||
|
||||
export function isPredicateCall(node: FormulaNode): node is PredicateCall {
|
||||
return node.kind === 'predicate'
|
||||
}
|
||||
|
||||
export function isVariableRef(node: AstNode): node is VariableRef {
|
||||
return node.type === 'VariableRef'
|
||||
}
|
||||
|
||||
export function isDomainRef(node: AstNode): node is DomainRef {
|
||||
return node.type === 'DomainRef'
|
||||
}
|
||||
|
||||
export function isAccessorTerm(node: AstNode): node is AccessorTerm {
|
||||
return node.type === 'AccessorTerm'
|
||||
}
|
||||
|
||||
export function isTupleBinding(node: AstNode): node is TupleBinding {
|
||||
return node.type === 'TupleBinding'
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Flattened, data-oriented execution IR for first-order logic formulas.
|
||||
*
|
||||
* The hot-path representation stores formulas in parallel typed arrays
|
||||
* indexed by formulaId. No deep nesting exists at runtime; child
|
||||
* relationships are expressed through numeric offsets into the same
|
||||
* flat tables.
|
||||
*
|
||||
* Invariant: every formula referenced by a child pointer is stored in
|
||||
* the same LogicIr instance. The root formula always has id 0.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, SourceOrigin } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula Kinds (must fit in Uint8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FORMULA_KIND_FORALL = 1
|
||||
export const FORMULA_KIND_EXISTS = 2
|
||||
export const FORMULA_KIND_AND = 3
|
||||
export const FORMULA_KIND_OR = 4
|
||||
export const FORMULA_KIND_NOT = 5
|
||||
export const FORMULA_KIND_IMPLIES = 6
|
||||
export const FORMULA_KIND_PREDICATE = 7
|
||||
|
||||
export type FormulaKind =
|
||||
| typeof FORMULA_KIND_FORALL
|
||||
| typeof FORMULA_KIND_EXISTS
|
||||
| typeof FORMULA_KIND_AND
|
||||
| typeof FORMULA_KIND_OR
|
||||
| typeof FORMULA_KIND_NOT
|
||||
| typeof FORMULA_KIND_IMPLIES
|
||||
| typeof FORMULA_KIND_PREDICATE
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logic IR Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogicIr {
|
||||
/** Total number of formulas in this IR. */
|
||||
formulaCount: number
|
||||
|
||||
// Formula classification (all Uint8Array)
|
||||
formulaKind: Uint8Array
|
||||
|
||||
// Child pointers (Uint32Array, 0xFFFFFFFF means "none")
|
||||
formulaLeft: Uint32Array
|
||||
formulaRight: Uint32Array
|
||||
|
||||
// Binding region (for quantifiers)
|
||||
// Each quantifier formula references a slice [bindingStart, bindingStart + bindingCount)
|
||||
// in the flat binding table below.
|
||||
bindingStart: Uint32Array
|
||||
bindingCount: Uint8Array
|
||||
|
||||
// Predicate call region (for atomic formulas)
|
||||
// Each predicate formula references a slice [argStart, argStart + argCount)
|
||||
predicateId: Uint32Array
|
||||
argStart: Uint32Array
|
||||
argCount: Uint8Array
|
||||
|
||||
// Origin and proof tracking (Uint32Array)
|
||||
originIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat Binding Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BindingTable {
|
||||
/** Number of bindings. */
|
||||
count: number
|
||||
|
||||
// Variable names (string table index)
|
||||
variableNameId: Uint32Array
|
||||
|
||||
// Domain descriptor index
|
||||
domainId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat Argument Table (for predicate calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ArgTable {
|
||||
/** Number of argument terms. */
|
||||
count: number
|
||||
|
||||
// Term kind: 1 = variable, 2 = accessor, 3 = literal number
|
||||
termKind: Uint8Array
|
||||
|
||||
// For variables: binding table index
|
||||
// For accessors: variable binding index
|
||||
// For literals: 0xFFFFFFFF (value stored in literalValue)
|
||||
termRef: Uint32Array
|
||||
|
||||
// For accessors: string table index of property name
|
||||
// For literals: the literal numeric value (reinterpreted as Uint32)
|
||||
termAux: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Predicate Registry Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PredicateIndexEntry {
|
||||
predicateId: number
|
||||
name: string
|
||||
arity: number
|
||||
domainSignature: number[]
|
||||
requiredFacts: string[]
|
||||
}
|
||||
|
||||
export interface PredicateIndex {
|
||||
entries: PredicateIndexEntry[]
|
||||
byName: Map<string, number>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String Table (shared across IR tables)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IrStringTable {
|
||||
values: string[]
|
||||
byValue: Map<string, number>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evaluation Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogicEvaluationRequest {
|
||||
logicIr: LogicIr
|
||||
bindings: BindingTable
|
||||
args: ArgTable
|
||||
predicates: PredicateIndex
|
||||
strings: IrStringTable
|
||||
rootFormulaId: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogicIrBuilder {
|
||||
addFormula(descriptor: LogicFormulaDescriptor): number
|
||||
addBinding(descriptor: LogicBindingDescriptor): number
|
||||
addArg(descriptor: LogicArgDescriptor): number
|
||||
build(): LogicIr
|
||||
}
|
||||
|
||||
export interface LogicFormulaDescriptor {
|
||||
kind: FormulaKind
|
||||
left?: number
|
||||
right?: number
|
||||
bindingStart?: number
|
||||
bindingCount?: number
|
||||
predicateId?: number
|
||||
argStart?: number
|
||||
argCount?: number
|
||||
originIndex?: number
|
||||
}
|
||||
|
||||
export interface LogicBindingDescriptor {
|
||||
variableNameId: number
|
||||
domainId: number
|
||||
}
|
||||
|
||||
export interface LogicArgDescriptor {
|
||||
termKind: number
|
||||
termRef: number
|
||||
termAux: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Create empty Logic IR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyLogicIr(): LogicIr {
|
||||
return {
|
||||
formulaCount: 0,
|
||||
formulaKind: new Uint8Array(0),
|
||||
formulaLeft: new Uint32Array(0),
|
||||
formulaRight: new Uint32Array(0),
|
||||
bindingStart: new Uint32Array(0),
|
||||
bindingCount: new Uint8Array(0),
|
||||
predicateId: new Uint32Array(0),
|
||||
argStart: new Uint32Array(0),
|
||||
argCount: new Uint8Array(0),
|
||||
originIndex: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyBindingTable(): BindingTable {
|
||||
return {
|
||||
count: 0,
|
||||
variableNameId: new Uint32Array(0),
|
||||
domainId: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyArgTable(): ArgTable {
|
||||
return {
|
||||
count: 0,
|
||||
termKind: new Uint8Array(0),
|
||||
termRef: new Uint32Array(0),
|
||||
termAux: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyStringTable(): IrStringTable {
|
||||
return {
|
||||
values: [],
|
||||
byValue: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Intern a string into the string table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function internString(table: IrStringTable, value: string): number {
|
||||
const existing = table.byValue.get(value)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
const id = table.values.length
|
||||
table.values.push(value)
|
||||
table.byValue.set(value, id)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Unit tests for Imhotep logic AST nodes, IR structure, and domain
|
||||
* definitions.
|
||||
*
|
||||
* Covers Stream 1 contracts:
|
||||
* - Formula node construction and type guards
|
||||
* - Logic IR flat array layout
|
||||
* - Domain descriptor and value factories
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import type {
|
||||
FormulaNode,
|
||||
ForAllFormula,
|
||||
ExistsFormula,
|
||||
AndFormula,
|
||||
OrFormula,
|
||||
NotFormula,
|
||||
ImpliesFormula,
|
||||
PredicateCall,
|
||||
VariableRef,
|
||||
DomainRef,
|
||||
AccessorTerm,
|
||||
TupleBinding,
|
||||
PropertyRunBlock,
|
||||
SampledRunBlock,
|
||||
} from './logic-ast.js'
|
||||
|
||||
import {
|
||||
isForAllFormula,
|
||||
isExistsFormula,
|
||||
isAndFormula,
|
||||
isOrFormula,
|
||||
isNotFormula,
|
||||
isImpliesFormula,
|
||||
isPredicateCall,
|
||||
isVariableRef,
|
||||
isDomainRef,
|
||||
isAccessorTerm,
|
||||
isTupleBinding,
|
||||
} from './logic-ast.js'
|
||||
|
||||
import {
|
||||
createEmptyLogicIr,
|
||||
createEmptyBindingTable,
|
||||
createEmptyArgTable,
|
||||
createEmptyStringTable,
|
||||
internString,
|
||||
FORMULA_KIND_FORALL,
|
||||
FORMULA_KIND_PREDICATE,
|
||||
} from './logic-ir.js'
|
||||
|
||||
import {
|
||||
createElementsDomain,
|
||||
createDescendantsDomain,
|
||||
createLineBoxesDomain,
|
||||
createTextRunsDomain,
|
||||
createFragmentsDomain,
|
||||
createFramesDomain,
|
||||
createDomainValue,
|
||||
registerDomain,
|
||||
lookupDomain,
|
||||
listDomains,
|
||||
clearDomainRegistry,
|
||||
} from './domains.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula AST
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Logic AST', () => {
|
||||
it('constructs a ForAllFormula', () => {
|
||||
const formula: ForAllFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [
|
||||
{
|
||||
type: 'TupleBinding',
|
||||
variables: ['btn'],
|
||||
domain: {
|
||||
type: 'DomainRef',
|
||||
domain: 'elements',
|
||||
selector: '.button',
|
||||
},
|
||||
},
|
||||
],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'atLeast',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: 'btn' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert.strictEqual(formula.kind, 'forall')
|
||||
assert.strictEqual(formula.bindings.length, 1)
|
||||
assert.strictEqual(formula.bindings[0].variables[0], 'btn')
|
||||
assert.ok(isForAllFormula(formula))
|
||||
assert.strictEqual(isExistsFormula(formula), false)
|
||||
})
|
||||
|
||||
it('constructs an ExistsFormula', () => {
|
||||
const formula: ExistsFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [
|
||||
{
|
||||
type: 'TupleBinding',
|
||||
variables: ['icon'],
|
||||
domain: {
|
||||
type: 'DomainRef',
|
||||
domain: 'descendants',
|
||||
parentVar: 'card',
|
||||
selector: '.icon',
|
||||
},
|
||||
},
|
||||
],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'above',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: 'icon' },
|
||||
{ type: 'VariableRef', name: 'content' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert.ok(isExistsFormula(formula))
|
||||
assert.strictEqual(formula.bindings[0].domain.domain, 'descendants')
|
||||
})
|
||||
|
||||
it('constructs boolean connectives', () => {
|
||||
const p: PredicateCall = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'width',
|
||||
args: [{ type: 'VariableRef', name: 'x' }],
|
||||
}
|
||||
|
||||
const q: PredicateCall = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'height',
|
||||
args: [{ type: 'VariableRef', name: 'x' }],
|
||||
}
|
||||
|
||||
const andF: AndFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'and',
|
||||
left: p,
|
||||
right: q,
|
||||
}
|
||||
|
||||
const orF: OrFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'or',
|
||||
left: p,
|
||||
right: q,
|
||||
}
|
||||
|
||||
const notF: NotFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: p,
|
||||
}
|
||||
|
||||
const impliesF: ImpliesFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'implies',
|
||||
antecedent: p,
|
||||
consequent: q,
|
||||
}
|
||||
|
||||
assert.ok(isAndFormula(andF))
|
||||
assert.ok(isOrFormula(orF))
|
||||
assert.ok(isNotFormula(notF))
|
||||
assert.ok(isImpliesFormula(impliesF))
|
||||
assert.ok(isPredicateCall(p))
|
||||
})
|
||||
|
||||
it('type guards narrow correctly', () => {
|
||||
const varRef: VariableRef = { type: 'VariableRef', name: 'btn' }
|
||||
const domRef: DomainRef = { type: 'DomainRef', domain: 'elements', selector: '.btn' }
|
||||
const accTerm: AccessorTerm = { type: 'AccessorTerm', variable: 'btn', property: 'width' }
|
||||
|
||||
assert.ok(isVariableRef(varRef))
|
||||
assert.ok(isDomainRef(domRef))
|
||||
assert.ok(isAccessorTerm(accTerm))
|
||||
assert.strictEqual(isVariableRef(domRef), false)
|
||||
})
|
||||
|
||||
it('constructs PropertyRunBlock and SampledRunBlock', () => {
|
||||
const propBlock: PropertyRunBlock = {
|
||||
type: 'PropertyRunBlock',
|
||||
mode: 'enumerated',
|
||||
inputDomain: { size: ['sm', 'md', 'lg'] },
|
||||
body: [],
|
||||
}
|
||||
|
||||
const sampledBlock: SampledRunBlock = {
|
||||
type: 'SampledRunBlock',
|
||||
arbitrary: {},
|
||||
numRuns: 100,
|
||||
seed: 42,
|
||||
body: [],
|
||||
}
|
||||
|
||||
assert.strictEqual(propBlock.mode, 'enumerated')
|
||||
assert.strictEqual(sampledBlock.numRuns, 100)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logic IR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Logic IR', () => {
|
||||
it('creates empty Logic IR with zero-length arrays', () => {
|
||||
const ir = createEmptyLogicIr()
|
||||
assert.strictEqual(ir.formulaCount, 0)
|
||||
assert.strictEqual(ir.formulaKind.length, 0)
|
||||
assert.strictEqual(ir.formulaLeft.length, 0)
|
||||
assert.strictEqual(ir.formulaRight.length, 0)
|
||||
assert.strictEqual(ir.bindingStart.length, 0)
|
||||
assert.strictEqual(ir.bindingCount.length, 0)
|
||||
assert.strictEqual(ir.predicateId.length, 0)
|
||||
assert.strictEqual(ir.argStart.length, 0)
|
||||
assert.strictEqual(ir.argCount.length, 0)
|
||||
assert.strictEqual(ir.originIndex.length, 0)
|
||||
})
|
||||
|
||||
it('creates empty binding and arg tables', () => {
|
||||
const bindings = createEmptyBindingTable()
|
||||
assert.strictEqual(bindings.count, 0)
|
||||
assert.strictEqual(bindings.variableNameId.length, 0)
|
||||
|
||||
const args = createEmptyArgTable()
|
||||
assert.strictEqual(args.count, 0)
|
||||
assert.strictEqual(args.termKind.length, 0)
|
||||
})
|
||||
|
||||
it('interns strings into the string table', () => {
|
||||
const table = createEmptyStringTable()
|
||||
const id1 = internString(table, 'width')
|
||||
const id2 = internString(table, 'height')
|
||||
const id3 = internString(table, 'width')
|
||||
|
||||
assert.strictEqual(id1, 0)
|
||||
assert.strictEqual(id2, 1)
|
||||
assert.strictEqual(id3, 0)
|
||||
assert.strictEqual(table.values.length, 2)
|
||||
assert.strictEqual(table.values[id1], 'width')
|
||||
})
|
||||
|
||||
it('exposes formula kind constants', () => {
|
||||
assert.strictEqual(FORMULA_KIND_FORALL, 1)
|
||||
assert.strictEqual(FORMULA_KIND_PREDICATE, 7)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Domains', () => {
|
||||
it('creates elements domain descriptor', () => {
|
||||
const d = createElementsDomain('dom_1', '.button')
|
||||
assert.strictEqual(d.domainId, 'dom_1')
|
||||
assert.strictEqual(d.kind, 'elements')
|
||||
assert.strictEqual(d.selector, '.button')
|
||||
})
|
||||
|
||||
it('creates descendants domain descriptor', () => {
|
||||
const d = createDescendantsDomain('dom_2', '$card', '.title')
|
||||
assert.strictEqual(d.kind, 'descendants')
|
||||
assert.strictEqual(d.parentVar, '$card')
|
||||
assert.strictEqual(d.selector, '.title')
|
||||
})
|
||||
|
||||
it('creates line boxes domain descriptor', () => {
|
||||
const d = createLineBoxesDomain('dom_3', 'text_1')
|
||||
assert.strictEqual(d.kind, 'lineBoxes')
|
||||
assert.strictEqual(d.selector, 'text_1')
|
||||
})
|
||||
|
||||
it('creates text runs domain descriptor', () => {
|
||||
const d = createTextRunsDomain('dom_4', 'text_1')
|
||||
assert.strictEqual(d.kind, 'textRuns')
|
||||
})
|
||||
|
||||
it('creates fragments domain descriptor', () => {
|
||||
const d = createFragmentsDomain('dom_5', 'subj_1')
|
||||
assert.strictEqual(d.kind, 'fragments')
|
||||
})
|
||||
|
||||
it('creates frames domain descriptor', () => {
|
||||
const d = createFramesDomain('dom_6', 'viewport')
|
||||
assert.strictEqual(d.kind, 'frames')
|
||||
assert.strictEqual(d.selector, 'viewport')
|
||||
})
|
||||
|
||||
it('creates domain value with subject ids', () => {
|
||||
const ids = new Uint32Array([10, 20, 30])
|
||||
const dv = createDomainValue('dom_1', ids, 'elements(.button)', true)
|
||||
assert.strictEqual(dv.domainId, 'dom_1')
|
||||
assert.strictEqual(dv.subjectIds.length, 3)
|
||||
assert.strictEqual(dv.closed, true)
|
||||
assert.strictEqual(dv.provenance, 'elements(.button)')
|
||||
})
|
||||
|
||||
it('registers and looks up domains', () => {
|
||||
clearDomainRegistry()
|
||||
const d = createElementsDomain('dom_test', '.test')
|
||||
registerDomain(d)
|
||||
|
||||
const lookedUp = lookupDomain('dom_test')
|
||||
assert.ok(lookedUp)
|
||||
assert.strictEqual(lookedUp?.selector, '.test')
|
||||
|
||||
const all = listDomains()
|
||||
assert.strictEqual(all.length, 1)
|
||||
|
||||
clearDomainRegistry()
|
||||
assert.strictEqual(listDomains().length, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,379 @@
|
||||
// perf.test.ts - Performance regression tests for Imhotep core hot paths
|
||||
// Uses Node.js built-in test runner and performance hooks.
|
||||
// Targets (V1 developer guide):
|
||||
// Unit tests < 10ms each
|
||||
// Integration tests < 100ms each
|
||||
// Compilation < 5ms per assertion
|
||||
// Extraction < 50ms per page
|
||||
// Solving < 1ms per clause
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
import { compile } from 'imhotep-dsl'
|
||||
import {
|
||||
normalizeSubjects,
|
||||
normalizeBoxes,
|
||||
normalizeTopology,
|
||||
normalizeFrames,
|
||||
buildTransformChain,
|
||||
composeTransform2D,
|
||||
clearTransformCaches,
|
||||
} from 'imhotep-geometry'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
|
||||
describe('Performance regression — compilation', () => {
|
||||
it('compiles 100 assertions in under 500ms', () => {
|
||||
const assertions = generateAssertions(100)
|
||||
const start = performance.now()
|
||||
const ir = compile(assertions)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 500,
|
||||
true,
|
||||
`Compilation took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('compiles 10 assertions in under 50ms (5ms per assertion)', () => {
|
||||
const assertions = generateAssertions(10)
|
||||
const start = performance.now()
|
||||
compile(assertions)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Compilation took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance regression — world creation', () => {
|
||||
it('normalizes 1000 subjects in under 50ms', () => {
|
||||
const raw = generateRawSubjects(1000)
|
||||
const start = performance.now()
|
||||
normalizeSubjects(raw)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Subject normalization took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes 1000 boxes in under 50ms', () => {
|
||||
const raw = generateRawBoxes(1000)
|
||||
const start = performance.now()
|
||||
normalizeBoxes(raw)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Box normalization took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes topology in under 25ms', () => {
|
||||
const raw = generateRawTopology(1000)
|
||||
const start = performance.now()
|
||||
normalizeTopology(raw, 1000)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 25,
|
||||
true,
|
||||
`Topology normalization took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance regression — frame resolution', () => {
|
||||
it('resolves 100 transform chains in under 50ms', () => {
|
||||
const matrices = generateTransformMatrices()
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
buildTransformChain(matrices)
|
||||
}
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Frame resolution took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('composes 100 2D transforms in under 100ms', () => {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
composeTransform2D(0, 0, i, i, 0, 1, 1)
|
||||
}
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 100,
|
||||
true,
|
||||
`Transform composition took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('caches repeated transform chains (second pass under 1ms)', () => {
|
||||
const matrices = generateTransformMatrices()
|
||||
clearTransformCaches()
|
||||
buildTransformChain(matrices) // warm cache
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
buildTransformChain(matrices)
|
||||
}
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 1,
|
||||
true,
|
||||
`Cached frame resolution took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance regression — solving', () => {
|
||||
it('evaluates 100 clauses in under 100ms (1ms per clause)', () => {
|
||||
registerDefaultClauses()
|
||||
const world = generateMinimalWorld()
|
||||
const clauses = generateClauses(100)
|
||||
const start = performance.now()
|
||||
evaluate(world, clauses, { skipMissingEvaluators: true })
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 100,
|
||||
true,
|
||||
`Solving took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('evaluates 10 clauses in under 10ms', () => {
|
||||
registerDefaultClauses()
|
||||
const world = generateMinimalWorld()
|
||||
const clauses = generateClauses(10)
|
||||
const start = performance.now()
|
||||
evaluate(world, clauses, { skipMissingEvaluators: true })
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 10,
|
||||
true,
|
||||
`Solving took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateAssertions(n: number): Array<{
|
||||
type: 'RelationAssertion'
|
||||
relation: string
|
||||
subject: { type: 'SelectorRef'; value: string }
|
||||
reference: { type: 'SelectorRef'; value: string }
|
||||
options: { type: 'RelationOptions' }
|
||||
}> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
type: 'RelationAssertion' as const,
|
||||
relation: i % 2 === 0 ? 'leftOf' : 'above',
|
||||
subject: { type: 'SelectorRef' as const, value: `.s-${i}` },
|
||||
reference: { type: 'SelectorRef' as const, value: `.r-${i}` },
|
||||
options: { type: 'RelationOptions' as const },
|
||||
}))
|
||||
}
|
||||
|
||||
function generateRawSubjects(n: number): Array<{
|
||||
id: number
|
||||
domNodeId: number
|
||||
kind: string
|
||||
primaryBoxId: number
|
||||
firstFragmentId: number
|
||||
fragmentCount: number
|
||||
firstTextRunId: number
|
||||
textRunCount: number
|
||||
}> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: i,
|
||||
domNodeId: i,
|
||||
kind: 'element',
|
||||
primaryBoxId: i,
|
||||
firstFragmentId: 0,
|
||||
fragmentCount: 0,
|
||||
firstTextRunId: 0,
|
||||
textRunCount: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function generateRawBoxes(n: number): Array<{
|
||||
id: number
|
||||
subjectId: number
|
||||
frameId: number
|
||||
borderLeft: number
|
||||
borderTop: number
|
||||
borderRight: number
|
||||
borderBottom: number
|
||||
paddingLeft: number
|
||||
paddingTop: number
|
||||
paddingRight: number
|
||||
paddingBottom: number
|
||||
contentLeft: number
|
||||
contentTop: number
|
||||
contentRight: number
|
||||
contentBottom: number
|
||||
}> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: i,
|
||||
subjectId: i,
|
||||
frameId: 0,
|
||||
borderLeft: 0,
|
||||
borderTop: 0,
|
||||
borderRight: 10,
|
||||
borderBottom: 10,
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
contentLeft: 0,
|
||||
contentTop: 0,
|
||||
contentRight: 10,
|
||||
contentBottom: 10,
|
||||
}))
|
||||
}
|
||||
|
||||
function generateRawTopology(n: number): {
|
||||
containingBlockOf: number[]
|
||||
nearestPositionedAncestorOf: number[]
|
||||
scrollContainerOf: number[]
|
||||
stackingContextOf: number[]
|
||||
formattingContextOf: number[]
|
||||
clippingRootOf: number[]
|
||||
paintOrderBucket: number[]
|
||||
paintOrderIndex: number[]
|
||||
} {
|
||||
return {
|
||||
containingBlockOf: Array.from({ length: n }, () => 0),
|
||||
nearestPositionedAncestorOf: Array.from({ length: n }, () => 0),
|
||||
scrollContainerOf: Array.from({ length: n }, () => 0),
|
||||
stackingContextOf: Array.from({ length: n }, () => 0),
|
||||
formattingContextOf: Array.from({ length: n }, () => 0),
|
||||
clippingRootOf: Array.from({ length: n }, () => 0),
|
||||
paintOrderBucket: Array.from({ length: n }, () => 0),
|
||||
paintOrderIndex: Array.from({ length: n }, () => 0),
|
||||
}
|
||||
}
|
||||
|
||||
function generateTransformMatrices(): Float64Array[] {
|
||||
return [
|
||||
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
||||
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 10, 0, 1]),
|
||||
]
|
||||
}
|
||||
|
||||
function generateMinimalWorld(): any {
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snapshot_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array([0, 1]),
|
||||
domNodeId: new Uint32Array([0, 1]),
|
||||
subjectKind: new Uint16Array([1, 1]),
|
||||
primaryBoxId: new Uint32Array([0, 1]),
|
||||
firstFragmentId: new Uint32Array([0, 0]),
|
||||
fragmentCount: new Uint16Array([0, 0]),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array([0, 1]),
|
||||
subjectId: new Uint32Array([0, 1]),
|
||||
frameId: new Uint32Array([0, 0]),
|
||||
borderLeft: new Float64Array([0, 0]),
|
||||
borderTop: new Float64Array([0, 0]),
|
||||
borderRight: new Float64Array([10, 10]),
|
||||
borderBottom: new Float64Array([10, 10]),
|
||||
paddingLeft: new Float64Array([0, 0]),
|
||||
paddingTop: new Float64Array([0, 0]),
|
||||
paddingRight: new Float64Array([0, 0]),
|
||||
paddingBottom: new Float64Array([0, 0]),
|
||||
contentLeft: new Float64Array([0, 0]),
|
||||
contentTop: new Float64Array([0, 0]),
|
||||
contentRight: new Float64Array([10, 10]),
|
||||
contentBottom: new Float64Array([10, 10]),
|
||||
},
|
||||
rects: {
|
||||
rectId: new Uint32Array([0, 1]),
|
||||
left: new Float64Array([0, 5]),
|
||||
top: new Float64Array([0, 5]),
|
||||
right: new Float64Array([10, 15]),
|
||||
bottom: new Float64Array([10, 15]),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array([0, 0]),
|
||||
nearestPositionedAncestorOf: new Uint32Array([0, 0]),
|
||||
scrollContainerOf: new Uint32Array([0, 0]),
|
||||
stackingContextOf: new Uint32Array([0, 0]),
|
||||
formattingContextOf: new Uint32Array([0, 0]),
|
||||
clippingRootOf: new Uint32Array([0, 0]),
|
||||
paintOrderBucket: new Uint16Array([0, 0]),
|
||||
paintOrderIndex: new Uint32Array([0, 1]),
|
||||
},
|
||||
scroll: {
|
||||
containerId: new Uint32Array([0]),
|
||||
scrollLeft: new Float64Array([0]),
|
||||
scrollTop: new Float64Array([0]),
|
||||
scrollWidth: new Float64Array([1280]),
|
||||
scrollHeight: new Float64Array([720]),
|
||||
clientWidth: new Float64Array([1280]),
|
||||
clientHeight: new Float64Array([720]),
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: new Uint32Array([0]),
|
||||
subjectId: new Uint32Array([0]),
|
||||
clipKind: new Uint16Array([1]),
|
||||
clipLeft: new Float64Array([0]),
|
||||
clipTop: new Float64Array([0]),
|
||||
clipRight: new Float64Array([1280]),
|
||||
clipBottom: new Float64Array([720]),
|
||||
parentClipNodeId: new Uint32Array([0]),
|
||||
},
|
||||
visibility: {
|
||||
subjectId: new Uint32Array([0, 1]),
|
||||
isRendered: new Uint8Array([1, 1]),
|
||||
isVisible: new Uint8Array([1, 1]),
|
||||
visibleArea: new Float64Array([100, 100]),
|
||||
clippedArea: new Float64Array([0, 0]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateClauses(n: number): Array<{
|
||||
clauseId: string
|
||||
clauseKind: string
|
||||
version: number
|
||||
subjectRef: number
|
||||
referenceRef?: number
|
||||
}> {
|
||||
const kinds = [
|
||||
'relation.leftOf',
|
||||
'relation.above',
|
||||
'relation.overlaps',
|
||||
'relation.inside',
|
||||
]
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
clauseId: `clause-${i}`,
|
||||
clauseKind: kinds[i % kinds.length],
|
||||
version: 1,
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Property runner input domain contracts.
|
||||
*
|
||||
* These types define how render inputs are supplied to a property run:
|
||||
* either as an explicit finite enumeration (determinate) or as a
|
||||
* generated sampled domain (sampled). The mode is always explicit.
|
||||
*
|
||||
* Invariant 6: every InputDomain carries its mode at the type level so
|
||||
* downstream code cannot silently switch from enumerated to generated.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InputDomain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type InputDomain =
|
||||
| { mode: 'enumerated'; values: unknown[] }
|
||||
| { mode: 'generated'; arbitrary: unknown; seed?: number; numRuns?: number }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isEnumeratedDomain(domain: InputDomain): domain is Extract<InputDomain, { mode: 'enumerated' }> {
|
||||
return domain.mode === 'enumerated'
|
||||
}
|
||||
|
||||
export function isGeneratedDomain(domain: InputDomain): domain is Extract<InputDomain, { mode: 'generated' }> {
|
||||
return domain.mode === 'generated'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function enumeratedDomain(values: unknown[]): InputDomain {
|
||||
return { mode: 'enumerated', values }
|
||||
}
|
||||
|
||||
export function generatedDomain(arbitrary: unknown, options?: { seed?: number; numRuns?: number }): InputDomain {
|
||||
return { mode: 'generated', arbitrary, seed: options?.seed, numRuns: options?.numRuns }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Property run result contracts.
|
||||
*
|
||||
* These interfaces materialize Invariant 6: every result must clearly say
|
||||
* whether it is scene-determinate, scene-indeterminate, enumerated-determinate,
|
||||
* or sampled. The mode field is a literal discriminant so switch-exhaustiveness
|
||||
* checking works.
|
||||
*/
|
||||
|
||||
import type { ImhotepId } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SampledRunResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SampledRunResult {
|
||||
mode: 'sampled'
|
||||
seed: number
|
||||
numRuns: number
|
||||
passed: boolean
|
||||
counterexample?: unknown
|
||||
shrunkCounterexample?: unknown
|
||||
failingScene?: unknown
|
||||
diagnostics: unknown[]
|
||||
// P1.3 Property-Run Reporting Upgrade
|
||||
minimalFailingCase?: unknown
|
||||
caseIndex?: number
|
||||
durationMs?: number
|
||||
replayPayload?: { props: unknown; seed: number; caseIndex: number }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnumeratedRunResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EnumeratedRunResult {
|
||||
mode: 'enumerated-determinate'
|
||||
totalCases: number
|
||||
passed: boolean
|
||||
failingCase?: unknown
|
||||
diagnostics: unknown[]
|
||||
// P1.3 Property-Run Reporting Upgrade
|
||||
minimalFailingCase?: unknown
|
||||
caseIndex?: number
|
||||
seed?: number
|
||||
durationMs?: number
|
||||
replayPayload?: { props: unknown; seed: number; caseIndex: number }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene Evaluation Results (for completeness in the taxonomy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DeterministicSceneResult {
|
||||
mode: 'scene-determinate' | 'scene-indeterminate'
|
||||
sceneId: ImhotepId
|
||||
results: unknown[]
|
||||
proofs: unknown[]
|
||||
diagnostics: unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Union type for any property or scene run result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PropertyRunResult =
|
||||
| SampledRunResult
|
||||
| EnumeratedRunResult
|
||||
| DeterministicSceneResult
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isSampledRunResult(result: PropertyRunResult): result is SampledRunResult {
|
||||
return result.mode === 'sampled'
|
||||
}
|
||||
|
||||
export function isEnumeratedRunResult(result: PropertyRunResult): result is EnumeratedRunResult {
|
||||
return result.mode === 'enumerated-determinate'
|
||||
}
|
||||
|
||||
export function isDeterministicSceneResult(result: PropertyRunResult): result is DeterministicSceneResult {
|
||||
return result.mode === 'scene-determinate' || result.mode === 'scene-indeterminate'
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Unit tests for SceneTarget discriminated union, type guards, and matchers.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import type { SceneTarget, RenderCase } from './scene-target.js'
|
||||
|
||||
import {
|
||||
isSceneTarget,
|
||||
isPageTarget,
|
||||
isPlaywrightPageTarget,
|
||||
isStorybookStoryTarget,
|
||||
isReactComponentTarget,
|
||||
isVueComponentTarget,
|
||||
isCustomRendererTarget,
|
||||
isFixtureTarget,
|
||||
isComponentTarget,
|
||||
isRendererTarget,
|
||||
matchSceneTarget,
|
||||
pageTarget,
|
||||
playwrightPageTarget,
|
||||
storybookStoryTarget,
|
||||
reactComponentTarget,
|
||||
vueComponentTarget,
|
||||
customRendererTarget,
|
||||
fixtureTarget,
|
||||
createRenderCase,
|
||||
} from './scene-target.js'
|
||||
|
||||
describe('SceneTarget type guards', () => {
|
||||
it('accepts valid page target', () => {
|
||||
const target = pageTarget('https://example.com')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isPageTarget(target), true)
|
||||
assert.strictEqual(isPlaywrightPageTarget(target), false)
|
||||
assert.strictEqual(isComponentTarget(target), false)
|
||||
assert.strictEqual(isRendererTarget(target), false)
|
||||
})
|
||||
|
||||
it('accepts valid playwright-page target', () => {
|
||||
const target = playwrightPageTarget('page-1', 'https://example.com')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isPlaywrightPageTarget(target), true)
|
||||
assert.strictEqual(isPageTarget(target), false)
|
||||
})
|
||||
|
||||
it('accepts valid storybook-story target', () => {
|
||||
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isStorybookStoryTarget(target), true)
|
||||
assert.strictEqual(isRendererTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), false)
|
||||
})
|
||||
|
||||
it('accepts valid react-component target', () => {
|
||||
const target = reactComponentTarget('react-1', 'Button')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isReactComponentTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), true)
|
||||
assert.strictEqual(isRendererTarget(target), true)
|
||||
})
|
||||
|
||||
it('accepts valid vue-component target', () => {
|
||||
const target = vueComponentTarget('vue-1', 'Button')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isVueComponentTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), true)
|
||||
})
|
||||
|
||||
it('accepts valid custom-renderer target', () => {
|
||||
const target = customRendererTarget('custom-1', 'my-widget')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isCustomRendererTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), true)
|
||||
})
|
||||
|
||||
it('accepts valid fixture target', () => {
|
||||
const target = fixtureTarget('fixtures/button.html')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isFixtureTarget(target), true)
|
||||
assert.strictEqual(isRendererTarget(target), false)
|
||||
})
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
assert.strictEqual(isSceneTarget(null), false)
|
||||
assert.strictEqual(isSceneTarget(undefined), false)
|
||||
assert.strictEqual(isSceneTarget('string'), false)
|
||||
assert.strictEqual(isSceneTarget(42), false)
|
||||
})
|
||||
|
||||
it('rejects objects without valid kind', () => {
|
||||
assert.strictEqual(isSceneTarget({}), false)
|
||||
assert.strictEqual(isSceneTarget({ kind: 'unknown' }), false)
|
||||
})
|
||||
|
||||
it('rejects page target with missing url', () => {
|
||||
assert.strictEqual(isSceneTarget({ kind: 'page' }), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneTarget matchers', () => {
|
||||
it('matches page target to correct branch', () => {
|
||||
const target = pageTarget('https://example.com')
|
||||
const result = matchSceneTarget<string>(target, {
|
||||
page: (url) => `page:${url}`,
|
||||
'playwright-page': (pageRef) => `playwright-page:${pageRef}`,
|
||||
'storybook-story': (storyId) => `storybook-story:${storyId}`,
|
||||
'react-component': (rendererId, componentId) => `react-component:${rendererId}:${componentId}`,
|
||||
'vue-component': (rendererId, componentId) => `vue-component:${rendererId}:${componentId}`,
|
||||
'custom-renderer': (rendererId, targetId) => `custom-renderer:${rendererId}:${targetId}`,
|
||||
fixture: (fixtureId) => `fixture:${fixtureId}`,
|
||||
})
|
||||
assert.strictEqual(result, 'page:https://example.com')
|
||||
})
|
||||
|
||||
it('matches react-component target to correct branch', () => {
|
||||
const target = reactComponentTarget('react-1', 'Button')
|
||||
const result = matchSceneTarget(target, {
|
||||
page: () => 'page',
|
||||
'playwright-page': () => 'playwright-page',
|
||||
'storybook-story': () => 'storybook-story',
|
||||
'react-component': (rendererId, componentId) => `react:${rendererId}:${componentId}`,
|
||||
'vue-component': () => 'vue',
|
||||
'custom-renderer': () => 'custom',
|
||||
fixture: () => 'fixture',
|
||||
})
|
||||
assert.strictEqual(result, 'react:react-1:Button')
|
||||
})
|
||||
|
||||
it('matches all target kinds exhaustively', () => {
|
||||
const targets: SceneTarget[] = [
|
||||
pageTarget('https://a.com'),
|
||||
playwrightPageTarget('p1'),
|
||||
storybookStoryTarget('s1', 'http://sb'),
|
||||
reactComponentTarget('r1', 'C1'),
|
||||
vueComponentTarget('v1', 'C2'),
|
||||
customRendererTarget('c1', 't1'),
|
||||
fixtureTarget('f1'),
|
||||
]
|
||||
|
||||
for (const target of targets) {
|
||||
const result = matchSceneTarget(target, {
|
||||
page: () => 'page',
|
||||
'playwright-page': () => 'playwright-page',
|
||||
'storybook-story': () => 'storybook-story',
|
||||
'react-component': () => 'react-component',
|
||||
'vue-component': () => 'vue-component',
|
||||
'custom-renderer': () => 'custom-renderer',
|
||||
fixture: () => 'fixture',
|
||||
})
|
||||
assert.strictEqual(result, target.kind)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneTarget factory functions', () => {
|
||||
it('pageTarget creates correct shape', () => {
|
||||
const target = pageTarget('https://example.com')
|
||||
assert.deepStrictEqual(target, { kind: 'page', url: 'https://example.com' })
|
||||
})
|
||||
|
||||
it('playwrightPageTarget creates correct shape with optional url', () => {
|
||||
const withUrl = playwrightPageTarget('page-1', 'https://example.com')
|
||||
assert.deepStrictEqual(withUrl, { kind: 'playwright-page', pageRef: 'page-1', url: 'https://example.com' })
|
||||
|
||||
const withoutUrl = playwrightPageTarget('page-1')
|
||||
assert.deepStrictEqual(withoutUrl, { kind: 'playwright-page', pageRef: 'page-1', url: undefined })
|
||||
})
|
||||
|
||||
it('storybookStoryTarget creates correct shape', () => {
|
||||
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
|
||||
assert.deepStrictEqual(target, {
|
||||
kind: 'storybook-story',
|
||||
storyId: 'button--primary',
|
||||
storybookUrl: 'http://localhost:6006',
|
||||
})
|
||||
})
|
||||
|
||||
it('reactComponentTarget creates correct shape', () => {
|
||||
const target = reactComponentTarget('react-dom', 'Button')
|
||||
assert.deepStrictEqual(target, { kind: 'react-component', rendererId: 'react-dom', componentId: 'Button' })
|
||||
})
|
||||
|
||||
it('vueComponentTarget creates correct shape', () => {
|
||||
const target = vueComponentTarget('vue-3', 'Button')
|
||||
assert.deepStrictEqual(target, { kind: 'vue-component', rendererId: 'vue-3', componentId: 'Button' })
|
||||
})
|
||||
|
||||
it('customRendererTarget creates correct shape', () => {
|
||||
const target = customRendererTarget('svelte', 'Widget')
|
||||
assert.deepStrictEqual(target, { kind: 'custom-renderer', rendererId: 'svelte', targetId: 'Widget' })
|
||||
})
|
||||
|
||||
it('fixtureTarget creates correct shape', () => {
|
||||
const target = fixtureTarget('fixtures/card.html')
|
||||
assert.deepStrictEqual(target, { kind: 'fixture', fixtureId: 'fixtures/card.html' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RenderCase', () => {
|
||||
it('createRenderCase builds minimal case', () => {
|
||||
const rc = createRenderCase('case-1', { size: 'md' })
|
||||
assert.strictEqual(rc.caseId, 'case-1')
|
||||
assert.deepStrictEqual(rc.input, { size: 'md' })
|
||||
assert.strictEqual(rc.env, undefined)
|
||||
assert.strictEqual(rc.metadata, undefined)
|
||||
})
|
||||
|
||||
it('createRenderCase includes env and metadata when provided', () => {
|
||||
const rc = createRenderCase('case-2', { disabled: true }, { viewportWidth: 800 }, { run: 3 })
|
||||
assert.strictEqual(rc.caseId, 'case-2')
|
||||
assert.deepStrictEqual(rc.input, { disabled: true })
|
||||
assert.deepStrictEqual(rc.env, { viewportWidth: 800 })
|
||||
assert.deepStrictEqual(rc.metadata, { run: 3 })
|
||||
})
|
||||
|
||||
it('isSceneTarget rejects RenderCase', () => {
|
||||
const rc = createRenderCase('case-1', {})
|
||||
assert.strictEqual(isSceneTarget(rc), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* SceneTarget discriminated union and type guards.
|
||||
*
|
||||
* The scene target is the stage. It must be possible to mount any component,
|
||||
* any story, any page, and treat it as a deterministic scene. The adapter
|
||||
* boundary is the moat that keeps renderer concerns out of the core.
|
||||
*/
|
||||
|
||||
import type { Environment } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SceneTarget Union
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SceneTarget =
|
||||
| { kind: 'page'; url: string }
|
||||
| { kind: 'playwright-page'; pageRef: string; url?: string }
|
||||
| { kind: 'storybook-story'; storyId: string; storybookUrl: string }
|
||||
| { kind: 'react-component'; rendererId: string; componentId: string }
|
||||
| { kind: 'vue-component'; rendererId: string; componentId: string }
|
||||
| { kind: 'custom-renderer'; rendererId: string; targetId: string }
|
||||
| { kind: 'fixture'; fixtureId: string }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderCase Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RenderCase {
|
||||
caseId: string
|
||||
input: unknown
|
||||
env?: Partial<Environment>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isSceneTarget(value: unknown): value is SceneTarget {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const obj = value as Record<string, unknown>
|
||||
if (typeof obj.kind !== 'string') return false
|
||||
switch (obj.kind) {
|
||||
case 'page':
|
||||
return typeof obj.url === 'string'
|
||||
case 'playwright-page':
|
||||
return typeof obj.pageRef === 'string' && (obj.url === undefined || typeof obj.url === 'string')
|
||||
case 'storybook-story':
|
||||
return typeof obj.storyId === 'string' && typeof obj.storybookUrl === 'string'
|
||||
case 'react-component':
|
||||
return typeof obj.rendererId === 'string' && typeof obj.componentId === 'string'
|
||||
case 'vue-component':
|
||||
return typeof obj.rendererId === 'string' && typeof obj.componentId === 'string'
|
||||
case 'custom-renderer':
|
||||
return typeof obj.rendererId === 'string' && typeof obj.targetId === 'string'
|
||||
case 'fixture':
|
||||
return typeof obj.fixtureId === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isPageTarget(target: SceneTarget): target is Extract<SceneTarget, { kind: 'page' }> {
|
||||
return target.kind === 'page'
|
||||
}
|
||||
|
||||
export function isPlaywrightPageTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'playwright-page' }> {
|
||||
return target.kind === 'playwright-page'
|
||||
}
|
||||
|
||||
export function isStorybookStoryTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'storybook-story' }> {
|
||||
return target.kind === 'storybook-story'
|
||||
}
|
||||
|
||||
export function isReactComponentTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'react-component' }> {
|
||||
return target.kind === 'react-component'
|
||||
}
|
||||
|
||||
export function isVueComponentTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'vue-component' }> {
|
||||
return target.kind === 'vue-component'
|
||||
}
|
||||
|
||||
export function isCustomRendererTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'custom-renderer' }> {
|
||||
return target.kind === 'custom-renderer'
|
||||
}
|
||||
|
||||
export function isFixtureTarget(target: SceneTarget): target is Extract<SceneTarget, { kind: 'fixture' }> {
|
||||
return target.kind === 'fixture'
|
||||
}
|
||||
|
||||
export function isComponentTarget(
|
||||
target: SceneTarget
|
||||
): target is
|
||||
| Extract<SceneTarget, { kind: 'react-component' }>
|
||||
| Extract<SceneTarget, { kind: 'vue-component' }>
|
||||
| Extract<SceneTarget, { kind: 'custom-renderer' }> {
|
||||
return target.kind === 'react-component' || target.kind === 'vue-component' || target.kind === 'custom-renderer'
|
||||
}
|
||||
|
||||
export function isRendererTarget(
|
||||
target: SceneTarget
|
||||
): target is
|
||||
| Extract<SceneTarget, { kind: 'react-component' }>
|
||||
| Extract<SceneTarget, { kind: 'vue-component' }>
|
||||
| Extract<SceneTarget, { kind: 'storybook-story' }>
|
||||
| Extract<SceneTarget, { kind: 'custom-renderer' }> {
|
||||
return (
|
||||
target.kind === 'react-component' ||
|
||||
target.kind === 'vue-component' ||
|
||||
target.kind === 'storybook-story' ||
|
||||
target.kind === 'custom-renderer'
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function matchSceneTarget<R>(
|
||||
target: SceneTarget,
|
||||
cases: {
|
||||
page: (url: string) => R
|
||||
'playwright-page': (pageRef: string, url?: string) => R
|
||||
'storybook-story': (storyId: string, storybookUrl: string) => R
|
||||
'react-component': (rendererId: string, componentId: string) => R
|
||||
'vue-component': (rendererId: string, componentId: string) => R
|
||||
'custom-renderer': (rendererId: string, targetId: string) => R
|
||||
fixture: (fixtureId: string) => R
|
||||
}
|
||||
): R {
|
||||
switch (target.kind) {
|
||||
case 'page':
|
||||
return cases.page(target.url)
|
||||
case 'playwright-page':
|
||||
return cases['playwright-page'](target.pageRef, target.url)
|
||||
case 'storybook-story':
|
||||
return cases['storybook-story'](target.storyId, target.storybookUrl)
|
||||
case 'react-component':
|
||||
return cases['react-component'](target.rendererId, target.componentId)
|
||||
case 'vue-component':
|
||||
return cases['vue-component'](target.rendererId, target.componentId)
|
||||
case 'custom-renderer':
|
||||
return cases['custom-renderer'](target.rendererId, target.targetId)
|
||||
case 'fixture':
|
||||
return cases.fixture(target.fixtureId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function pageTarget(url: string): SceneTarget {
|
||||
return { kind: 'page', url }
|
||||
}
|
||||
|
||||
export function playwrightPageTarget(pageRef: string, url?: string): SceneTarget {
|
||||
return { kind: 'playwright-page', pageRef, url }
|
||||
}
|
||||
|
||||
export function storybookStoryTarget(storyId: string, storybookUrl: string): SceneTarget {
|
||||
return { kind: 'storybook-story', storyId, storybookUrl }
|
||||
}
|
||||
|
||||
export function reactComponentTarget(rendererId: string, componentId: string): SceneTarget {
|
||||
return { kind: 'react-component', rendererId, componentId }
|
||||
}
|
||||
|
||||
export function vueComponentTarget(rendererId: string, componentId: string): SceneTarget {
|
||||
return { kind: 'vue-component', rendererId, componentId }
|
||||
}
|
||||
|
||||
export function customRendererTarget(rendererId: string, targetId: string): SceneTarget {
|
||||
return { kind: 'custom-renderer', rendererId, targetId }
|
||||
}
|
||||
|
||||
export function fixtureTarget(fixtureId: string): SceneTarget {
|
||||
return { kind: 'fixture', fixtureId }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderCase Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createRenderCase(
|
||||
caseId: string,
|
||||
input: unknown,
|
||||
env?: Partial<Environment>,
|
||||
metadata?: Record<string, unknown>
|
||||
): RenderCase {
|
||||
return { caseId, input, env, metadata }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import type {
|
||||
Point,
|
||||
Position,
|
||||
LengthLiteral,
|
||||
ClauseStatus,
|
||||
TruthValue,
|
||||
ClauseResult,
|
||||
Proof,
|
||||
} from './types.js'
|
||||
|
||||
import type {
|
||||
DiagnosticCategory,
|
||||
DiagnosticCode,
|
||||
} from './diagnostics.js'
|
||||
|
||||
import {
|
||||
isAstNode,
|
||||
isParentNode,
|
||||
isProgram,
|
||||
isRelationAssertion,
|
||||
isCompoundAssertion,
|
||||
type Program,
|
||||
type RelationAssertion,
|
||||
type CompoundAssertion,
|
||||
} from './ast.js'
|
||||
|
||||
import { createEmptySemanticIr } from './ir.js'
|
||||
import { createEmptyWorld } from './world.js'
|
||||
import { createDiagnostic, isContractFailure, isSystemUseError } from './diagnostics.js'
|
||||
|
||||
describe('Core Types', () => {
|
||||
it('constructs a valid Point', () => {
|
||||
const p: Point = { line: 1, column: 1, offset: 0 }
|
||||
assert.strictEqual(p.line, 1)
|
||||
assert.strictEqual(p.column, 1)
|
||||
assert.strictEqual(p.offset, 0)
|
||||
})
|
||||
|
||||
it('constructs a valid Position', () => {
|
||||
const pos: Position = {
|
||||
start: { line: 2, column: 5, offset: 17 },
|
||||
end: { line: 2, column: 51, offset: 63 },
|
||||
}
|
||||
assert.strictEqual(pos.start.line, 2)
|
||||
assert.strictEqual(pos.end.offset, 63)
|
||||
})
|
||||
|
||||
it('constructs a valid LengthLiteral', () => {
|
||||
const len: LengthLiteral = { value: 16, unit: 'px' }
|
||||
assert.strictEqual(len.value, 16)
|
||||
assert.strictEqual(len.unit, 'px')
|
||||
})
|
||||
|
||||
it('narrows ClauseStatus discriminated union', () => {
|
||||
const statuses: ClauseStatus[] = ['pass', 'fail', 'error', 'skipped']
|
||||
for (const s of statuses) {
|
||||
assert.ok(['pass', 'fail', 'error', 'skipped'].includes(s))
|
||||
}
|
||||
})
|
||||
|
||||
it('narrows TruthValue discriminated union', () => {
|
||||
const truths: TruthValue[] = ['determinate', 'indeterminate', 'approximate']
|
||||
for (const t of truths) {
|
||||
assert.ok(['determinate', 'indeterminate', 'approximate'].includes(t))
|
||||
}
|
||||
})
|
||||
|
||||
it('constructs a valid ClauseResult', () => {
|
||||
const result: ClauseResult = {
|
||||
clauseId: 'clause_42',
|
||||
status: 'fail',
|
||||
truth: 'determinate',
|
||||
metrics: { observedGap: 11, minGap: 16, maxGap: 20 },
|
||||
witness: { subjectId: 2, referenceId: 9, frameId: 1 },
|
||||
diagnostics: [],
|
||||
}
|
||||
assert.strictEqual(result.status, 'fail')
|
||||
assert.strictEqual(result.truth, 'determinate')
|
||||
assert.strictEqual(result.metrics.observedGap, 11)
|
||||
})
|
||||
|
||||
it('constructs a valid Proof', () => {
|
||||
const proof: Proof = {
|
||||
proofId: 'proof_99',
|
||||
clauseId: 'clause_42',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
usedFacts: [102, 103, 220],
|
||||
derivedFacts: [501, 502],
|
||||
failedPredicate: { op: '>=', left: 11, right: 24 },
|
||||
witness: {
|
||||
subjectId: 5,
|
||||
referenceId: 8,
|
||||
envCaseId: 'env_3',
|
||||
snapshotId: 'hover',
|
||||
},
|
||||
}
|
||||
assert.strictEqual(proof.usedFacts.length, 3)
|
||||
assert.strictEqual(proof.failedPredicate?.op, '>=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AST Type Guards', () => {
|
||||
it('identifies valid AstNode', () => {
|
||||
assert.strictEqual(isAstNode({ type: 'Program' }), true)
|
||||
assert.strictEqual(isAstNode(null), false)
|
||||
assert.strictEqual(isAstNode({}), false)
|
||||
})
|
||||
|
||||
it('identifies ParentNode via children property', () => {
|
||||
const program: Program = {
|
||||
type: 'Program',
|
||||
children: [],
|
||||
}
|
||||
assert.strictEqual(isParentNode(program), true)
|
||||
|
||||
const relation: RelationAssertion = {
|
||||
type: 'RelationAssertion',
|
||||
relation: 'leftOf',
|
||||
subject: { type: 'SelectorRef', value: '.a' },
|
||||
reference: { type: 'SelectorRef', value: '.b' },
|
||||
options: { type: 'RelationOptions' },
|
||||
}
|
||||
assert.strictEqual(isParentNode(relation), false)
|
||||
})
|
||||
|
||||
it('narrows Program type', () => {
|
||||
const node = { type: 'Program', children: [] } as const
|
||||
assert.strictEqual(isProgram(node), true)
|
||||
assert.strictEqual(isRelationAssertion(node), false)
|
||||
})
|
||||
|
||||
it('narrows RelationAssertion type', () => {
|
||||
const node: RelationAssertion = {
|
||||
type: 'RelationAssertion',
|
||||
relation: 'leftOf',
|
||||
subject: { type: 'SelectorRef', value: '.a' },
|
||||
reference: { type: 'SelectorRef', value: '.b' },
|
||||
options: { type: 'RelationOptions' },
|
||||
}
|
||||
assert.strictEqual(isRelationAssertion(node), true)
|
||||
assert.strictEqual(isProgram(node), false)
|
||||
})
|
||||
|
||||
it('narrows CompoundAssertion type', () => {
|
||||
const node: CompoundAssertion = {
|
||||
type: 'CompoundAssertion',
|
||||
quantifier: { type: 'Quantifier', kind: 'all' },
|
||||
children: [],
|
||||
}
|
||||
assert.strictEqual(isCompoundAssertion(node), true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Semantic IR', () => {
|
||||
it('creates empty Semantic IR with all tables', () => {
|
||||
const ir = createEmptySemanticIr()
|
||||
assert.ok(ir.subjects instanceof Map)
|
||||
assert.ok(ir.frames instanceof Map)
|
||||
assert.ok(ir.clauses instanceof Map)
|
||||
assert.ok(ir.groups instanceof Map)
|
||||
assert.ok(ir.tolerances instanceof Map)
|
||||
assert.ok(ir.envGuards instanceof Map)
|
||||
assert.ok(ir.states instanceof Map)
|
||||
assert.ok(ir.timelines instanceof Map)
|
||||
assert.ok(ir.diagnosticMetadata instanceof Map)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Geometry World', () => {
|
||||
it('creates empty world with correct scene/snapshot ids', () => {
|
||||
const world = createEmptyWorld('scene_1', 'snapshot_default')
|
||||
assert.strictEqual(world.sceneId, 'scene_1')
|
||||
assert.strictEqual(world.snapshotId, 'snapshot_default')
|
||||
assert.strictEqual(world.env.viewportWidth, 0)
|
||||
assert.ok(world.subjects.ids instanceof Uint32Array)
|
||||
assert.ok(world.boxes.boxId instanceof Uint32Array)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Diagnostics', () => {
|
||||
it('creates a diagnostic with correct category', () => {
|
||||
const d = createDiagnostic(
|
||||
'IMH_RELATION_LEFT_OF_FAILED',
|
||||
'contract-failure',
|
||||
'error',
|
||||
'Expected .gallery to be left of .details.',
|
||||
)
|
||||
assert.strictEqual(d.code, 'IMH_RELATION_LEFT_OF_FAILED')
|
||||
assert.strictEqual(d.category, 'contract-failure')
|
||||
assert.strictEqual(d.severity, 'error')
|
||||
assert.strictEqual(isContractFailure(d), true)
|
||||
assert.strictEqual(isSystemUseError(d), false)
|
||||
})
|
||||
|
||||
it('classifies system use errors correctly', () => {
|
||||
const parseErr = createDiagnostic(
|
||||
'IMH_PARSE_INVALID_SYNTAX',
|
||||
'parse-error',
|
||||
'error',
|
||||
'Unexpected token',
|
||||
)
|
||||
assert.strictEqual(isSystemUseError(parseErr), true)
|
||||
assert.strictEqual(isContractFailure(parseErr), false)
|
||||
|
||||
const resolutionErr = createDiagnostic(
|
||||
'IMH_SELECTOR_ZERO_MATCHES',
|
||||
'resolution-error',
|
||||
'error',
|
||||
'Selector matched zero elements.',
|
||||
)
|
||||
assert.strictEqual(isSystemUseError(resolutionErr), true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Core type definitions for Imhotep.
|
||||
*
|
||||
* Imhotep uses three internal representations:
|
||||
* 1. Authoring AST — rich tree, unist-style, source-positioned
|
||||
* 2. Semantic IR — normalized graph with explicit obligations
|
||||
* 3. Execution IR — flattened, data-oriented arrays for speed
|
||||
*
|
||||
* This file holds the foundational types shared across all stages.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Position and Source Origin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Point {
|
||||
/** 1-based line number in the source text */
|
||||
line: number
|
||||
/** 1-based column number in the source text */
|
||||
column: number
|
||||
/** 0-based character offset in the source text */
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
start: Point
|
||||
end: Point
|
||||
}
|
||||
|
||||
export interface SourceOrigin {
|
||||
/** AST node ids that contributed to this IR node */
|
||||
astNodeIds: string[]
|
||||
/** Source spans traceable back to authoring syntax */
|
||||
positions: Position[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifier and Reference Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImhotepId = string
|
||||
|
||||
export interface IdRef {
|
||||
id: ImhotepId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Literal Value Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LengthUnit = 'px' | 'em' | 'rem' | 'ch' | 'jnd'
|
||||
|
||||
export interface LengthLiteral {
|
||||
value: number
|
||||
unit: LengthUnit
|
||||
}
|
||||
|
||||
export interface RangeLiteral {
|
||||
min: LengthLiteral
|
||||
max: LengthLiteral
|
||||
}
|
||||
|
||||
export interface ToleranceLiteral {
|
||||
value: number
|
||||
unit: 'jnd' | 'px'
|
||||
}
|
||||
|
||||
export type LiteralValue =
|
||||
| { kind: 'length'; value: number; unit: LengthUnit }
|
||||
| { kind: 'range'; min: LengthLiteral; max: LengthLiteral }
|
||||
| { kind: 'tolerance'; value: number; unit: 'jnd' | 'px' }
|
||||
| { kind: 'string'; value: string }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment and Snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ColorScheme = 'light' | 'dark'
|
||||
export type PointerType = 'coarse' | 'fine' | 'none'
|
||||
export type ReducedMotion = 'reduce' | 'no-preference'
|
||||
export type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'
|
||||
|
||||
export interface Environment {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: ColorScheme
|
||||
pointer: PointerType
|
||||
hover: boolean
|
||||
reducedMotion: ReducedMotion
|
||||
locale: string
|
||||
writingMode: WritingMode
|
||||
}
|
||||
|
||||
export interface EnvCase {
|
||||
id: ImhotepId
|
||||
env: Environment
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State and Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StateKind =
|
||||
| 'default'
|
||||
| 'hover'
|
||||
| 'focus'
|
||||
| 'focusVisible'
|
||||
| 'active'
|
||||
| 'disabled'
|
||||
| 'checked'
|
||||
| 'expanded'
|
||||
| 'collapsed'
|
||||
| 'selected'
|
||||
| 'pressed'
|
||||
| 'visited'
|
||||
| 'custom'
|
||||
|
||||
export interface StateSnapshot {
|
||||
id: ImhotepId
|
||||
kind: StateKind
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type TimelineMode = 'static' | 'keyframes' | 'range' | 'transition'
|
||||
|
||||
export interface TimelineSnapshot {
|
||||
id: ImhotepId
|
||||
mode: TimelineMode
|
||||
samples?: number[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FrameKind =
|
||||
| 'viewport'
|
||||
| 'document'
|
||||
| 'element'
|
||||
| 'nearestPositionedAncestor'
|
||||
| 'containingBlock'
|
||||
| 'scrollContainer'
|
||||
| 'namedGridArea'
|
||||
| 'landmark'
|
||||
| 'stackingContextRoot'
|
||||
| 'custom'
|
||||
|
||||
export interface Frame {
|
||||
id: ImhotepId
|
||||
kind: FrameKind
|
||||
selector?: string
|
||||
name?: string
|
||||
originX: number
|
||||
originY: number
|
||||
writingMode: WritingMode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subject Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SubjectKind =
|
||||
| 'element'
|
||||
| 'pseudoElement'
|
||||
| 'textRange'
|
||||
| 'lineBox'
|
||||
| 'fragmentBox'
|
||||
| 'landmark'
|
||||
| 'gridArea'
|
||||
| 'scrollContainer'
|
||||
| 'syntheticFrame'
|
||||
|
||||
export interface Subject {
|
||||
id: ImhotepId
|
||||
selector: string
|
||||
kind: SubjectKind
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion and Quantifier Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RelationName =
|
||||
| 'leftOf'
|
||||
| 'rightOf'
|
||||
| 'above'
|
||||
| 'below'
|
||||
| 'alignedWith'
|
||||
| 'leftAlignedWith'
|
||||
| 'rightAlignedWith'
|
||||
| 'topAlignedWith'
|
||||
| 'bottomAlignedWith'
|
||||
| 'centeredWithin'
|
||||
| 'inside'
|
||||
| 'contains'
|
||||
| 'overlaps'
|
||||
| 'intersects'
|
||||
| 'touches'
|
||||
| 'separatedFrom'
|
||||
| 'hasGap'
|
||||
|
||||
export type SizeProperty =
|
||||
| 'width'
|
||||
| 'height'
|
||||
| 'minWidth'
|
||||
| 'minHeight'
|
||||
| 'maxWidth'
|
||||
| 'maxHeight'
|
||||
|
||||
export type QuantifierKind =
|
||||
| 'all'
|
||||
| 'any'
|
||||
| 'none'
|
||||
| 'exactly'
|
||||
| 'atLeast'
|
||||
| 'atMost'
|
||||
| 'ordered'
|
||||
| 'pairwise'
|
||||
|
||||
export interface Quantifier {
|
||||
kind: QuantifierKind
|
||||
count?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clause Result and Truth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClauseStatus = 'pass' | 'fail' | 'error' | 'skipped'
|
||||
export type TruthValue = 'determinate' | 'indeterminate' | 'approximate'
|
||||
|
||||
export interface ClauseMetrics {
|
||||
[key: string]: number | LengthLiteral | undefined
|
||||
}
|
||||
|
||||
export interface ClauseWitness {
|
||||
subjectId: number
|
||||
referenceId?: number
|
||||
frameId: number
|
||||
envCaseId?: ImhotepId
|
||||
snapshotId?: ImhotepId
|
||||
}
|
||||
|
||||
export interface SourceReference {
|
||||
/** 0-based index of the assertion in a fluent batch */
|
||||
fluentIndex?: number
|
||||
/** 1-based line number in the dense spec string */
|
||||
specLine?: number
|
||||
/** 1-based column number in the dense spec string */
|
||||
specColumn?: number
|
||||
}
|
||||
|
||||
export interface ClauseResult {
|
||||
clauseId: ImhotepId
|
||||
status: ClauseStatus
|
||||
truth: TruthValue
|
||||
metrics: ClauseMetrics
|
||||
witness: ClauseWitness
|
||||
diagnostics: ImhotepId[]
|
||||
/** Traceability metadata linking this result to its authored source */
|
||||
sourceRef?: SourceReference
|
||||
/** Human-readable label derived from the normalized contract */
|
||||
clauseLabel?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GroupOperator = 'and' | 'or' | 'not' | 'quantifier'
|
||||
|
||||
export interface GroupResult {
|
||||
groupId: ImhotepId
|
||||
operator: GroupOperator
|
||||
clauseResults: ImhotepId[]
|
||||
groupResults?: ImhotepId[]
|
||||
status: ClauseStatus
|
||||
truth: TruthValue
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof and Determination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FailedPredicate {
|
||||
op: string
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
export interface Proof {
|
||||
proofId: ImhotepId
|
||||
clauseId: ImhotepId
|
||||
outcome: ClauseStatus
|
||||
truth: TruthValue
|
||||
usedFacts: number[]
|
||||
derivedFacts: number[]
|
||||
failedPredicate?: FailedPredicate
|
||||
witness: {
|
||||
subjectId: number
|
||||
referenceId?: number
|
||||
envCaseId: ImhotepId
|
||||
snapshotId: ImhotepId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Geometry world schema types for Imhotep.
|
||||
*
|
||||
* The geometry world is the extracted and normalized scene model.
|
||||
* It is immutable per snapshot, indexed by stable numeric ids for speed,
|
||||
* and explicit about confidence and provenance.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, Environment } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate Space
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coordinate space distinguishes between layout-local and visual-viewport
|
||||
* coordinates. Layout coordinates are pre-transform (the local box model
|
||||
* as computed by the browser). Visual coordinates are post-transform
|
||||
* (the effective position after all CSS transforms are applied).
|
||||
*
|
||||
* Playwright boundingBox() returns visual coordinates.
|
||||
* CDP DOM.getBoxModel returns layout coordinates.
|
||||
*/
|
||||
export enum CoordinateSpace {
|
||||
LAYOUT = 0, // Local layout coordinates (pre-transform)
|
||||
VISUAL = 1, // Visual viewport coordinates (post-transform)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-Level World
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GeometryWorld {
|
||||
sceneId: ImhotepId
|
||||
snapshotId: ImhotepId
|
||||
env: WorldEnvironment
|
||||
source: WorldSource
|
||||
strings: StringTable
|
||||
subjects: SubjectsTable
|
||||
dom: DomTable
|
||||
frames: FramesTable
|
||||
matrices: MatricesTable
|
||||
rects: RectsTable
|
||||
boxes: BoxesTable // Layout-space box geometry (CDP box model, pre-transform)
|
||||
visualBoxes: BoxesTable // Visual-space box geometry (post-transform, viewport-relative)
|
||||
fragments: FragmentsTable
|
||||
transforms: TransformsTable
|
||||
styles: StylesTable
|
||||
text: TextTable
|
||||
topology: TopologyTable
|
||||
scroll: ScrollTable
|
||||
clipping: ClippingTable
|
||||
paint: PaintTable
|
||||
visibility: VisibilityTable
|
||||
provenance: ProvenanceTable
|
||||
confidence: ConfidenceTable
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment and Source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldEnvironment {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: string
|
||||
pointer: string
|
||||
hover: boolean
|
||||
reducedMotion: string
|
||||
locale: string
|
||||
writingMode: string
|
||||
}
|
||||
|
||||
export interface WorldSource {
|
||||
url: string
|
||||
browserName: string
|
||||
browserVersion: string
|
||||
engine: 'chromium-cdp'
|
||||
extractedAt: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StringTable {
|
||||
values: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subjects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SubjectsTable {
|
||||
ids: Uint32Array
|
||||
domNodeId: Uint32Array
|
||||
subjectKind: Uint16Array
|
||||
primaryBoxId: Uint32Array
|
||||
firstFragmentId: Uint32Array
|
||||
fragmentCount: Uint16Array
|
||||
firstTextRunId: Uint32Array
|
||||
textRunCount: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomTable {
|
||||
nodeId: Uint32Array
|
||||
backendNodeId: Uint32Array
|
||||
parentNodeId: Uint32Array
|
||||
firstChildIndex: Uint32Array
|
||||
childCount: Uint16Array
|
||||
shadowRootKind: Uint8Array
|
||||
tagNameStringId: Uint32Array
|
||||
roleStringId: Uint32Array
|
||||
ariaNameStringId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FrameKind {
|
||||
Viewport = 1,
|
||||
ContainingBlock = 2,
|
||||
NearestPositionedAncestor = 3,
|
||||
ScrollContainer = 4,
|
||||
Synthetic = 5,
|
||||
}
|
||||
|
||||
export interface FramesTable {
|
||||
frameId: Uint32Array
|
||||
frameKind: Uint16Array
|
||||
ownerSubjectId: Uint32Array
|
||||
parentFrameId: Uint32Array
|
||||
originX: Float64Array
|
||||
originY: Float64Array
|
||||
axisMatrixStart: Uint32Array
|
||||
clipRectId: Uint32Array
|
||||
scrollContainerId: Uint32Array
|
||||
writingMode: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MatricesTable {
|
||||
values: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RectsTable {
|
||||
rectId: Uint32Array
|
||||
left: Float64Array
|
||||
top: Float64Array
|
||||
right: Float64Array
|
||||
bottom: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BoxesTable {
|
||||
boxId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
frameId: Uint32Array
|
||||
borderLeft: Float64Array
|
||||
borderTop: Float64Array
|
||||
borderRight: Float64Array
|
||||
borderBottom: Float64Array
|
||||
paddingLeft: Float64Array
|
||||
paddingTop: Float64Array
|
||||
paddingRight: Float64Array
|
||||
paddingBottom: Float64Array
|
||||
contentLeft: Float64Array
|
||||
contentTop: Float64Array
|
||||
contentRight: Float64Array
|
||||
contentBottom: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fragments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FragmentKind {
|
||||
Line = 1,
|
||||
Column = 2,
|
||||
Region = 3,
|
||||
Page = 4,
|
||||
}
|
||||
|
||||
export interface FragmentsTable {
|
||||
fragmentId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
fragmentKind: Uint16Array
|
||||
boxLeft: Float64Array
|
||||
boxTop: Float64Array
|
||||
boxRight: Float64Array
|
||||
boxBottom: Float64Array
|
||||
lineIndex: Int32Array
|
||||
flowIndex: Int32Array
|
||||
parentFragmentId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransformsTable {
|
||||
transformId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
matrixStart: Uint32Array
|
||||
matrixLength: Uint16Array
|
||||
originX: Float64Array
|
||||
originY: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StylesTable {
|
||||
subjectId: Uint32Array
|
||||
display: Uint16Array
|
||||
position: Uint16Array
|
||||
zIndexKind: Uint8Array
|
||||
zIndexValue: Int32Array
|
||||
overflowX: Uint16Array
|
||||
overflowY: Uint16Array
|
||||
opacity: Float32Array
|
||||
visibility: Uint16Array
|
||||
containFlags: Uint32Array
|
||||
pointerEvents: Uint16Array
|
||||
lineHeight: Float64Array
|
||||
fontFamilyStringId: Uint32Array
|
||||
fontSize: Float64Array
|
||||
fontWeight: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TextTable {
|
||||
runId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
contentStringId: Uint32Array
|
||||
lineBoxId: Uint32Array
|
||||
inkLeft: Float64Array
|
||||
inkTop: Float64Array
|
||||
inkRight: Float64Array
|
||||
inkBottom: Float64Array
|
||||
baselineY: Float64Array
|
||||
capHeight: Float64Array
|
||||
computedLineHeight: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topology
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TopologyTable {
|
||||
containingBlockOf: Uint32Array
|
||||
nearestPositionedAncestorOf: Uint32Array
|
||||
scrollContainerOf: Uint32Array
|
||||
stackingContextOf: Uint32Array
|
||||
formattingContextOf: Uint32Array
|
||||
clippingRootOf: Uint32Array
|
||||
paintOrderBucket: Uint16Array
|
||||
paintOrderIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScrollTable {
|
||||
containerId: Uint32Array
|
||||
scrollLeft: Float64Array
|
||||
scrollTop: Float64Array
|
||||
scrollWidth: Float64Array
|
||||
scrollHeight: Float64Array
|
||||
clientWidth: Float64Array
|
||||
clientHeight: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClippingTable {
|
||||
clipNodeId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
clipKind: Uint16Array
|
||||
clipLeft: Float64Array
|
||||
clipTop: Float64Array
|
||||
clipRight: Float64Array
|
||||
clipBottom: Float64Array
|
||||
parentClipNodeId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaintTable {
|
||||
paintNodeId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
stackingContextId: Uint32Array
|
||||
bucket: Uint16Array
|
||||
localPaintIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VisibilityTable {
|
||||
subjectId: Uint32Array
|
||||
isRendered: Uint8Array
|
||||
isVisible: Uint8Array
|
||||
visibleArea: Float64Array
|
||||
clippedArea: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provenance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProvenanceTable {
|
||||
factId: Uint32Array
|
||||
extractionStepId: Uint32Array
|
||||
sourceKind: Uint16Array
|
||||
sourceRef: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confidence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConfidenceTable {
|
||||
factId: Uint32Array
|
||||
confidence: Float32Array
|
||||
reasonCode: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fact Request Plan (fed to extractors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FactRequestPlan {
|
||||
geometry: boolean
|
||||
fragments: boolean
|
||||
styles: string[] | boolean
|
||||
topology: string[] | boolean
|
||||
text: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Create empty world shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyWorld(sceneId: ImhotepId, snapshotId: ImhotepId): GeometryWorld {
|
||||
return {
|
||||
sceneId,
|
||||
snapshotId,
|
||||
env: {
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: 'no-preference',
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: '',
|
||||
browserName: '',
|
||||
browserVersion: '',
|
||||
engine: 'chromium-cdp',
|
||||
extractedAt: 0,
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(0),
|
||||
domNodeId: new Uint32Array(0),
|
||||
subjectKind: new Uint16Array(0),
|
||||
primaryBoxId: new Uint32Array(0),
|
||||
firstFragmentId: new Uint32Array(0),
|
||||
fragmentCount: new Uint16Array(0),
|
||||
firstTextRunId: new Uint32Array(0),
|
||||
textRunCount: new Uint16Array(0),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(0),
|
||||
backendNodeId: new Uint32Array(0),
|
||||
parentNodeId: new Uint32Array(0),
|
||||
firstChildIndex: new Uint32Array(0),
|
||||
childCount: new Uint16Array(0),
|
||||
shadowRootKind: new Uint8Array(0),
|
||||
tagNameStringId: new Uint32Array(0),
|
||||
roleStringId: new Uint32Array(0),
|
||||
ariaNameStringId: new Uint32Array(0),
|
||||
},
|
||||
frames: {
|
||||
frameId: new Uint32Array(0),
|
||||
frameKind: new Uint16Array(0),
|
||||
ownerSubjectId: new Uint32Array(0),
|
||||
parentFrameId: new Uint32Array(0),
|
||||
originX: new Float64Array(0),
|
||||
originY: new Float64Array(0),
|
||||
axisMatrixStart: new Uint32Array(0),
|
||||
clipRectId: new Uint32Array(0),
|
||||
scrollContainerId: new Uint32Array(0),
|
||||
writingMode: new Uint16Array(0),
|
||||
},
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: {
|
||||
rectId: new Uint32Array(0),
|
||||
left: new Float64Array(0),
|
||||
top: new Float64Array(0),
|
||||
right: new Float64Array(0),
|
||||
bottom: new Float64Array(0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
frameId: new Uint32Array(0),
|
||||
borderLeft: new Float64Array(0),
|
||||
borderTop: new Float64Array(0),
|
||||
borderRight: new Float64Array(0),
|
||||
borderBottom: new Float64Array(0),
|
||||
paddingLeft: new Float64Array(0),
|
||||
paddingTop: new Float64Array(0),
|
||||
paddingRight: new Float64Array(0),
|
||||
paddingBottom: new Float64Array(0),
|
||||
contentLeft: new Float64Array(0),
|
||||
contentTop: new Float64Array(0),
|
||||
contentRight: new Float64Array(0),
|
||||
contentBottom: new Float64Array(0),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
frameId: new Uint32Array(0),
|
||||
borderLeft: new Float64Array(0),
|
||||
borderTop: new Float64Array(0),
|
||||
borderRight: new Float64Array(0),
|
||||
borderBottom: new Float64Array(0),
|
||||
paddingLeft: new Float64Array(0),
|
||||
paddingTop: new Float64Array(0),
|
||||
paddingRight: new Float64Array(0),
|
||||
paddingBottom: new Float64Array(0),
|
||||
contentLeft: new Float64Array(0),
|
||||
contentTop: new Float64Array(0),
|
||||
contentRight: new Float64Array(0),
|
||||
contentBottom: new Float64Array(0),
|
||||
},
|
||||
fragments: {
|
||||
fragmentId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
fragmentKind: new Uint16Array(0),
|
||||
boxLeft: new Float64Array(0),
|
||||
boxTop: new Float64Array(0),
|
||||
boxRight: new Float64Array(0),
|
||||
boxBottom: new Float64Array(0),
|
||||
lineIndex: new Int32Array(0),
|
||||
flowIndex: new Int32Array(0),
|
||||
parentFragmentId: new Uint32Array(0),
|
||||
},
|
||||
transforms: {
|
||||
transformId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
matrixStart: new Uint32Array(0),
|
||||
matrixLength: new Uint16Array(0),
|
||||
originX: new Float64Array(0),
|
||||
originY: new Float64Array(0),
|
||||
},
|
||||
styles: {
|
||||
subjectId: new Uint32Array(0),
|
||||
display: new Uint16Array(0),
|
||||
position: new Uint16Array(0),
|
||||
zIndexKind: new Uint8Array(0),
|
||||
zIndexValue: new Int32Array(0),
|
||||
overflowX: new Uint16Array(0),
|
||||
overflowY: new Uint16Array(0),
|
||||
opacity: new Float32Array(0),
|
||||
visibility: new Uint16Array(0),
|
||||
containFlags: new Uint32Array(0),
|
||||
pointerEvents: new Uint16Array(0),
|
||||
lineHeight: new Float64Array(0),
|
||||
fontFamilyStringId: new Uint32Array(0),
|
||||
fontSize: new Float64Array(0),
|
||||
fontWeight: new Uint16Array(0),
|
||||
},
|
||||
text: {
|
||||
runId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
contentStringId: new Uint32Array(0),
|
||||
lineBoxId: new Uint32Array(0),
|
||||
inkLeft: new Float64Array(0),
|
||||
inkTop: new Float64Array(0),
|
||||
inkRight: new Float64Array(0),
|
||||
inkBottom: new Float64Array(0),
|
||||
baselineY: new Float64Array(0),
|
||||
capHeight: new Float64Array(0),
|
||||
computedLineHeight: new Float64Array(0),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(0),
|
||||
nearestPositionedAncestorOf: new Uint32Array(0),
|
||||
scrollContainerOf: new Uint32Array(0),
|
||||
stackingContextOf: new Uint32Array(0),
|
||||
formattingContextOf: new Uint32Array(0),
|
||||
clippingRootOf: new Uint32Array(0),
|
||||
paintOrderBucket: new Uint16Array(0),
|
||||
paintOrderIndex: new Uint32Array(0),
|
||||
},
|
||||
scroll: {
|
||||
containerId: new Uint32Array(0),
|
||||
scrollLeft: new Float64Array(0),
|
||||
scrollTop: new Float64Array(0),
|
||||
scrollWidth: new Float64Array(0),
|
||||
scrollHeight: new Float64Array(0),
|
||||
clientWidth: new Float64Array(0),
|
||||
clientHeight: new Float64Array(0),
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
clipKind: new Uint16Array(0),
|
||||
clipLeft: new Float64Array(0),
|
||||
clipTop: new Float64Array(0),
|
||||
clipRight: new Float64Array(0),
|
||||
clipBottom: new Float64Array(0),
|
||||
parentClipNodeId: new Uint32Array(0),
|
||||
},
|
||||
paint: {
|
||||
paintNodeId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
stackingContextId: new Uint32Array(0),
|
||||
bucket: new Uint16Array(0),
|
||||
localPaintIndex: new Uint32Array(0),
|
||||
},
|
||||
visibility: {
|
||||
subjectId: new Uint32Array(0),
|
||||
isRendered: new Uint8Array(0),
|
||||
isVisible: new Uint8Array(0),
|
||||
visibleArea: new Float64Array(0),
|
||||
clippedArea: new Float64Array(0),
|
||||
},
|
||||
provenance: {
|
||||
factId: new Uint32Array(0),
|
||||
extractionStepId: new Uint32Array(0),
|
||||
sourceKind: new Uint16Array(0),
|
||||
sourceRef: new Uint32Array(0),
|
||||
},
|
||||
confidence: {
|
||||
factId: new Uint32Array(0),
|
||||
confidence: new Float32Array(0),
|
||||
reasonCode: new Uint16Array(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {},
|
||||
"composite": false,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/integration-*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-test",
|
||||
"rootDir": "./src",
|
||||
"paths": {},
|
||||
"composite": false,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "imhotep-dsl",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"imhotep-core": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
// Compiler: lowers AST -> Semantic IR -> Execution IR
|
||||
// Pure function, no browser dependencies
|
||||
|
||||
import type {
|
||||
RelationAssertion,
|
||||
SemanticIr,
|
||||
ExecutionIr,
|
||||
SemanticClause,
|
||||
SemanticSubject,
|
||||
SemanticFrame,
|
||||
SemanticState,
|
||||
SemanticTimeline,
|
||||
SemanticTolerance,
|
||||
SemanticEnvGuard,
|
||||
Diagnostic,
|
||||
Position,
|
||||
ImhotepId,
|
||||
FormulaNode,
|
||||
VariableRef,
|
||||
DomainRef,
|
||||
TupleBinding,
|
||||
PredicateCall,
|
||||
AssertionNode,
|
||||
SizeAssertion,
|
||||
TopologyAssertion,
|
||||
CompoundAssertion,
|
||||
SelectorRef,
|
||||
} from 'imhotep-core'
|
||||
|
||||
import { createEmptySemanticIr, getDefaultContext } from 'imhotep-core'
|
||||
import { parseTolerance, parseGap } from './validator.js'
|
||||
|
||||
// DSL grammar FOL types (distinct from solver FormulaNode imported above)
|
||||
import type {
|
||||
FormulaNode as DslFormulaNode,
|
||||
ForAllFormula as DslForAll,
|
||||
ExistsFormula as DslExists,
|
||||
AndFormula as DslAnd,
|
||||
OrFormula as DslOr,
|
||||
NotFormula as DslNot,
|
||||
ImpliesFormula as DslImplies,
|
||||
PredicateCall as DslPredicateCall,
|
||||
VariableRef as DslVariableRef,
|
||||
DomainRef as DslDomainRef,
|
||||
} from './logic-fluent.js'
|
||||
|
||||
// ---- Compiler Options (dependency injection) ----
|
||||
|
||||
export interface CompilerOptions {
|
||||
// Injected id generator for determinism in tests
|
||||
generateId?: (prefix: string) => string
|
||||
// Default frame when none specified
|
||||
defaultFrameKind?: string
|
||||
// Default tolerance when none specified
|
||||
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
|
||||
}
|
||||
|
||||
// ---- Id Generator ----
|
||||
|
||||
function defaultNextId(prefix: string): string {
|
||||
return `${prefix}_${getDefaultContext().idGenerator()}`
|
||||
}
|
||||
|
||||
// ---- Option Value Normalization ----
|
||||
// INVARIANT: Both fluent API and string DSL must lower to equivalent Execution IR.
|
||||
// The string parser produces LiteralNode / ToleranceLiteralNode AST wrappers,
|
||||
// while the fluent API stores raw numbers. These normalizers extract the scalar
|
||||
// value from both formats so parseGap / parseTolerance receive consistent input.
|
||||
//
|
||||
// Bug fixed: Previously parseGap(LiteralNode) silently returned null because
|
||||
// String(object) === "[object Object]", causing string DSL gap options to be
|
||||
// lost in compilation. The extractGapValue helper now handles range literals.
|
||||
|
||||
function normalizeOptionValue(raw: unknown): number | string | undefined {
|
||||
if (raw === undefined || raw === null) return undefined
|
||||
if (typeof raw === 'number' || typeof raw === 'string') return raw
|
||||
|
||||
// String-parser/fluent path: LiteralNode or ToleranceLiteralNode
|
||||
if (typeof raw === 'object') {
|
||||
const obj = raw as Record<string, unknown>
|
||||
if ('value' in obj && typeof obj.value === 'number') {
|
||||
if ('unit' in obj && typeof obj.unit === 'string') {
|
||||
return `${obj.value}${obj.unit}`
|
||||
}
|
||||
return obj.value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Extract a numeric gap value from fluent raw numbers, string-parser LiteralNodes,
|
||||
// or string-parser range LiteralNodes ( picking min or max edge ).
|
||||
function extractGapValue(raw: unknown, edge: 'min' | 'max'): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined
|
||||
if (typeof raw === 'number') return raw
|
||||
if (typeof raw === 'string') {
|
||||
const num = parseFloat(raw.trim())
|
||||
if (!Number.isNaN(num)) return num
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
const obj = raw as Record<string, unknown>
|
||||
|
||||
// Simple length literal: { type: 'Literal', kind: 'length', value: number }
|
||||
if ('value' in obj && typeof obj.value === 'number') {
|
||||
return obj.value
|
||||
}
|
||||
|
||||
// Range literal: { type: 'Literal', kind: 'range', value: { min: { value }, max: { value } } }
|
||||
if (obj.kind === 'range' && obj.value && typeof obj.value === 'object') {
|
||||
const range = obj.value as Record<string, unknown>
|
||||
const target = edge === 'min' ? range.min : range.max
|
||||
if (target && typeof target === 'object') {
|
||||
const targetObj = target as Record<string, unknown>
|
||||
if (typeof targetObj.value === 'number') {
|
||||
return targetObj.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---- Semantic Lowering ----
|
||||
|
||||
function toSemanticIr(
|
||||
ast: RelationAssertion[],
|
||||
opts: CompilerOptions,
|
||||
): { semanticIr: SemanticIr; diagnostics: Diagnostic[] } {
|
||||
const generateId = opts.generateId || defaultNextId
|
||||
const ir = createEmptySemanticIr()
|
||||
const diagnostics: Diagnostic[] = []
|
||||
|
||||
// Default frame
|
||||
const defaultFrameId = generateId('frame')
|
||||
ir.frames.set(defaultFrameId, {
|
||||
id: defaultFrameId,
|
||||
kind: (opts.defaultFrameKind || 'viewport') as SemanticFrame['kind'],
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
// Default state
|
||||
const defaultStateId = generateId('state')
|
||||
ir.states.set(defaultStateId, {
|
||||
id: defaultStateId,
|
||||
kind: 'default',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
// Default timeline
|
||||
const defaultTimelineId = generateId('timeline')
|
||||
ir.timelines.set(defaultTimelineId, {
|
||||
id: defaultTimelineId,
|
||||
mode: 'static',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
// Default tolerance
|
||||
const defaultToleranceId = generateId('tolerance')
|
||||
ir.tolerances.set(defaultToleranceId, {
|
||||
id: defaultToleranceId,
|
||||
value: opts.defaultTolerance?.value ?? 0,
|
||||
unit: opts.defaultTolerance?.unit ?? 'px',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
for (const assertion of ast) {
|
||||
// Build origin from source span
|
||||
const origin = buildOrigin(assertion.position)
|
||||
|
||||
// Access extended DSL properties via cast
|
||||
const extended = assertion as unknown as Record<string, unknown>
|
||||
|
||||
// Subject
|
||||
const subjectId = generateId('subject')
|
||||
const subjectDef: SemanticSubject = {
|
||||
id: subjectId,
|
||||
selector: assertion.subject.value,
|
||||
kind: 'element',
|
||||
origin,
|
||||
}
|
||||
ir.subjects.set(subjectId, subjectDef)
|
||||
|
||||
// Reference
|
||||
const referenceId = generateId('subject')
|
||||
const referenceDef: SemanticSubject = {
|
||||
id: referenceId,
|
||||
selector: assertion.reference.value,
|
||||
kind: 'element',
|
||||
origin,
|
||||
}
|
||||
ir.subjects.set(referenceId, referenceDef)
|
||||
|
||||
// Frame
|
||||
let frameId = defaultFrameId
|
||||
const frameNode = extended.frame as { kind: string; selector?: string; name?: string } | undefined
|
||||
if (frameNode) {
|
||||
frameId = generateId('frame')
|
||||
const frameDef: SemanticFrame = {
|
||||
id: frameId,
|
||||
kind: frameNode.kind as SemanticFrame['kind'],
|
||||
selector: frameNode.selector,
|
||||
name: frameNode.name,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
origin,
|
||||
}
|
||||
ir.frames.set(frameId, frameDef)
|
||||
}
|
||||
|
||||
// State
|
||||
let stateId = defaultStateId
|
||||
const stateNode = extended.state as { kind: string; name?: string } | undefined
|
||||
if (stateNode) {
|
||||
stateId = generateId('state')
|
||||
const stateDef: SemanticState = {
|
||||
id: stateId,
|
||||
kind: stateNode.kind as SemanticState['kind'],
|
||||
name: stateNode.name,
|
||||
origin,
|
||||
}
|
||||
ir.states.set(stateId, stateDef)
|
||||
}
|
||||
|
||||
// Tolerance
|
||||
let toleranceId = defaultToleranceId
|
||||
const rawOpts = assertion.options as unknown as Record<string, unknown> | undefined
|
||||
const tol = parseTolerance(normalizeOptionValue(rawOpts?.tolerance))
|
||||
if (tol) {
|
||||
toleranceId = generateId('tolerance')
|
||||
ir.tolerances.set(toleranceId, {
|
||||
id: toleranceId,
|
||||
value: tol.value,
|
||||
unit: tol.unit,
|
||||
origin,
|
||||
})
|
||||
}
|
||||
|
||||
// Environment guard
|
||||
const envGuardId = generateId('guard')
|
||||
const envGuard = extended.envGuard as { condition: string } | undefined
|
||||
const guardDef: SemanticEnvGuard = {
|
||||
id: envGuardId,
|
||||
expression: envGuard?.condition || 'true',
|
||||
normalizedCases: [],
|
||||
origin,
|
||||
}
|
||||
ir.envGuards.set(envGuardId, guardDef)
|
||||
|
||||
// Bounds
|
||||
const bounds: SemanticClause['bounds'] = {}
|
||||
if (rawOpts) {
|
||||
if ('minGap' in rawOpts) {
|
||||
const minVal = extractGapValue(rawOpts.minGap, 'min')
|
||||
if (minVal !== undefined) {
|
||||
bounds.minGap = { value: minVal, unit: 'px' }
|
||||
}
|
||||
}
|
||||
if ('maxGap' in rawOpts) {
|
||||
const maxVal = extractGapValue(rawOpts.maxGap, 'max')
|
||||
if (maxVal !== undefined) {
|
||||
bounds.maxGap = { value: maxVal, unit: 'px' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flags
|
||||
let flags = 0
|
||||
const quantifier = extended.quantifier as string | undefined
|
||||
if (quantifier === 'all') flags |= 1
|
||||
if (quantifier === 'any') flags |= 2
|
||||
if (quantifier === 'none') flags |= 4
|
||||
if (rawOpts?.inStackingContext) flags |= 8
|
||||
if ((assertion as any).negated) flags |= 16
|
||||
|
||||
// Clause
|
||||
const clauseId = generateId('clause')
|
||||
const clause: SemanticClause = {
|
||||
id: clauseId,
|
||||
origin,
|
||||
subjectRef: subjectId,
|
||||
referenceRef: referenceId,
|
||||
relation: assertion.relation,
|
||||
frameRef: frameId,
|
||||
stateRef: stateId,
|
||||
timelineRef: defaultTimelineId,
|
||||
envGuardRef: envGuardId,
|
||||
toleranceRef: toleranceId,
|
||||
bounds,
|
||||
}
|
||||
|
||||
// Attach flags via cast since core SemanticClause doesn't have flags field yet
|
||||
;(clause as unknown as Record<string, unknown>).flags = flags
|
||||
|
||||
ir.clauses.set(clauseId, clause)
|
||||
}
|
||||
|
||||
return { semanticIr: ir, diagnostics }
|
||||
}
|
||||
|
||||
function buildOrigin(position?: Position): { astNodeIds: string[]; positions: Position[] } {
|
||||
if (position) {
|
||||
return { astNodeIds: [], positions: [position] }
|
||||
}
|
||||
return { astNodeIds: [], positions: [] }
|
||||
}
|
||||
|
||||
// ---- Execution IR Compilation ----
|
||||
|
||||
function toExecutionIr(semanticIr: SemanticIr): ExecutionIr {
|
||||
const clauses = Array.from(semanticIr.clauses.values())
|
||||
const count = clauses.length
|
||||
|
||||
if (count === 0) {
|
||||
return {
|
||||
clauseCount: 0,
|
||||
clauseType: new Uint16Array(0),
|
||||
clauseSubject: new Uint32Array(0),
|
||||
clauseReference: new Uint32Array(0),
|
||||
clauseFrame: new Uint32Array(0),
|
||||
clauseState: new Uint32Array(0),
|
||||
clauseTimeline: new Uint32Array(0),
|
||||
clauseTolerance: new Uint32Array(0),
|
||||
clauseEnvGuard: new Uint32Array(0),
|
||||
clauseArg0: new Float64Array(0),
|
||||
clauseArg1: new Float64Array(0),
|
||||
clauseFlags: new Uint32Array(0),
|
||||
clauseOrigin: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
// Build lookup maps for numeric indices — iterate Maps directly to avoid Array.from().
|
||||
const subjectIndex = new Map<ImhotepId, number>()
|
||||
let idx = 0
|
||||
for (const k of semanticIr.subjects.keys()) subjectIndex.set(k, idx++)
|
||||
|
||||
const frameIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.frames.keys()) frameIndex.set(k, idx++)
|
||||
|
||||
const stateIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.states.keys()) stateIndex.set(k, idx++)
|
||||
|
||||
const timelineIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.timelines.keys()) timelineIndex.set(k, idx++)
|
||||
|
||||
const toleranceIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.tolerances.keys()) toleranceIndex.set(k, idx++)
|
||||
|
||||
const guardIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.envGuards.keys()) guardIndex.set(k, idx++)
|
||||
|
||||
// Encode relation as small integer
|
||||
const relationCodes: Record<string, number> = {
|
||||
leftOf: 1,
|
||||
rightOf: 2,
|
||||
above: 3,
|
||||
below: 4,
|
||||
alignedWith: 5,
|
||||
leftAlignedWith: 6,
|
||||
rightAlignedWith: 7,
|
||||
topAlignedWith: 8,
|
||||
bottomAlignedWith: 9,
|
||||
centeredWithin: 10,
|
||||
inside: 11,
|
||||
contains: 12,
|
||||
overlaps: 13,
|
||||
separatedFrom: 14,
|
||||
aspectRatioBetween: 15,
|
||||
between: 16,
|
||||
}
|
||||
|
||||
const clauseType = new Uint16Array(count)
|
||||
const clauseSubject = new Uint32Array(count)
|
||||
const clauseReference = new Uint32Array(count)
|
||||
const clauseFrame = new Uint32Array(count)
|
||||
const clauseState = new Uint32Array(count)
|
||||
const clauseTimeline = new Uint32Array(count)
|
||||
const clauseTolerance = new Uint32Array(count)
|
||||
const clauseEnvGuard = new Uint32Array(count)
|
||||
const clauseArg0 = new Float64Array(count)
|
||||
const clauseArg1 = new Float64Array(count)
|
||||
const clauseFlags = new Uint32Array(count)
|
||||
const clauseOrigin = new Uint32Array(count)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const c = clauses[i]
|
||||
clauseType[i] = relationCodes[c.relation] || 0
|
||||
clauseSubject[i] = subjectIndex.get(c.subjectRef) ?? 0
|
||||
clauseReference[i] = c.referenceRef ? (subjectIndex.get(c.referenceRef) ?? 0) : 0
|
||||
clauseFrame[i] = frameIndex.get(c.frameRef) ?? 0
|
||||
clauseState[i] = stateIndex.get(c.stateRef) ?? 0
|
||||
clauseTimeline[i] = timelineIndex.get(c.timelineRef) ?? 0
|
||||
clauseTolerance[i] = toleranceIndex.get(c.toleranceRef) ?? 0
|
||||
clauseEnvGuard[i] = guardIndex.get(c.envGuardRef) ?? 0
|
||||
clauseOrigin[i] = i // origin index same as clause index for now
|
||||
|
||||
// Retrieve flags via cast
|
||||
const flags = (c as unknown as Record<string, unknown>).flags as number | undefined
|
||||
clauseFlags[i] = flags || 0
|
||||
|
||||
// Pack bounds into arg0/arg1 where applicable
|
||||
if (c.bounds.minGap && c.bounds.maxGap) {
|
||||
clauseArg0[i] = c.bounds.minGap.value
|
||||
clauseArg1[i] = c.bounds.maxGap.value
|
||||
} else if (c.bounds.minGap) {
|
||||
clauseArg0[i] = c.bounds.minGap.value
|
||||
clauseArg1[i] = Number.POSITIVE_INFINITY
|
||||
} else if (c.bounds.maxGap) {
|
||||
clauseArg0[i] = Number.NEGATIVE_INFINITY
|
||||
clauseArg1[i] = c.bounds.maxGap.value
|
||||
} else {
|
||||
clauseArg0[i] = Number.NEGATIVE_INFINITY
|
||||
clauseArg1[i] = Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clauseCount: count,
|
||||
clauseType,
|
||||
clauseSubject,
|
||||
clauseReference,
|
||||
clauseFrame,
|
||||
clauseState,
|
||||
clauseTimeline,
|
||||
clauseTolerance,
|
||||
clauseEnvGuard,
|
||||
clauseArg0,
|
||||
clauseArg1,
|
||||
clauseFlags,
|
||||
clauseOrigin,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FOL Formula Compilation ----
|
||||
|
||||
const subjectVar: VariableRef = { type: 'VariableRef', name: '$subject' }
|
||||
const referenceVar: VariableRef = { type: 'VariableRef', name: '$reference' }
|
||||
|
||||
function makeDomain(selector: string): DomainRef {
|
||||
return { type: 'DomainRef', domain: 'elements', selector }
|
||||
}
|
||||
|
||||
function makeBinding(varName: string, selector: string): TupleBinding {
|
||||
return {
|
||||
type: 'TupleBinding',
|
||||
variables: [varName],
|
||||
domain: makeDomain(selector),
|
||||
}
|
||||
}
|
||||
|
||||
function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion | TopologyAssertion): Record<string, unknown> {
|
||||
const options: Record<string, unknown> = {}
|
||||
|
||||
if (assertion.type === 'RelationAssertion') {
|
||||
const opts = assertion.options as unknown as Record<string, unknown>
|
||||
if (opts?.minGap !== undefined) {
|
||||
const v = normalizeOptionValue(opts.minGap)
|
||||
if (v !== undefined) options.minGap = v
|
||||
}
|
||||
if (opts?.maxGap !== undefined) {
|
||||
const v = normalizeOptionValue(opts.maxGap)
|
||||
if (v !== undefined) options.maxGap = v
|
||||
}
|
||||
if (opts?.tolerance !== undefined) {
|
||||
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
|
||||
if (tol) {
|
||||
options.tolerance = tol.value
|
||||
options.toleranceUnit = tol.unit
|
||||
}
|
||||
}
|
||||
if (opts?.axis !== undefined) options.axis = opts.axis
|
||||
if (opts?.inStackingContext === true) options.inStackingContext = true
|
||||
}
|
||||
|
||||
if (assertion.type === 'SizeAssertion') {
|
||||
const bounds = assertion.bounds as unknown as Record<string, unknown>
|
||||
if (bounds?.min && typeof (bounds.min as any).value === 'number') {
|
||||
options.min = (bounds.min as any).value
|
||||
options.value = (bounds.min as any).value
|
||||
}
|
||||
if (bounds?.max && typeof (bounds.max as any).value === 'number') {
|
||||
options.max = (bounds.max as any).value
|
||||
}
|
||||
if (bounds?.exact && typeof (bounds.exact as any).value === 'number') {
|
||||
options.value = (bounds.exact as any).value
|
||||
}
|
||||
if (assertion.property) {
|
||||
options.dimension = assertion.property
|
||||
}
|
||||
}
|
||||
|
||||
if (assertion.type === 'TopologyAssertion') {
|
||||
const opts = assertion.options as unknown as Record<string, unknown>
|
||||
if (opts?.tolerance !== undefined) {
|
||||
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
|
||||
if (tol) {
|
||||
options.tolerance = tol.value
|
||||
options.toleranceUnit = tol.unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function compileSimpleAssertionToFormula(
|
||||
assertion: RelationAssertion | SizeAssertion | TopologyAssertion,
|
||||
): FormulaNode {
|
||||
const options = buildOptionsFromAssertion(assertion)
|
||||
const isUnary = assertion.type === 'SizeAssertion'
|
||||
|| (assertion.type === 'RelationAssertion'
|
||||
&& (assertion.relation === 'atLeast' || assertion.relation === 'atMost'))
|
||||
|
||||
let predicateName: string
|
||||
let args: VariableRef[]
|
||||
|
||||
if (assertion.type === 'RelationAssertion') {
|
||||
predicateName = assertion.relation
|
||||
args = isUnary ? [subjectVar] : [subjectVar, referenceVar]
|
||||
} else if (assertion.type === 'SizeAssertion') {
|
||||
if (assertion.property === 'aspectRatio') {
|
||||
predicateName = 'aspectRatio'
|
||||
} else if (assertion.bounds.min && assertion.bounds.max) {
|
||||
predicateName = 'between'
|
||||
} else if (assertion.bounds.max) {
|
||||
predicateName = 'atMost'
|
||||
} else if (assertion.bounds.min) {
|
||||
predicateName = 'atLeast'
|
||||
} else {
|
||||
predicateName = 'atLeast'
|
||||
}
|
||||
args = [subjectVar]
|
||||
} else {
|
||||
predicateName = assertion.predicate
|
||||
args = assertion.reference ? [subjectVar, referenceVar] : [subjectVar]
|
||||
}
|
||||
|
||||
let body: FormulaNode = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: predicateName,
|
||||
args,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
} as FormulaNode
|
||||
|
||||
// Negation
|
||||
if ((assertion as any).negated) {
|
||||
body = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: body,
|
||||
}
|
||||
}
|
||||
|
||||
// Bindings
|
||||
const subjectBinding = makeBinding('$subject', assertion.subject.value)
|
||||
const bindings: TupleBinding[] = [subjectBinding]
|
||||
|
||||
if (!isUnary && assertion.type !== 'TopologyAssertion') {
|
||||
const ref = (assertion as RelationAssertion).reference
|
||||
if (ref?.value) {
|
||||
bindings.push(makeBinding('$reference', ref.value))
|
||||
}
|
||||
}
|
||||
if (assertion.type === 'TopologyAssertion' && assertion.reference?.value) {
|
||||
bindings.push(makeBinding('$reference', assertion.reference.value))
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings,
|
||||
body,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
function compileQuantifierToFormula(
|
||||
assertion: CompoundAssertion,
|
||||
body: FormulaNode,
|
||||
): FormulaNode {
|
||||
const quantifier = assertion.quantifier?.kind
|
||||
const subject = (assertion.children[0] as any).subject as SelectorRef
|
||||
const reference = (assertion.children[0] as any).reference as SelectorRef | undefined
|
||||
|
||||
const subjectBinding = makeBinding('$subject', subject.value)
|
||||
|
||||
if (quantifier === 'any') {
|
||||
const refBinding = reference?.value
|
||||
? makeBinding('$reference', reference.value)
|
||||
: null
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [subjectBinding],
|
||||
body: refBinding
|
||||
? {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [refBinding],
|
||||
body,
|
||||
}
|
||||
: body,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
if (quantifier === 'none') {
|
||||
const refBinding = reference?.value
|
||||
? makeBinding('$reference', reference.value)
|
||||
: null
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [subjectBinding],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: refBinding
|
||||
? {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [refBinding],
|
||||
body,
|
||||
}
|
||||
: body,
|
||||
},
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
// Default / 'all'
|
||||
const bindings: TupleBinding[] = [subjectBinding]
|
||||
if (reference?.value) {
|
||||
bindings.push(makeBinding('$reference', reference.value))
|
||||
}
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings,
|
||||
body,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a single DSL assertion AST node to a FOL FormulaNode.
|
||||
* Handles RelationAssertion, SizeAssertion, TopologyAssertion,
|
||||
* and CompoundAssertion (with operators and/or quantifiers).
|
||||
*/
|
||||
export function compileToFormula(assertion: AssertionNode): FormulaNode | null {
|
||||
if (!assertion) return null
|
||||
|
||||
// Compound assertion: operator (and/or) or quantifier wrapper
|
||||
if (assertion.type === 'CompoundAssertion') {
|
||||
const compound = assertion as CompoundAssertion
|
||||
|
||||
if (compound.operator) {
|
||||
// Logical compound: compile children and wrap in and/or
|
||||
const children = (compound.children || [])
|
||||
.map((child) => compileToFormula(child))
|
||||
.filter((f): f is FormulaNode => f !== null)
|
||||
|
||||
if (children.length === 0) return null
|
||||
if (children.length === 1) return children[0]
|
||||
|
||||
let result = children[0]
|
||||
for (let i = 1; i < children.length; i++) {
|
||||
result = {
|
||||
type: 'FormulaNode',
|
||||
kind: compound.operator,
|
||||
left: result,
|
||||
right: children[i],
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
// If quantifier is present, wrap the whole compound
|
||||
if (compound.quantifier) {
|
||||
return compileQuantifierToFormula(compound, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Quantifier-only compound
|
||||
const childFormulas = (compound.children || [])
|
||||
.map((child) => compileToFormula(child))
|
||||
.filter((f): f is FormulaNode => f !== null)
|
||||
|
||||
if (childFormulas.length === 0) return null
|
||||
const body = childFormulas.length === 1
|
||||
? childFormulas[0]
|
||||
: childFormulas.reduce((left, right) => ({
|
||||
type: 'FormulaNode',
|
||||
kind: 'and',
|
||||
left,
|
||||
right,
|
||||
} as FormulaNode))
|
||||
|
||||
return compileQuantifierToFormula(compound, body)
|
||||
}
|
||||
|
||||
// Simple assertions
|
||||
if (
|
||||
assertion.type === 'RelationAssertion'
|
||||
|| assertion.type === 'SizeAssertion'
|
||||
|| assertion.type === 'TopologyAssertion'
|
||||
) {
|
||||
return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- Dense DSL FOL Compilation (bypasses canonical lowering) ----
|
||||
|
||||
/**
|
||||
* Convert a DSL grammar FOL formula AST node to a solver FormulaNode.
|
||||
*
|
||||
* Dense DSL forall/exists formulas use a distinct AST shape from the solver's
|
||||
* FormulaNode (e.g. type: 'ForAll' vs type: 'FormulaNode' kind: 'forall').
|
||||
* This function bridges the two representations so that parsed dense FOL specs
|
||||
* can be evaluated directly by evaluateLogic() without going through canonical
|
||||
* clause descriptors.
|
||||
*
|
||||
* String arguments inside PredicateCall are lifted into implicit forall
|
||||
* bindings because the solver only accepts VariableRef and AccessorTerm
|
||||
* as predicate arguments.
|
||||
*/
|
||||
export function compileDenseFOLToFormula(dslFormula: DslFormulaNode): FormulaNode {
|
||||
let freshVarCounter = 0
|
||||
function makeFreshVar(): string {
|
||||
freshVarCounter += 1
|
||||
return `$str_${freshVarCounter}`
|
||||
}
|
||||
|
||||
function compile(node: DslFormulaNode): FormulaNode {
|
||||
switch (node.type) {
|
||||
case 'ForAll': {
|
||||
const fa = node as DslForAll
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [makeTupleBinding(fa.variable, fa.domain)],
|
||||
body: compile(fa.body),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Exists': {
|
||||
const ex = node as DslExists
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [makeTupleBinding(ex.variable, ex.domain)],
|
||||
body: compile(ex.body),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'And': {
|
||||
const a = node as DslAnd
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'and',
|
||||
left: compile(a.left),
|
||||
right: compile(a.right),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Or': {
|
||||
const o = node as DslOr
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'or',
|
||||
left: compile(o.left),
|
||||
right: compile(o.right),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Not': {
|
||||
const n = node as DslNot
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: compile(n.operand),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Implies': {
|
||||
const imp = node as DslImplies
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'implies',
|
||||
antecedent: compile(imp.left),
|
||||
consequent: compile(imp.right),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'PredicateCall': {
|
||||
const pc = node as DslPredicateCall
|
||||
const implicitBindings: TupleBinding[] = []
|
||||
const compiledArgs: VariableRef[] = []
|
||||
|
||||
for (const arg of pc.args) {
|
||||
if (typeof arg === 'string') {
|
||||
const varName = makeFreshVar()
|
||||
implicitBindings.push({
|
||||
type: 'TupleBinding',
|
||||
variables: [varName],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: arg },
|
||||
})
|
||||
compiledArgs.push({ type: 'VariableRef', name: varName })
|
||||
} else if (typeof arg === 'number') {
|
||||
// Numbers are not supported as predicate args in the solver.
|
||||
// Skip them — the caller should have encoded numeric values
|
||||
// in predicate options instead.
|
||||
continue
|
||||
} else {
|
||||
compiledArgs.push(convertTerm(arg) as VariableRef)
|
||||
}
|
||||
}
|
||||
|
||||
// Size predicates with comparison operators: width($x) >= 44 → size.atLeast
|
||||
let predicateBody: FormulaNode
|
||||
if (['width', 'height', 'size'].includes(pc.name) && pc.operator && pc.right !== undefined) {
|
||||
const options: Record<string, unknown> = {}
|
||||
if (pc.name === 'width') options.dimension = 'width'
|
||||
else if (pc.name === 'height') options.dimension = 'height'
|
||||
else options.dimension = 'width' // size defaults to width
|
||||
|
||||
let predicateName: string
|
||||
switch (pc.operator) {
|
||||
case '>=':
|
||||
predicateName = 'atLeast'
|
||||
options.min = pc.right
|
||||
break
|
||||
case '<=':
|
||||
predicateName = 'atMost'
|
||||
options.max = pc.right
|
||||
break
|
||||
case '==':
|
||||
predicateName = 'between'
|
||||
options.min = pc.right
|
||||
options.max = pc.right
|
||||
break
|
||||
case '!=':
|
||||
// Not-equal is compiled as negated equality
|
||||
predicateBody = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'between',
|
||||
args: compiledArgs,
|
||||
options: { ...options, min: pc.right, max: pc.right },
|
||||
} as FormulaNode,
|
||||
} as FormulaNode
|
||||
// Wrap implicit bindings if any
|
||||
return wrapInForAll(implicitBindings, predicateBody)
|
||||
case '>':
|
||||
predicateName = 'atLeast'
|
||||
options.min = pc.right
|
||||
break
|
||||
case '<':
|
||||
predicateName = 'atMost'
|
||||
options.max = pc.right
|
||||
break
|
||||
default:
|
||||
predicateName = pc.name
|
||||
}
|
||||
|
||||
predicateBody = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: predicateName,
|
||||
args: compiledArgs,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
} as FormulaNode
|
||||
} else {
|
||||
predicateBody = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: pc.name,
|
||||
args: compiledArgs,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
// Wrap implicit forall bindings around the predicate body.
|
||||
// String literals in predicate args are treated as universally
|
||||
// quantified domains (same semantics as canonical clause compilation).
|
||||
return wrapInForAll(implicitBindings, predicateBody)
|
||||
}
|
||||
case 'VariableRef':
|
||||
case 'DomainRef':
|
||||
// VariableRef and DomainRef are terms, not formulas. They should only
|
||||
// appear as arguments inside PredicateCall, never as top-level formulas.
|
||||
throw new Error(`DSL ${node.type} is a term, not a formula, and cannot be compiled standalone`)
|
||||
default:
|
||||
throw new Error(`Unknown DSL formula node type: ${(node as any).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
return compile(dslFormula)
|
||||
}
|
||||
|
||||
function wrapInForAll(bindings: TupleBinding[], body: FormulaNode): FormulaNode {
|
||||
if (bindings.length === 0) return body
|
||||
let result = body
|
||||
// Wrap from last binding to first so that the outermost forall
|
||||
// corresponds to the leftmost argument.
|
||||
for (let i = bindings.length - 1; i >= 0; i--) {
|
||||
result = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [bindings[i]],
|
||||
body: result,
|
||||
} as FormulaNode
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function makeTupleBinding(variable: DslVariableRef, domain: DslDomainRef): TupleBinding {
|
||||
return {
|
||||
type: 'TupleBinding',
|
||||
variables: [variable.name],
|
||||
domain: convertDomain(domain),
|
||||
}
|
||||
}
|
||||
|
||||
function convertDomain(domain: DslDomainRef): DomainRef {
|
||||
const selectorFromVar = domain.variableSelector
|
||||
? `$${domain.variableSelector.name}`
|
||||
: undefined
|
||||
const extraArgFromVar = domain.variableExtraArg
|
||||
? `$${domain.variableExtraArg.name}`
|
||||
: undefined
|
||||
|
||||
// Descendant domains use the first argument as parent and second as filter:
|
||||
// descendants($card, '.title') => parentVar: '$card', selector: '.title'
|
||||
if (domain.kind === 'descendants') {
|
||||
const parentVar = selectorFromVar ?? domain.selector
|
||||
const selector = extraArgFromVar ?? (domain as any).extraArg
|
||||
return {
|
||||
type: 'DomainRef',
|
||||
domain: domain.kind,
|
||||
selector,
|
||||
parentVar,
|
||||
}
|
||||
}
|
||||
|
||||
// Default mapping for non-descendant domains.
|
||||
const selector = selectorFromVar ?? domain.selector
|
||||
const parentVar = extraArgFromVar ?? (domain as any).extraArg
|
||||
return {
|
||||
type: 'DomainRef',
|
||||
domain: domain.kind,
|
||||
selector,
|
||||
parentVar,
|
||||
}
|
||||
}
|
||||
|
||||
function convertTerm(term: DslVariableRef | string | number): VariableRef | string | number {
|
||||
if (typeof term === 'object' && term !== null && term.type === 'VariableRef') {
|
||||
return { type: 'VariableRef', name: (term as DslVariableRef).name }
|
||||
}
|
||||
return term
|
||||
}
|
||||
|
||||
// ---- Public Compiler ----
|
||||
|
||||
export interface CompileResult {
|
||||
ast: { type: 'Program'; children: RelationAssertion[] }
|
||||
semanticIr: SemanticIr
|
||||
executionIr: ExecutionIr
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
export function compile(
|
||||
ast: RelationAssertion[],
|
||||
options: CompilerOptions = {},
|
||||
): CompileResult {
|
||||
const programNode = {
|
||||
type: 'Program' as const,
|
||||
children: ast,
|
||||
}
|
||||
|
||||
const { semanticIr, diagnostics } = toSemanticIr(ast, options)
|
||||
const executionIr = toExecutionIr(semanticIr)
|
||||
|
||||
return {
|
||||
ast: programNode,
|
||||
semanticIr,
|
||||
executionIr,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Global configuration and project defaults for Imhotep
|
||||
// Uses dependency injection patterns: configure() sets runtime globals,
|
||||
// project() sets persistent project-level metadata.
|
||||
|
||||
export interface ImhotepConfig {
|
||||
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
|
||||
defaultFrame?: string
|
||||
perceptualModel?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
let globalConfig: ImhotepConfig = {}
|
||||
|
||||
/** Sets or updates global Imhotep runtime configuration. */
|
||||
export function configure(config: ImhotepConfig): void {
|
||||
globalConfig = { ...globalConfig, ...config }
|
||||
}
|
||||
|
||||
/** Returns a shallow copy of the current global config. */
|
||||
export function getConfig(): ImhotepConfig {
|
||||
return { ...globalConfig }
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
name?: string
|
||||
fontCorpus?: string
|
||||
adapters?: string[]
|
||||
environments?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
let projectConfig: ProjectConfig = {}
|
||||
|
||||
/** Sets or updates project-level defaults, font corpora, and adapters. */
|
||||
export function project(config: ProjectConfig): void {
|
||||
projectConfig = { ...projectConfig, ...config }
|
||||
}
|
||||
|
||||
/** Returns a shallow copy of the current project config. */
|
||||
export function getProjectConfig(): ProjectConfig {
|
||||
return { ...projectConfig }
|
||||
}
|
||||
|
||||
/** Environment guard for responsive contracts.
|
||||
* Only executes the callback when the condition is truthy.
|
||||
*/
|
||||
export function when(condition: unknown, fn: () => void): void {
|
||||
if (condition) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Tests for FOL fluent builder safety.
|
||||
*
|
||||
* Verifies:
|
||||
* - forAll/exists callbacks collect returned assertions
|
||||
* - Empty assertion quantifiers throw instead of vacuously passing
|
||||
* - Assertions are properly lowered to formula AST
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {
|
||||
expect,
|
||||
FluentAssertion,
|
||||
FluentBinding,
|
||||
FluentQuantifier,
|
||||
} from './fluent.js'
|
||||
|
||||
describe('FOL fluent builder', () => {
|
||||
it('forAll collects returned FluentRelation', () => {
|
||||
const quantifier = FluentAssertion.forAll('.button', (btn) =>
|
||||
btn.expect().to.be.above('.label'),
|
||||
)
|
||||
|
||||
assert.strictEqual(quantifier.kind, 'forAll')
|
||||
assert.strictEqual(quantifier.bindings.length, 1)
|
||||
const assertions = quantifier.bindings[0].getAssertions()
|
||||
assert.strictEqual(assertions.length, 1)
|
||||
assert.strictEqual(assertions[0].relation, 'above')
|
||||
assert.strictEqual(assertions[0].subject.value, '.button')
|
||||
assert.strictEqual(assertions[0].reference.value, '.label')
|
||||
})
|
||||
|
||||
it('forAll collects multiple returned assertions via addAssertion', () => {
|
||||
const quantifier = FluentAssertion.forAll('.button', (btn) => {
|
||||
const rel1 = btn.expect().to.be.above('.label')
|
||||
btn.addAssertion(rel1.toAst())
|
||||
const rel2 = btn.expect().to.be.leftOf('.icon')
|
||||
btn.addAssertion(rel2.toAst())
|
||||
})
|
||||
|
||||
const assertions = quantifier.bindings[0].getAssertions()
|
||||
assert.strictEqual(assertions.length, 2)
|
||||
assert.strictEqual(assertions[0].relation, 'above')
|
||||
assert.strictEqual(assertions[1].relation, 'leftOf')
|
||||
})
|
||||
|
||||
it('forAll with no assertions throws on toFormula', () => {
|
||||
const quantifier = FluentAssertion.forAll('.button', (_btn) => {
|
||||
// Intentionally empty — simulates builder misuse
|
||||
})
|
||||
|
||||
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
|
||||
})
|
||||
|
||||
it('exists collects returned FluentRelation', () => {
|
||||
const quantifier = FluentAssertion.exists('.tooltip', (tip) =>
|
||||
tip.expect().to.be.inside('.container'),
|
||||
)
|
||||
|
||||
assert.strictEqual(quantifier.kind, 'exists')
|
||||
assert.strictEqual(quantifier.bindings.length, 1)
|
||||
const assertions = quantifier.bindings[0].getAssertions()
|
||||
assert.strictEqual(assertions.length, 1)
|
||||
assert.strictEqual(assertions[0].relation, 'inside')
|
||||
})
|
||||
|
||||
it('exists with no assertions throws on toFormula', () => {
|
||||
const quantifier = FluentAssertion.exists('.tooltip', (_tip) => {
|
||||
// Intentionally empty
|
||||
})
|
||||
|
||||
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
|
||||
})
|
||||
|
||||
it('forAll over multiple selectors creates multiple bindings', () => {
|
||||
const quantifier = FluentAssertion.forAll(['.a', '.b'], (el) =>
|
||||
el.expect().to.be.leftOf('.c'),
|
||||
)
|
||||
|
||||
assert.strictEqual(quantifier.bindings.length, 2)
|
||||
assert.strictEqual(quantifier.bindings[0].selector, '.a')
|
||||
assert.strictEqual(quantifier.bindings[1].selector, '.b')
|
||||
})
|
||||
|
||||
it('forAll lowers to ForAll formula AST', () => {
|
||||
const quantifier = FluentAssertion.forAll('.button', (btn) =>
|
||||
btn.expect().to.be.above('.label'),
|
||||
)
|
||||
|
||||
const formula = quantifier.toFormula()
|
||||
assert.strictEqual(formula.type, 'ForAll')
|
||||
assert.strictEqual((formula as any).variable.name, '$0')
|
||||
assert.strictEqual((formula as any).domain.selector, '.button')
|
||||
// Binary relations are wrapped in a reference forall
|
||||
assert.strictEqual((formula as any).body.type, 'ForAll')
|
||||
assert.strictEqual((formula as any).body.variable.name, '$ref')
|
||||
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
|
||||
assert.strictEqual((formula as any).body.body.name, 'above')
|
||||
})
|
||||
|
||||
it('exists lowers to Exists formula AST', () => {
|
||||
const quantifier = FluentAssertion.exists('.modal', (modal) =>
|
||||
modal.expect().to.be.centeredWithin('.viewport'),
|
||||
)
|
||||
|
||||
const formula = quantifier.toFormula()
|
||||
assert.strictEqual(formula.type, 'Exists')
|
||||
// Binary relations are wrapped in a reference forall
|
||||
assert.strictEqual((formula as any).body.type, 'ForAll')
|
||||
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
|
||||
assert.strictEqual((formula as any).body.body.name, 'centeredWithin')
|
||||
})
|
||||
|
||||
it('forAll with multiple assertions lowers to conjunction', () => {
|
||||
const quantifier = FluentAssertion.forAll('.button', (btn) => {
|
||||
const rel1 = btn.expect().to.be.above('.label')
|
||||
btn.addAssertion(rel1.toAst())
|
||||
const rel2 = btn.expect().to.be.leftOf('.icon')
|
||||
btn.addAssertion(rel2.toAst())
|
||||
return undefined
|
||||
})
|
||||
|
||||
const formula = quantifier.toFormula()
|
||||
assert.strictEqual(formula.type, 'ForAll')
|
||||
const body = (formula as any).body
|
||||
assert.strictEqual(body.type, 'And')
|
||||
// Each predicate is wrapped in a reference forall
|
||||
assert.strictEqual(body.left.type, 'ForAll')
|
||||
assert.strictEqual(body.left.body.type, 'PredicateCall')
|
||||
assert.strictEqual(body.right.type, 'ForAll')
|
||||
assert.strictEqual(body.right.body.type, 'PredicateCall')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,421 @@
|
||||
// Unit tests for imhotep-dsl fluent API and compiler
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {
|
||||
expect,
|
||||
assert as imhotepAssert,
|
||||
frame,
|
||||
state,
|
||||
compile,
|
||||
validateRelation,
|
||||
validateProgram,
|
||||
parseSpec,
|
||||
parseTolerance,
|
||||
parseGap,
|
||||
} from './index.js'
|
||||
|
||||
// ---- Fluent Builder Tests ----
|
||||
|
||||
describe('fluent builder', () => {
|
||||
it('creates a basic leftOf relation', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
|
||||
assert.strictEqual(chain.assertion.getSubject(), '.a')
|
||||
assert.strictEqual(chain.relation, 'leftOf')
|
||||
assert.strictEqual(chain.referenceSelector, '.b')
|
||||
assert.strictEqual(chain.options.minGap, 16)
|
||||
})
|
||||
|
||||
it('chains frame attachment', () => {
|
||||
const chain = expect('.tooltip')
|
||||
.in(frame.nearestPositionedAncestor('.trigger'))
|
||||
.to.be.rightOf('.trigger', { minGap: 8, maxGap: 16 })
|
||||
|
||||
assert.strictEqual(chain.assertion.getSubject(), '.tooltip')
|
||||
assert.strictEqual(chain.assertion.getFrame()?.kind, 'nearestPositionedAncestor')
|
||||
assert.strictEqual(chain.assertion.getFrame()?.selector, '.trigger')
|
||||
assert.strictEqual(chain.relation, 'rightOf')
|
||||
assert.deepStrictEqual(chain.options, { minGap: 8, maxGap: 16 })
|
||||
})
|
||||
|
||||
it('chains state attachment', () => {
|
||||
const chain = expect('.button')
|
||||
.inState(state.hover())
|
||||
.to.be.above('.label')
|
||||
|
||||
assert.strictEqual(chain.assertion.getState()?.kind, 'hover')
|
||||
})
|
||||
|
||||
it('chains tolerance option', () => {
|
||||
const chain = expect('.dialog').to.be.centeredWithin('.viewport', {
|
||||
tolerance: 1,
|
||||
})
|
||||
assert.strictEqual(chain.options.tolerance, 1)
|
||||
})
|
||||
|
||||
it('chains perceptual tolerance in jnd', () => {
|
||||
const chain = expect('.icon').to.be.alignedWith('.label', {
|
||||
tolerance: '0.5jnd',
|
||||
})
|
||||
assert.strictEqual(chain.options.tolerance, '0.5jnd')
|
||||
})
|
||||
|
||||
it('chains quantifiers', () => {
|
||||
const allChain = expect('.toolbar button').all.to.be.leftOf('.sidebar')
|
||||
assert.strictEqual(allChain.assertion.getQuantifier(), 'all')
|
||||
|
||||
const anyChain = expect('.menu').any.to.be.below('.trigger')
|
||||
assert.strictEqual(anyChain.assertion.getQuantifier(), 'any')
|
||||
|
||||
const noneChain = expect('.errors').none.to.be.above('.footer')
|
||||
assert.strictEqual(noneChain.assertion.getQuantifier(), 'none')
|
||||
})
|
||||
|
||||
it('chains environment guard', () => {
|
||||
const chain = expect('.sidebar')
|
||||
.when('width >= 768')
|
||||
.to.be.leftOf('.content', { minGap: 24 })
|
||||
|
||||
assert.strictEqual(chain.assertion.getEnvGuard(), 'width >= 768')
|
||||
})
|
||||
|
||||
it('produces a relation assertion AST node', () => {
|
||||
const chain = expect('.nav-toggle')
|
||||
.in(frame.viewport())
|
||||
.to.be.leftOf('.search', { minGap: 16, maxGap: 20 })
|
||||
|
||||
const ast = chain.toAst()
|
||||
assert.strictEqual(ast.type, 'RelationAssertion')
|
||||
assert.strictEqual(ast.relation, 'leftOf')
|
||||
assert.strictEqual(ast.subject.value, '.nav-toggle')
|
||||
assert.strictEqual(ast.reference.value, '.search')
|
||||
|
||||
// Access extended properties via cast
|
||||
const extended = ast as unknown as Record<string, unknown>
|
||||
assert.strictEqual((extended.frame as { kind: string }).kind, 'viewport')
|
||||
|
||||
const opts = ast.options as unknown as Record<string, unknown>
|
||||
assert.strictEqual(opts.minGap, 16)
|
||||
assert.strictEqual(opts.maxGap, 20)
|
||||
})
|
||||
|
||||
it('produces AST with source span', () => {
|
||||
const span = {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 10, offset: 9 },
|
||||
}
|
||||
const chain = expect('.a', span).to.be.leftOf('.b')
|
||||
const ast = chain.toAst()
|
||||
assert.deepStrictEqual(ast.position, span)
|
||||
})
|
||||
|
||||
it('supports lower-level assert helper', () => {
|
||||
const chain = imhotepAssert('leftOf', '.a', '.b', { minGap: 8 }, frame.viewport(), state.default())
|
||||
assert.strictEqual(chain.relation, 'leftOf')
|
||||
assert.strictEqual(chain.assertion.getFrame()?.kind, 'viewport')
|
||||
assert.strictEqual(chain.assertion.getState()?.kind, 'default')
|
||||
})
|
||||
|
||||
// ---- Size Assertion Overloads ----
|
||||
|
||||
it('atLeast(44, "width") returns FluentRelation directly', () => {
|
||||
const chain = expect('.box').to.be.atLeast(44, 'width')
|
||||
assert.strictEqual(chain.relation, 'size.atLeast')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.value, 44)
|
||||
assert.strictEqual(chain.options.mode, 'atLeast')
|
||||
})
|
||||
|
||||
it('atLeast({ width: 44 }) returns FluentRelation directly', () => {
|
||||
const chain = expect('.box').to.be.atLeast({ width: 44 })
|
||||
assert.strictEqual(chain.relation, 'size.atLeast')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.value, 44)
|
||||
assert.strictEqual(chain.options.mode, 'atLeast')
|
||||
})
|
||||
|
||||
it('atLeast({ height: 88 }) returns height FluentRelation', () => {
|
||||
const chain = expect('.box').to.be.atLeast({ height: 88 })
|
||||
assert.strictEqual(chain.relation, 'size.atLeast')
|
||||
assert.strictEqual(chain.options.dimension, 'height')
|
||||
assert.strictEqual(chain.options.value, 88)
|
||||
})
|
||||
|
||||
it('atMost(100, "height") returns FluentRelation directly', () => {
|
||||
const chain = expect('.box').to.be.atMost(100, 'height')
|
||||
assert.strictEqual(chain.relation, 'size.atMost')
|
||||
assert.strictEqual(chain.options.dimension, 'height')
|
||||
assert.strictEqual(chain.options.value, 100)
|
||||
assert.strictEqual(chain.options.mode, 'atMost')
|
||||
})
|
||||
|
||||
it('atMost({ width: 200 }) returns FluentRelation directly', () => {
|
||||
const chain = expect('.box').to.be.atMost({ width: 200 })
|
||||
assert.strictEqual(chain.relation, 'size.atMost')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.value, 200)
|
||||
})
|
||||
|
||||
it('between(50, 100, "width") returns FluentRelation directly', () => {
|
||||
const chain = expect('.box').to.be.between(50, 100, 'width')
|
||||
assert.strictEqual(chain.relation, 'size.between')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.min, 50)
|
||||
assert.strictEqual(chain.options.max, 100)
|
||||
assert.strictEqual(chain.options.mode, 'between')
|
||||
})
|
||||
|
||||
it('between({ width: [50, 100] }) returns FluentRelation directly', () => {
|
||||
const chain = expect('.box').to.be.between({ width: [50, 100] })
|
||||
assert.strictEqual(chain.relation, 'size.between')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.min, 50)
|
||||
assert.strictEqual(chain.options.max, 100)
|
||||
})
|
||||
|
||||
it('between({ height: [20, 80] }) returns height FluentRelation', () => {
|
||||
const chain = expect('.box').to.be.between({ height: [20, 80] })
|
||||
assert.strictEqual(chain.relation, 'size.between')
|
||||
assert.strictEqual(chain.options.dimension, 'height')
|
||||
assert.strictEqual(chain.options.min, 20)
|
||||
assert.strictEqual(chain.options.max, 80)
|
||||
})
|
||||
|
||||
// ---- Backward Compatibility ----
|
||||
|
||||
it('atLeast("44px").wide still returns SizeProxy (old syntax)', () => {
|
||||
const proxy = expect('.box').to.be.atLeast('44px')
|
||||
assert.ok(proxy)
|
||||
assert.strictEqual(typeof proxy.wide, 'object')
|
||||
const chain = proxy.wide
|
||||
assert.strictEqual(chain.relation, 'size.atLeast')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.value, '44px')
|
||||
})
|
||||
|
||||
it('atLeast("44px").tall still works (old syntax)', () => {
|
||||
const chain = expect('.box').to.be.atLeast('44px').tall
|
||||
assert.strictEqual(chain.relation, 'size.atLeast')
|
||||
assert.strictEqual(chain.options.dimension, 'height')
|
||||
assert.strictEqual(chain.options.value, '44px')
|
||||
})
|
||||
|
||||
it('atMost("100px").wide still works (old syntax)', () => {
|
||||
const chain = expect('.box').to.be.atMost('100px').wide
|
||||
assert.strictEqual(chain.relation, 'size.atMost')
|
||||
assert.strictEqual(chain.options.dimension, 'width')
|
||||
assert.strictEqual(chain.options.value, '100px')
|
||||
})
|
||||
|
||||
it('between("50px", "100px").tall still works (old syntax)', () => {
|
||||
const chain = expect('.box').to.be.between('50px', '100px').tall
|
||||
assert.strictEqual(chain.relation, 'size.between')
|
||||
assert.strictEqual(chain.options.dimension, 'height')
|
||||
assert.strictEqual(chain.options.min, '50px')
|
||||
assert.strictEqual(chain.options.max, '100px')
|
||||
})
|
||||
|
||||
it('new overloads produce correct AST via toAst()', () => {
|
||||
const chain = expect('.box').to.be.atLeast(44, 'width')
|
||||
const ast = chain.toAst()
|
||||
assert.strictEqual(ast.relation, 'size.atLeast')
|
||||
const opts = ast.options as unknown as Record<string, unknown>
|
||||
assert.strictEqual(opts.dimension, 'width')
|
||||
assert.strictEqual(opts.value, 44)
|
||||
assert.strictEqual(opts.mode, 'atLeast')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Validator Tests ----
|
||||
|
||||
describe('validator', () => {
|
||||
it('accepts a valid leftOf with minGap', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
|
||||
const result = validateRelation(chain.toAst())
|
||||
assert.strictEqual(result.valid, true)
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
})
|
||||
|
||||
it('rejects unknown relation', () => {
|
||||
// Manually construct an invalid AST
|
||||
const ast = expect('.a').to.be.leftOf('.b').toAst()
|
||||
ast.relation = 'unknownRelation'
|
||||
const result = validateRelation(ast)
|
||||
assert.strictEqual(result.valid, false)
|
||||
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_ILLEGAL_RELATION_OPTION')
|
||||
})
|
||||
|
||||
it('rejects invalid option for relation', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { axis: 'centerY' })
|
||||
const result = validateRelation(chain.toAst())
|
||||
assert.strictEqual(result.valid, false)
|
||||
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_ILLEGAL_RELATION_OPTION')
|
||||
})
|
||||
|
||||
it('rejects contradictory gap bounds', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { minGap: 30, maxGap: 10 })
|
||||
const result = validateRelation(chain.toAst())
|
||||
assert.strictEqual(result.valid, false)
|
||||
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_CONTRADICTION')
|
||||
})
|
||||
|
||||
it('rejects negative gap', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { minGap: -5 })
|
||||
const result = validateRelation(chain.toAst())
|
||||
assert.strictEqual(result.valid, false)
|
||||
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_INVALID_UNIT')
|
||||
})
|
||||
|
||||
it('accepts valid tolerance in px', () => {
|
||||
const chain = expect('.a').to.be.centeredWithin('.b', { tolerance: 2 })
|
||||
const result = validateRelation(chain.toAst())
|
||||
assert.strictEqual(result.valid, true)
|
||||
})
|
||||
|
||||
it('accepts valid tolerance in jnd', () => {
|
||||
const chain = expect('.a').to.be.centeredWithin('.b', { tolerance: '1jnd' })
|
||||
const result = validateRelation(chain.toAst())
|
||||
assert.strictEqual(result.valid, true)
|
||||
})
|
||||
|
||||
it('parses tolerance correctly', () => {
|
||||
assert.deepStrictEqual(parseTolerance(1), { value: 1, unit: 'px' })
|
||||
assert.deepStrictEqual(parseTolerance('0.5jnd'), { value: 0.5, unit: 'jnd' })
|
||||
assert.strictEqual(parseTolerance('bad'), null)
|
||||
assert.strictEqual(parseTolerance(undefined), null)
|
||||
})
|
||||
|
||||
it('parses gap correctly', () => {
|
||||
assert.deepStrictEqual(parseGap(16), { value: 16, unit: 'px' })
|
||||
assert.deepStrictEqual(parseGap('24'), { value: 24, unit: 'px' })
|
||||
assert.strictEqual(parseGap(-1)?.value, -1)
|
||||
})
|
||||
|
||||
it('validates batch of assertions', () => {
|
||||
const assertions = [
|
||||
expect('.a').to.be.leftOf('.b', { minGap: 16 }).toAst(),
|
||||
expect('.c').to.be.above('.d', { maxGap: 10 }).toAst(),
|
||||
]
|
||||
const result = validateProgram(assertions)
|
||||
assert.strictEqual(result.valid, true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Compiler Tests ----
|
||||
|
||||
describe('compiler', () => {
|
||||
it('compiles a single relation to semantic IR', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
|
||||
const result = compile([chain.toAst()])
|
||||
|
||||
assert.strictEqual(result.ast.type, 'Program')
|
||||
assert.strictEqual(result.semanticIr.clauses.size, 1)
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
assert.strictEqual(clause.relation, 'leftOf')
|
||||
assert.strictEqual(clause.bounds.minGap?.value, 16)
|
||||
assert.strictEqual(clause.bounds.minGap?.unit, 'px')
|
||||
assert.ok(clause.frameRef)
|
||||
})
|
||||
|
||||
it('compiles frame attachment into semantic IR', () => {
|
||||
const chain = expect('.tooltip')
|
||||
.in(frame.nearestPositionedAncestor('.trigger'))
|
||||
.to.be.rightOf('.trigger', { minGap: 8 })
|
||||
|
||||
const result = compile([chain.toAst()])
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
const frameDef = result.semanticIr.frames.get(clause.frameRef)
|
||||
assert.strictEqual(frameDef?.kind, 'nearestPositionedAncestor')
|
||||
assert.strictEqual(frameDef?.selector, '.trigger')
|
||||
})
|
||||
|
||||
it('compiles state attachment into semantic IR', () => {
|
||||
const chain = expect('.button')
|
||||
.inState(state.hover())
|
||||
.to.be.above('.label')
|
||||
|
||||
const result = compile([chain.toAst()])
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
const stateDef = result.semanticIr.states.get(clause.stateRef)
|
||||
assert.strictEqual(stateDef?.kind, 'hover')
|
||||
})
|
||||
|
||||
it('compiles quantifier into flags', () => {
|
||||
const chain = expect('.toolbar button').all.to.be.leftOf('.sidebar')
|
||||
const result = compile([chain.toAst()])
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
const flags = (clause as unknown as Record<string, unknown>).flags as number
|
||||
assert.ok(flags & 1) // all flag
|
||||
})
|
||||
|
||||
it('compiles tolerance into semantic IR', () => {
|
||||
const chain = expect('.dialog').to.be.centeredWithin('.viewport', {
|
||||
tolerance: '1jnd',
|
||||
})
|
||||
const result = compile([chain.toAst()])
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
const tolDef = result.semanticIr.tolerances.get(clause.toleranceRef)
|
||||
assert.strictEqual(tolDef?.value, 1)
|
||||
assert.strictEqual(tolDef?.unit, 'jnd')
|
||||
})
|
||||
|
||||
it('compiles environment guard into semantic IR', () => {
|
||||
const chain = expect('.sidebar')
|
||||
.when('width >= 768')
|
||||
.to.be.leftOf('.content')
|
||||
|
||||
const result = compile([chain.toAst()])
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
const guardDef = result.semanticIr.envGuards.get(clause.envGuardRef)
|
||||
assert.strictEqual(guardDef?.expression, 'width >= 768')
|
||||
})
|
||||
|
||||
it('lowers semantic IR to execution IR', () => {
|
||||
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16, maxGap: 20 })
|
||||
const result = compile([chain.toAst()])
|
||||
|
||||
assert.strictEqual(result.executionIr.clauseCount, 1)
|
||||
assert.strictEqual(result.executionIr.clauseType[0], 1) // leftOf code
|
||||
assert.strictEqual(result.executionIr.clauseArg0[0], 16)
|
||||
assert.strictEqual(result.executionIr.clauseArg1[0], 20)
|
||||
assert.strictEqual(result.executionIr.clauseFlags[0], 0)
|
||||
})
|
||||
|
||||
it('handles empty assertion list', () => {
|
||||
const result = compile([])
|
||||
assert.strictEqual(result.semanticIr.clauses.size, 0)
|
||||
assert.strictEqual(result.executionIr.clauseCount, 0)
|
||||
})
|
||||
|
||||
it('attaches source spans in semantic IR', () => {
|
||||
const span = {
|
||||
start: { line: 2, column: 5, offset: 17 },
|
||||
end: { line: 2, column: 51, offset: 63 },
|
||||
}
|
||||
const chain = expect('.a', span).to.be.leftOf('.b')
|
||||
const result = compile([chain.toAst()])
|
||||
const clause = Array.from(result.semanticIr.clauses.values())[0]
|
||||
assert.deepStrictEqual(clause.origin.positions[0], span)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Parser Placeholder Tests ----
|
||||
|
||||
describe('parser placeholder', () => {
|
||||
it('returns empty AST for empty input', () => {
|
||||
const result = parseSpec('')
|
||||
assert.strictEqual(result.ast.children.length, 0)
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
})
|
||||
|
||||
it('parses dense DSL input', () => {
|
||||
const result = parseSpec(`in viewport:\n '.a' leftOf '.b'`)
|
||||
assert.strictEqual(result.ast.children.length, 1)
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
const block = result.ast.children[0] as any
|
||||
assert.strictEqual(block.type, 'FrameBlock')
|
||||
assert.strictEqual(block.children[0].type, 'RelationAssertion')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
// FOL Dense DSL Combination Tests
|
||||
// Systematically tests every First-Order Logic combination in dense DSL syntax
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { parseSpec } from './parser.js'
|
||||
import { tokenize } from './lexer.js'
|
||||
import { parseGrammar } from './grammar.js'
|
||||
import { lowerFormula, lowerProgram } from './logic-compiler.js'
|
||||
import { validateFormula, validateProgram } from './logic-validator.js'
|
||||
import { compileDenseFOLToFormula } from './compiler.js'
|
||||
import { extractFOLFormulas } from './lower-to-canonical.js'
|
||||
import type { FormulaNode } from './logic-fluent.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDense(source: string) {
|
||||
const tokens = tokenize(source)
|
||||
const ast = parseGrammar(tokens)
|
||||
return ast
|
||||
}
|
||||
|
||||
function getFormula(ast: any): FormulaNode | null {
|
||||
const formulas = ast.children.filter(
|
||||
(child: any) => child.type === 'ForAll' || child.type === 'Exists' || child.type === 'And' || child.type === 'Or' || child.type === 'Not' || child.type === 'Implies'
|
||||
)
|
||||
return formulas.length > 0 ? formulas[0] : null
|
||||
}
|
||||
|
||||
function testParses(name: string, source: string) {
|
||||
it(`parses: ${name}`, () => {
|
||||
const result = parseSpec(source)
|
||||
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula, `Expected formula node in AST, got children: ${result.ast.children.map((c: any) => c.type).join(', ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
function testLowers(name: string, source: string, expectedType: string) {
|
||||
it(`lowers: ${name}`, () => {
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula, 'Expected formula')
|
||||
const ir = lowerFormula(formula!)
|
||||
assert.strictEqual(ir.type, expectedType, `Expected ${expectedType} but got ${ir.type}`)
|
||||
})
|
||||
}
|
||||
|
||||
function testValidates(name: string, source: string, expectedValid: boolean) {
|
||||
it(`validates: ${name}`, () => {
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula, 'Expected formula')
|
||||
const validation = validateFormula(formula!)
|
||||
assert.strictEqual(validation.valid, expectedValid, `Expected valid=${expectedValid} but got: ${validation.diagnostics.map(d => d.message).join(', ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dense DSL FOL Combination Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FOL Dense DSL - Simple Quantifiers', () => {
|
||||
testParses('forall with simple predicate',
|
||||
`forall $btn in elements('.button'):\n width($btn) >= 44`)
|
||||
|
||||
testParses('exists with simple predicate',
|
||||
`exists $title in elements('.title'):\n above($title, '.content')`)
|
||||
|
||||
testParses('forall with elements domain',
|
||||
`forall $btn in elements('.button'):\n leftOf($btn, '.label')`)
|
||||
|
||||
testParses('forall with descendants domain',
|
||||
`forall $item in descendants('.card', '.item'):\n inside($item, '.container')`)
|
||||
|
||||
testParses('exists with lineBoxes domain',
|
||||
`exists $box in lineBoxes('.text'):\n overlaps($box, '.highlight')`)
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Boolean Connectives', () => {
|
||||
testParses('forall + and with spatial predicates',
|
||||
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') and above($item, '.footer')`)
|
||||
|
||||
testParses('forall + or',
|
||||
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') or above($item, '.footer')`)
|
||||
|
||||
testParses('forall + implies',
|
||||
`forall $modal in elements('.modal'):\n visible($modal) implies centeredWithin($modal, 'viewport')`)
|
||||
|
||||
testParses('exists + and with spatial predicates',
|
||||
`exists $item in elements('.item'):\n leftOf($item, '.sidebar') and above($item, '.footer')`)
|
||||
|
||||
testParses('exists + or',
|
||||
`exists $item in elements('.item'):\n leftOf($item, '.sidebar') or above($item, '.footer')`)
|
||||
|
||||
testParses('forall + nested and',
|
||||
`forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c') and inside($a, '.d')`)
|
||||
|
||||
testParses('forall + nested or',
|
||||
`forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c') or inside($a, '.d')`)
|
||||
|
||||
it('forall + and with size predicates (width/height) parses successfully', () => {
|
||||
// width/height are now recognized as predicate keywords
|
||||
const source = `forall $btn in elements('.button'):\n width($btn) >= 44 and height($btn) >= 44`
|
||||
const result = parseSpec(source)
|
||||
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula, 'Expected formula')
|
||||
assert.strictEqual(formula?.type, 'ForAll')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Negation', () => {
|
||||
testParses('forall + not prefix',
|
||||
`forall $el in elements('.error'):\n not overlaps($el, '.success')`)
|
||||
|
||||
testParses('exists + not prefix',
|
||||
`exists $el in elements('.error'):\n not overlaps($el, '.success')`)
|
||||
|
||||
testParses('not + forall',
|
||||
`not (forall $btn in elements('.button'):\n leftOf($btn, '.label'))`)
|
||||
|
||||
testParses('not + exists',
|
||||
`not (exists $btn in elements('.button'):\n leftOf($btn, '.label'))`)
|
||||
|
||||
testParses('forall + not + and',
|
||||
`forall $a in elements('.a'):\n not leftOf($a, '.b') and not above($a, '.c')`)
|
||||
|
||||
testParses('not + forall + and',
|
||||
`not (forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c'))`)
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Nested Quantifiers', () => {
|
||||
testParses('forall + exists with string selectors',
|
||||
`forall $card in elements('.card'):\n exists $title in descendants('.card', '.title'):\n above($title, '.content')`)
|
||||
|
||||
testParses('exists + forall with string selectors',
|
||||
`exists $container in elements('.container'):\n forall $item in descendants('.container', '.item'):\n inside($item, '.container')`)
|
||||
|
||||
testParses('forall + forall',
|
||||
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n leftOf($a, $b)`)
|
||||
|
||||
testParses('exists + exists',
|
||||
`exists $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`)
|
||||
|
||||
testParses('triple nesting',
|
||||
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n exists $c in elements('.c'):\n leftOf($a, $c) and above($b, $c)`)
|
||||
|
||||
it('descendants($var, selector) syntax is supported', () => {
|
||||
// Variable as first arg to descendants now supported
|
||||
const source = `forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`
|
||||
const result = parseSpec(source)
|
||||
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula, 'Expected formula')
|
||||
// Verify nested quantifier with variable domain is present
|
||||
assert.strictEqual(formula?.type, 'ForAll')
|
||||
})
|
||||
|
||||
it('GAP: variable reference as second arg in predicate not supported', () => {
|
||||
// Using $container as second arg
|
||||
const source = `exists $container in elements('.container'):\n forall $item in elements('.item'):\n inside($item, $container)`
|
||||
const result = parseSpec(source)
|
||||
// This might parse or might fail depending on grammar
|
||||
// Document actual behavior
|
||||
const formula = getFormula(result.ast)
|
||||
if (!formula) {
|
||||
assert.ok(result.diagnostics.length > 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Complex Combinations', () => {
|
||||
testParses('forall + exists + and + or',
|
||||
`forall $a in elements('.a'):\n exists $b in elements('.b'):\n (leftOf($a, $b) or above($a, $b)) and not overlaps($a, $b)`)
|
||||
|
||||
testParses('forall + implies + exists',
|
||||
`forall $btn in elements('.button'):\n visible($btn) implies exists $label in elements('.label'):\n above($label, $btn)`)
|
||||
|
||||
testParses('forall + or + not',
|
||||
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') or not overlaps($item, '.overlay')`)
|
||||
|
||||
testParses('exists + and + implies',
|
||||
`exists $modal in elements('.modal'):\n visible($modal) and (focused($modal) implies centeredWithin($modal, 'viewport'))`)
|
||||
|
||||
testParses('forall + complex nested',
|
||||
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n (leftOf($a, $b) and above($a, $b)) implies not overlaps($a, $b)`)
|
||||
|
||||
testParses('not + exists + and',
|
||||
`not (exists $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c'))`)
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Lowering Verification', () => {
|
||||
testLowers('forall lowers to LogicForAll',
|
||||
`forall $btn in elements('.button'):\n leftOf($btn, '.label')`,
|
||||
'LogicForAll')
|
||||
|
||||
testLowers('exists lowers to LogicExists',
|
||||
`exists $title in elements('.title'):\n above($title, '.content')`,
|
||||
'LogicExists')
|
||||
|
||||
testLowers('and lowers to LogicAnd',
|
||||
`forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`,
|
||||
'LogicForAll')
|
||||
|
||||
testLowers('or lowers to LogicOr (inside body)',
|
||||
`forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c')`,
|
||||
'LogicForAll')
|
||||
|
||||
testLowers('not lowers to LogicNot',
|
||||
`forall $a in elements('.a'):\n not leftOf($a, '.b')`,
|
||||
'LogicForAll')
|
||||
|
||||
testLowers('implies lowers to LogicImplies',
|
||||
`forall $a in elements('.a'):\n leftOf($a, '.b') implies above($a, '.c')`,
|
||||
'LogicForAll')
|
||||
|
||||
testLowers('nested forall+exists',
|
||||
`forall $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`,
|
||||
'LogicForAll')
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Validation Verification', () => {
|
||||
it('validates: closed forall formula', () => {
|
||||
const source = `forall $btn in elements('.button'):\n leftOf($btn, '.label')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const validation = validateFormula(formula)
|
||||
assert.strictEqual(validation.valid, true)
|
||||
})
|
||||
|
||||
it('validates: free variable detected', () => {
|
||||
// forall $btn but uses $bad inside
|
||||
const source = `forall $btn in elements('.button'):\n leftOf($bad, '.label')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const validation = validateFormula(formula)
|
||||
assert.strictEqual(validation.valid, false)
|
||||
assert.ok(validation.diagnostics.some(d => d.message.includes('Free variable') || d.message.includes('free variable')))
|
||||
})
|
||||
|
||||
it('validates: nested quantifiers with correct scoping', () => {
|
||||
const source = `forall $card in elements('.card'):\n exists $title in descendants('.card', '.title'):\n above($title, '.content')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const validation = validateFormula(formula)
|
||||
assert.strictEqual(validation.valid, true)
|
||||
})
|
||||
|
||||
it('validates: nested quantifiers with variable capture', () => {
|
||||
// $card used in inner scope where it should be accessible (capture is correct)
|
||||
const source = `forall $card in elements('.card'):\n exists $title in elements('.title'):\n above($title, $card)`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const validation = validateFormula(formula)
|
||||
assert.strictEqual(validation.valid, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FOL Dense DSL - Edge Cases', () => {
|
||||
it('parses: forall with predicate taking no arguments', () => {
|
||||
const source = `forall $btn in elements('.button'):\n focused()`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula)
|
||||
})
|
||||
|
||||
it('parses: forall with predicate taking string argument', () => {
|
||||
const source = `forall $btn in elements('.button'):\n matchesSelector($btn, '.primary')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula)
|
||||
})
|
||||
|
||||
it('parses: forall with predicate taking numeric argument', () => {
|
||||
const source = `forall $btn in elements('.button'):\n minWidth($btn, 44)`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)
|
||||
assert.ok(formula)
|
||||
})
|
||||
|
||||
it('parses: multiple independent quantifiers in program', () => {
|
||||
const source = `forall $a in elements('.a'):\n leftOf($a, '.b')\n\nexists $c in elements('.c'):\n above($c, '.d')`
|
||||
const result = parseSpec(source)
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
const formulas = result.ast.children.filter(
|
||||
(child: any) => child.type === 'ForAll' || child.type === 'Exists'
|
||||
)
|
||||
assert.strictEqual(formulas.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-End Lowering: DSL AST -> Solver FormulaNode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FOL Dense DSL - Solver Formula Lowering', () => {
|
||||
it('compileDenseFOLToFormula: forall -> solver forall', () => {
|
||||
const source = `forall $btn in elements('.button'):\n leftOf($btn, '.label')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
assert.strictEqual(solverFormula.type, 'FormulaNode')
|
||||
assert.strictEqual(solverFormula.kind, 'forall')
|
||||
assert.ok(Array.isArray(solverFormula.bindings))
|
||||
assert.strictEqual(solverFormula.bindings.length, 1)
|
||||
assert.strictEqual(solverFormula.bindings[0].type, 'TupleBinding')
|
||||
assert.deepStrictEqual(solverFormula.bindings[0].variables, ['btn'])
|
||||
assert.strictEqual(solverFormula.bindings[0].domain.type, 'DomainRef')
|
||||
assert.strictEqual(solverFormula.bindings[0].domain.domain, 'elements')
|
||||
assert.strictEqual(solverFormula.bindings[0].domain.selector, '.button')
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: exists -> solver exists', () => {
|
||||
const source = `exists $title in elements('.title'):\n above($title, '.content')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
assert.strictEqual(solverFormula.type, 'FormulaNode')
|
||||
assert.strictEqual(solverFormula.kind, 'exists')
|
||||
assert.strictEqual(solverFormula.bindings[0].variables[0], 'title')
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: and -> solver and', () => {
|
||||
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
assert.strictEqual(solverFormula.type, 'FormulaNode')
|
||||
assert.strictEqual(solverFormula.kind, 'forall')
|
||||
const andBody = (solverFormula as any).body
|
||||
assert.strictEqual(andBody.type, 'FormulaNode')
|
||||
assert.strictEqual(andBody.kind, 'and')
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: or -> solver or', () => {
|
||||
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
assert.strictEqual((solverFormula as any).body.kind, 'or')
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: not -> solver not', () => {
|
||||
const source = `forall $a in elements('.a'):\n not leftOf($a, '.b')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
assert.strictEqual((solverFormula as any).body.kind, 'not')
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: implies -> solver implies', () => {
|
||||
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') implies above($a, '.c')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
const impliesBody = (solverFormula as any).body
|
||||
assert.strictEqual(impliesBody.kind, 'implies')
|
||||
assert.ok(impliesBody.antecedent)
|
||||
assert.ok(impliesBody.consequent)
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: nested forall+exists', () => {
|
||||
const source = `forall $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula)
|
||||
|
||||
assert.strictEqual(solverFormula.kind, 'forall')
|
||||
const existsBody = (solverFormula as any).body
|
||||
assert.strictEqual(existsBody.kind, 'exists')
|
||||
const predBody = existsBody.body
|
||||
assert.strictEqual(predBody.kind, 'predicate')
|
||||
assert.strictEqual(predBody.predicate, 'leftOf')
|
||||
assert.strictEqual(predBody.args.length, 2)
|
||||
assert.strictEqual(predBody.args[0].type, 'VariableRef')
|
||||
assert.strictEqual(predBody.args[0].name, 'a')
|
||||
assert.strictEqual(predBody.args[1].type, 'VariableRef')
|
||||
assert.strictEqual(predBody.args[1].name, 'b')
|
||||
})
|
||||
|
||||
it('compileDenseFOLToFormula: descendants($var, selector) maps selector + parentVar correctly', () => {
|
||||
const source = `forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`
|
||||
const result = parseSpec(source)
|
||||
const formula = getFormula(result.ast)!
|
||||
const solverFormula = compileDenseFOLToFormula(formula) as any
|
||||
|
||||
assert.strictEqual(solverFormula.kind, 'forall')
|
||||
const existsBody = solverFormula.body
|
||||
assert.strictEqual(existsBody.kind, 'exists')
|
||||
assert.strictEqual(existsBody.bindings[0].domain.domain, 'descendants')
|
||||
assert.strictEqual(existsBody.bindings[0].domain.selector, '.title')
|
||||
assert.strictEqual(existsBody.bindings[0].domain.parentVar, '$card')
|
||||
})
|
||||
|
||||
it('extractFOLFormulas returns formulas from mixed AST', () => {
|
||||
const source = `forall $a in elements('.a'):\n leftOf($a, '.b')\n\n'[data-testid="x"]' above '[data-testid="y"]'`
|
||||
const result = parseSpec(source)
|
||||
const formulas = extractFOLFormulas(result.ast)
|
||||
|
||||
assert.strictEqual(formulas.length, 1)
|
||||
assert.strictEqual(formulas[0].type, 'ForAll')
|
||||
})
|
||||
|
||||
it('extractFOLFormulas ignores non-FOL assertions', () => {
|
||||
const source = `'[data-testid="x"]' above '[data-testid="y"]'`
|
||||
const result = parseSpec(source)
|
||||
const formulas = extractFOLFormulas(result.ast)
|
||||
|
||||
assert.strictEqual(formulas.length, 0)
|
||||
})
|
||||
|
||||
it('extractFOLFormulas finds formulas nested in frame blocks', () => {
|
||||
const source = `in viewport:\n forall $cat in elements('.showroom-category'):\n width($cat) >= 200`
|
||||
const result = parseSpec(source)
|
||||
const formulas = extractFOLFormulas(result.ast)
|
||||
|
||||
assert.strictEqual(formulas.length, 1)
|
||||
assert.strictEqual(formulas[0].type, 'ForAll')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gap Reporting Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FOL Dense DSL - Gap Detection', () => {
|
||||
// These tests document known gaps in the dense DSL FOL support
|
||||
|
||||
it('GAP: dense DSL does not support size between assertions', () => {
|
||||
// between is only in fluent API, not dense DSL grammar
|
||||
const source = `forall $btn in elements('.button'):\n between($btn, 44, 100)`
|
||||
const result = parseSpec(source)
|
||||
// May parse as predicate call or fail
|
||||
assert.ok(result.diagnostics.length > 0 || result.ast.children.length === 0,
|
||||
'Expected parse failure or no formula for between in dense DSL')
|
||||
})
|
||||
|
||||
it('GAP: dense DSL does not support separatedFrom relation', () => {
|
||||
const source = `forall $a in elements('.a'):\n separatedFrom($a, '.b')`
|
||||
const result = parseSpec(source)
|
||||
// separatedFrom may not be in keyword list
|
||||
assert.ok(result.diagnostics.length >= 0)
|
||||
})
|
||||
|
||||
it('GAP: dense DSL does not support contains relation', () => {
|
||||
// contains exists in fluent but may not parse in dense
|
||||
const source = `forall $a in elements('.a'):\n contains($a, '.b')`
|
||||
const result = parseSpec(source)
|
||||
// This might parse as generic predicate
|
||||
const formula = getFormula(result.ast)
|
||||
if (formula) {
|
||||
assert.ok(true, 'contains parsed as generic predicate')
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user