2025-08-15 10:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* Tests for the Playwright-specific public API.
|
|
|
|
|
*
|
|
|
|
|
* Uses a minimal Playwright page mock to verify the UI handle
|
|
|
|
|
* creation, assertion accumulation, and result structure.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it } from 'node:test'
|
|
|
|
|
import assert from 'node:assert'
|
|
|
|
|
|
|
|
|
|
import { imhotep, ImhotepUi, imhotepComponent, imhotepStory, imhotepFixture } from './public.js'
|
|
|
|
|
import { defaultEnvironment } from './fixture.js'
|
|
|
|
|
import { react, storybook } from './renderers.js'
|
2026-05-22 10:20:06 -07:00
|
|
|
import { SelectorDomainResolver, buildAncestorIndex, computeRequiredFacts } from './extraction.js'
|
2026-05-21 17:05:35 -07:00
|
|
|
import { BindingEnv } from 'imhotep-solver'
|
|
|
|
|
import type { GeometryWorld } from 'imhotep-solver'
|
2025-08-15 10:00:00 -07:00
|
|
|
|
|
|
|
|
// Minimal Playwright page mock
|
|
|
|
|
function createMockPage(): any {
|
|
|
|
|
return {
|
|
|
|
|
locator: (_selector: string) => ({
|
|
|
|
|
hover: async () => {},
|
|
|
|
|
focus: async () => {},
|
|
|
|
|
}),
|
|
|
|
|
setViewportSize: async () => {},
|
|
|
|
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
|
|
|
emulateMedia: async () => {},
|
|
|
|
|
evaluate: async (fn: any, ...args: any[]) => {
|
|
|
|
|
// Mock the extraction evaluate call: fn is the browser function,
|
|
|
|
|
// args[0] is the selectors array. Return a map with mock rects.
|
|
|
|
|
if (typeof fn === 'function' && Array.isArray(args[0])) {
|
|
|
|
|
const selectors: string[] = args[0]
|
|
|
|
|
const map: Record<string, any> = {}
|
|
|
|
|
for (const sel of selectors) {
|
|
|
|
|
map[sel] = [{
|
|
|
|
|
tagName: 'DIV',
|
|
|
|
|
rect: { x: 0, y: 0, width: 100, height: 100 },
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
return map
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
},
|
|
|
|
|
addInitScript: async () => {},
|
|
|
|
|
mouse: { move: async () => {} },
|
|
|
|
|
keyboard: { press: async () => {} },
|
|
|
|
|
context: () => ({}),
|
|
|
|
|
close: async () => {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mock page where specific selectors return no matches
|
|
|
|
|
function createMockPageWithMissingSelectors(): any {
|
|
|
|
|
return {
|
|
|
|
|
locator: (_selector: string) => ({
|
|
|
|
|
hover: async () => {},
|
|
|
|
|
focus: async () => {},
|
|
|
|
|
}),
|
|
|
|
|
setViewportSize: async () => {},
|
|
|
|
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
|
|
|
emulateMedia: async () => {},
|
|
|
|
|
evaluate: async (fn: any, ...args: any[]) => {
|
|
|
|
|
if (typeof fn === 'function' && Array.isArray(args[0])) {
|
|
|
|
|
const selectors: string[] = args[0]
|
|
|
|
|
const map: Record<string, any> = {}
|
|
|
|
|
for (const sel of selectors) {
|
|
|
|
|
// Simulate missing selectors
|
|
|
|
|
if (sel.includes('does-not-exist') || sel.includes('also-missing')) {
|
|
|
|
|
map[sel] = []
|
|
|
|
|
} else {
|
|
|
|
|
map[sel] = [{
|
|
|
|
|
tagName: 'DIV',
|
|
|
|
|
rect: { x: 0, y: 0, width: 100, height: 100 },
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return map
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
},
|
|
|
|
|
addInitScript: async () => {},
|
|
|
|
|
mouse: { move: async () => {} },
|
|
|
|
|
keyboard: { press: async () => {} },
|
|
|
|
|
context: () => ({}),
|
|
|
|
|
close: async () => {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFastGeometryMockPage(missingSelectors: string[] = []): any {
|
|
|
|
|
const missing = new Set(missingSelectors)
|
|
|
|
|
return {
|
|
|
|
|
locator: (_selector: string) => ({
|
|
|
|
|
hover: async () => {},
|
|
|
|
|
focus: async () => {},
|
|
|
|
|
}),
|
|
|
|
|
setViewportSize: async () => {},
|
|
|
|
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
|
|
|
emulateMedia: async () => {},
|
|
|
|
|
evaluate: async (_fn: any, ...args: any[]) => {
|
|
|
|
|
const payload = args[0]
|
|
|
|
|
if (!payload || !Array.isArray(payload.plans)) return undefined
|
|
|
|
|
const elements: Array<any> = []
|
|
|
|
|
const selectorToIds: Array<[string, number[]]> = []
|
|
|
|
|
for (const plan of payload.plans as Array<{ key: string; queries: string[] }>) {
|
|
|
|
|
const ids: number[] = []
|
|
|
|
|
if (!missing.has(plan.key)) {
|
|
|
|
|
for (const _query of plan.queries) {
|
|
|
|
|
const subjectId = elements.length + 1
|
|
|
|
|
elements.push({
|
|
|
|
|
tagName: 'div',
|
|
|
|
|
rect: { x: 0, y: 0, width: 100, height: 100 },
|
|
|
|
|
transform: { matrix: [1, 0, 0, 1, 0, 0], originX: 0, originY: 0 },
|
|
|
|
|
})
|
|
|
|
|
ids.push(subjectId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
selectorToIds.push([plan.key, ids])
|
|
|
|
|
}
|
|
|
|
|
return { elements, selectorToIds }
|
|
|
|
|
},
|
|
|
|
|
addInitScript: async () => {},
|
|
|
|
|
mouse: { move: async () => {} },
|
|
|
|
|
keyboard: { press: async () => {} },
|
|
|
|
|
context: () => ({}),
|
|
|
|
|
close: async () => {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Playwright public API', () => {
|
|
|
|
|
it('creates a UI handle from a page', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
assert.ok(ui)
|
|
|
|
|
assert.strictEqual(typeof ui.expect, 'function')
|
|
|
|
|
assert.strictEqual(typeof ui.checkAll, 'function')
|
|
|
|
|
assert.strictEqual(typeof ui.compatibilityReport, 'function')
|
|
|
|
|
assert.strictEqual(ui.playwrightPage, page)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('provides runtime compatibility report checks', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const report = ui.compatibilityReport()
|
|
|
|
|
assert.strictEqual(typeof report.ok, 'boolean')
|
|
|
|
|
assert.strictEqual(Array.isArray(report.checks), true)
|
|
|
|
|
assert.ok(report.checks.some((c) => c.id === 'dsl-units-vw-ch'))
|
|
|
|
|
assert.ok(report.checks.some((c) => c.id === 'package-fingerprints-resolved'))
|
|
|
|
|
assert.ok(report.checks.some((c) => c.id === 'quantifier-empty-domain-semantics'))
|
|
|
|
|
assert.ok(report.checks.some((c) => c.id === 'inside-overflow-orientation'))
|
|
|
|
|
assert.ok(Array.isArray(report.packageFingerprints))
|
|
|
|
|
assert.ok(report.packageFingerprints.some((p) => p.name === 'imhotep-playwright'))
|
|
|
|
|
assert.ok(report.packageFingerprints.some((p) => p.name === 'imhotep-dsl'))
|
|
|
|
|
assert.ok(report.packageFingerprints.some((p) => p.name === 'imhotep-solver'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('creates fluent assertions via ui.expect', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.button')
|
|
|
|
|
.to.be.leftOf('.label', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.assertion.getSubject(), '.button')
|
|
|
|
|
assert.strictEqual(chain.relation, 'leftOf')
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-21 12:31:35 -07:00
|
|
|
it('compound .and chaining creates compound assertion with both parts', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.a')
|
|
|
|
|
.to.be.leftOf('.b', { minGap: 8 }).and.above('.c', { maxGap: 16 })
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.relation, 'above')
|
|
|
|
|
const parts = (chain as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: Record<string, unknown> }> | undefined
|
|
|
|
|
assert.ok(parts, 'compound parts should be defined')
|
|
|
|
|
assert.strictEqual(parts!.length, 2)
|
|
|
|
|
assert.strictEqual(parts![0].relation, 'leftOf')
|
|
|
|
|
assert.strictEqual(parts![0].referenceSelector, '.b')
|
|
|
|
|
assert.strictEqual(parts![1].relation, 'above')
|
|
|
|
|
assert.strictEqual(parts![1].referenceSelector, '.c')
|
|
|
|
|
assert.strictEqual(parts![1].options.maxGap, 16)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('compound .or chaining creates compound assertion with both parts', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.x')
|
|
|
|
|
.to.be.inside('.y').or.centeredWithin('.z')
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.relation, 'centeredWithin')
|
|
|
|
|
const parts = (chain as any)._compoundParts as Array<{ relation: string; referenceSelector: string }> | undefined
|
|
|
|
|
assert.ok(parts, 'compound parts should be defined')
|
|
|
|
|
assert.strictEqual(parts!.length, 2)
|
|
|
|
|
assert.strictEqual(parts![0].relation, 'inside')
|
|
|
|
|
assert.strictEqual(parts![1].relation, 'centeredWithin')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('triple .and chaining accumulates all parts', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.a')
|
|
|
|
|
.to.be.leftOf('.b').and.above('.c').and.inside('.d')
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.relation, 'inside')
|
|
|
|
|
const parts = (chain as any)._compoundParts as Array<{ relation: string; referenceSelector: string }> | undefined
|
|
|
|
|
assert.ok(parts, 'compound parts should be defined for triple chain')
|
|
|
|
|
assert.strictEqual(parts!.length, 3)
|
|
|
|
|
assert.strictEqual(parts![0].relation, 'leftOf')
|
|
|
|
|
assert.strictEqual(parts![1].relation, 'above')
|
|
|
|
|
assert.strictEqual(parts![2].relation, 'inside')
|
|
|
|
|
assert.strictEqual(parts![2].referenceSelector, '.d')
|
|
|
|
|
})
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
it('supports quantifier entry via ui.expect.all(subject)', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect.all('.item').to.be.atLeast(44, 'width')
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.relation, 'size.atLeast')
|
|
|
|
|
assert.strictEqual(chain.assertion.getQuantifier(), 'all')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts ui.quantifier(ui.expect.all(...)) chain without TypeError', async () => {
|
|
|
|
|
const page = createFastGeometryMockPage(['.does-not-exist'])
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.quantifier(ui.expect.all('.does-not-exist').to.be.atLeast(1, 'height') as any)
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('treats all-quantifier over zero matches as vacuous pass', async () => {
|
|
|
|
|
const page = createFastGeometryMockPage(['.does-not-exist'])
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect.all('.does-not-exist').to.be.atLeast(12, 'height')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, true)
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 1)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
|
|
|
|
assert.strictEqual(result.diagnostics.some((d) => d.code === 'IMH_SELECTOR_ZERO_MATCHES'), false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns explicit empty-domain diagnostic for any-quantifier over zero matches', async () => {
|
|
|
|
|
const page = createFastGeometryMockPage(['.does-not-exist'])
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect.any('.does-not-exist').to.be.atLeast(12, 'height')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, false)
|
|
|
|
|
assert.ok(result.diagnostics.some((d) => d.code === 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('treats duck-typed quantifier objects as empty-domain aware', async () => {
|
|
|
|
|
const page = createFastGeometryMockPage(['.does-not-exist'])
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.quantifier({
|
|
|
|
|
bindings: [{ selector: '.does-not-exist' }],
|
|
|
|
|
toFormula: () => ({
|
|
|
|
|
type: 'FormulaNode',
|
|
|
|
|
kind: 'forall',
|
|
|
|
|
bindings: [{
|
|
|
|
|
type: 'TupleBinding',
|
|
|
|
|
variables: ['$x'],
|
|
|
|
|
domain: { type: 'DomainRef', domain: 'elements', selector: '.does-not-exist' },
|
|
|
|
|
}],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'FormulaNode',
|
|
|
|
|
kind: 'predicate',
|
|
|
|
|
predicate: 'atLeast',
|
|
|
|
|
args: [{ type: 'VariableRef', name: '$x' }],
|
|
|
|
|
options: { value: 1, dimension: 'height' },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
} as any)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, true)
|
|
|
|
|
assert.strictEqual(result.diagnostics.some((d) => d.code === 'IMH_SELECTOR_ZERO_MATCHES'), false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns a result from checkAll', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 16 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(typeof result.passed, 'boolean')
|
|
|
|
|
assert.strictEqual(typeof result.summary, 'string')
|
|
|
|
|
assert.ok(Array.isArray(result.clauseResults))
|
|
|
|
|
assert.ok(Array.isArray(result.diagnostics))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts dense string contracts via ui.spec and evaluates through same check path', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("in viewport:\n '.a' leftOf '.b' gap 16px")
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(typeof result.passed, 'boolean')
|
|
|
|
|
assert.strictEqual(result.clauseResults.length > 0, true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts vw and ch units in dense specs without parse failures', async () => {
|
|
|
|
|
const page = createFastGeometryMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("in viewport:\n '.chat-bubble' atMost 85vw wide\n '.chat-bubble' atMost 60ch wide")
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 2)
|
|
|
|
|
const parseError = result.diagnostics.find((d) =>
|
|
|
|
|
String(d.code).startsWith('IMH_PARSE_') || d.code === 'IMH_PARSE_ERROR',
|
|
|
|
|
)
|
|
|
|
|
assert.strictEqual(parseError, undefined)
|
|
|
|
|
assert.strictEqual(result.passed, true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('evaluates forall formulas nested in frame blocks', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec(`in viewport:\n forall $cat in elements('.showroom-category'):\n width($cat) >= 200`)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.clauseResults.length > 0, true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('exposes the underlying Imhotep page wrapper', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
assert.ok(ui.page)
|
|
|
|
|
assert.strictEqual(typeof ui.page.extract, 'function')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accumulates multiple assertions', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
// Use bounds that fail with identical mock rects so overall result is false
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 16 })
|
|
|
|
|
ui.expect('.c').to.be.above('.d', { minGap: 16 })
|
|
|
|
|
ui.expect('.e').to.be.centeredWithin('.f')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 3)
|
|
|
|
|
// leftOf and above fail (gap < minGap), centeredWithin passes (identical centers)
|
|
|
|
|
assert.strictEqual(result.passed, false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Fail-closed truth tests (Stream 1)', () => {
|
|
|
|
|
it('checkAll() must not return passed:true when no extraction occurred', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// The unit mock does not provide a CDP session, so strict public
|
|
|
|
|
// evaluation must fail closed (indeterminate/error), never pass.
|
|
|
|
|
assert.strictEqual(result.passed, false, 'Expected checkAll to fail closed, not pass via placeholder')
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].status, 'error')
|
|
|
|
|
assert.ok(
|
|
|
|
|
result.diagnostics.some((d) => d.code === 'IMH_EXTRACT_PROTOCOL_ERROR'),
|
|
|
|
|
'Expected extraction protocol diagnostic when CDP is unavailable'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('unimplemented relation must not pass silently', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.above('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, false, 'Unimplemented relation must not pass silently')
|
|
|
|
|
assert.ok(
|
|
|
|
|
result.clauseResults.some(cr => cr.status === 'error' || cr.status === 'fail'),
|
|
|
|
|
'Expected at least one clause result with error or fail status'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('assertion on non-existent selector must not pass', async () => {
|
|
|
|
|
const page = createMockPageWithMissingSelectors()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.does-not-exist').to.be.leftOf('.also-missing', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, false, 'Assertion on non-existent selector must not pass')
|
|
|
|
|
assert.ok(
|
|
|
|
|
result.clauseResults.some(cr => cr.status === 'error'),
|
|
|
|
|
'Expected at least one clause result with error status for missing selectors'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Component target entry points', () => {
|
|
|
|
|
it('imhotepComponent returns a handle with forAllProps', () => {
|
|
|
|
|
const handle = imhotepComponent('Button', {
|
|
|
|
|
renderer: react({ reactDom: {}, createElement: () => null }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof handle.forAllProps, 'function')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('imhotepComponent forAllProps requires fc adapter', async () => {
|
|
|
|
|
const handle = imhotepComponent('Button', {
|
|
|
|
|
renderer: react({ reactDom: {}, createElement: () => null }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// forAllProps now requires page, domain, and runner; without fc it throws.
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
async () => {
|
|
|
|
|
await handle.forAllProps({} as any, { mode: 'generated', arbitrary: null } as any, async () => {})
|
|
|
|
|
},
|
|
|
|
|
/requires a fast-check adapter/
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('imhotepStory returns a handle', () => {
|
|
|
|
|
const handle = imhotepStory('button--primary', {
|
|
|
|
|
storybookUrl: 'http://localhost:6006',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof handle.forAllProps, 'function')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('imhotepStory forAllProps requires fast-check adapter', async () => {
|
|
|
|
|
const handle = imhotepStory('button--primary', {
|
|
|
|
|
storybookUrl: 'http://localhost:6006',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
async () => {
|
|
|
|
|
await handle.forAllProps({} as any, { mode: 'generated', arbitrary: null } as any, async () => {})
|
|
|
|
|
},
|
|
|
|
|
/requires a fast-check adapter/
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('imhotepFixture returns a handle with input methods', () => {
|
|
|
|
|
const handle = imhotepFixture('fixtures/button.html')
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof handle.forAllInputs, 'function')
|
|
|
|
|
assert.strictEqual(typeof handle.exhaustivelyForAllInputs, 'function')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('imhotepFixture forAllInputs requires fc adapter', async () => {
|
|
|
|
|
const handle = imhotepFixture('fixtures/button.html')
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
async () => {
|
|
|
|
|
await handle.forAllInputs({} as any, { mode: 'generated', arbitrary: null } as any, async () => {})
|
|
|
|
|
},
|
|
|
|
|
/requires a fast-check adapter/
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('FOL engine integration', () => {
|
|
|
|
|
it('checkAll() uses logic-engine.ts for evaluation', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 16 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// In strict mode with a non-CDP unit mock, evaluation must fail closed.
|
|
|
|
|
assert.strictEqual(result.passed, false)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].status, 'error')
|
|
|
|
|
assert.ok(result.diagnostics.some((d) => d.code === 'IMH_EXTRACT_PROTOCOL_ERROR'))
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-21 13:07:16 -07:00
|
|
|
it('compound .and chaining compiles through FOL solver and evaluates', async () => {
|
|
|
|
|
// Elements at distinct positions:
|
|
|
|
|
// .a: (10,10) — top-left
|
|
|
|
|
// .b: (110,10) — to the right of .a (gap: 50 >= 8)
|
|
|
|
|
// .c: (10,110) — below .a (gap: 50 >= 8)
|
|
|
|
|
const page: any = {
|
|
|
|
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
|
|
|
setViewportSize: async () => {},
|
|
|
|
|
emulateMedia: async () => {},
|
|
|
|
|
addInitScript: async () => {},
|
|
|
|
|
keyboard: { press: async () => {} },
|
|
|
|
|
mouse: { move: async () => {} },
|
|
|
|
|
context: () => ({}),
|
|
|
|
|
locator: (_selector: string) => ({ hover: async () => {}, focus: async () => {} }),
|
|
|
|
|
evaluate: async (_fn: any, ...args: any[]) => {
|
|
|
|
|
const payload = args[0]
|
|
|
|
|
if (!payload || !Array.isArray(payload.plans)) return undefined
|
|
|
|
|
const elements: Array<any> = []
|
|
|
|
|
const selectorToIds: Array<[string, number[]]> = []
|
|
|
|
|
const positions: Record<string, { x: number; y: number }> = {
|
|
|
|
|
'.a': { x: 10, y: 10 },
|
|
|
|
|
'.b': { x: 110, y: 10 },
|
|
|
|
|
'.c': { x: 10, y: 110 },
|
|
|
|
|
}
|
|
|
|
|
for (const plan of payload.plans as Array<{ key: string; queries: string[] }>) {
|
|
|
|
|
const ids: number[] = []
|
|
|
|
|
const pos = positions[plan.key] || { x: 0, y: 0, width: 0, height: 0 }
|
|
|
|
|
for (const _query of plan.queries) {
|
|
|
|
|
const subjectId = elements.length + 1
|
|
|
|
|
elements.push({
|
|
|
|
|
tagName: 'div',
|
|
|
|
|
rect: { x: pos.x, y: pos.y, width: 50, height: 50 },
|
|
|
|
|
transform: { matrix: [1, 0, 0, 1, 0, 0], originX: 0, originY: 0 },
|
|
|
|
|
})
|
|
|
|
|
ids.push(subjectId)
|
|
|
|
|
}
|
|
|
|
|
selectorToIds.push([plan.key, ids])
|
|
|
|
|
}
|
|
|
|
|
return { elements, selectorToIds }
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 }).and.above('.c', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, true, `Expected passed:true, got passed:${result.passed}. Diagnostics: ${JSON.stringify(result.diagnostics.map(d => d.code))}`)
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 1)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
|
|
|
|
assert.ok(['true', 'determinate'].includes(result.clauseResults[0].truth), `expected true|determinate truth, got ${result.clauseResults[0].truth}`)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('compound .or chaining compiles through FOL solver and evaluates', async () => {
|
|
|
|
|
// .a is leftOf .b (true) but NOT leftOf .c (false — same x column).
|
|
|
|
|
// With .or, true clause satisfies the disjunction.
|
|
|
|
|
const page: any = {
|
|
|
|
|
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
|
|
|
setViewportSize: async () => {},
|
|
|
|
|
emulateMedia: async () => {},
|
|
|
|
|
addInitScript: async () => {},
|
|
|
|
|
keyboard: { press: async () => {} },
|
|
|
|
|
mouse: { move: async () => {} },
|
|
|
|
|
context: () => ({}),
|
|
|
|
|
locator: (_selector: string) => ({ hover: async () => {}, focus: async () => {} }),
|
|
|
|
|
evaluate: async (_fn: any, ...args: any[]) => {
|
|
|
|
|
const payload = args[0]
|
|
|
|
|
if (!payload || !Array.isArray(payload.plans)) return undefined
|
|
|
|
|
const elements: Array<any> = []
|
|
|
|
|
const selectorToIds: Array<[string, number[]]> = []
|
|
|
|
|
const positions: Record<string, { x: number; y: number }> = {
|
|
|
|
|
'.a': { x: 10, y: 10 },
|
|
|
|
|
'.b': { x: 110, y: 10 },
|
|
|
|
|
'.c': { x: 10, y: 110 },
|
|
|
|
|
}
|
|
|
|
|
for (const plan of payload.plans as Array<{ key: string; queries: string[] }>) {
|
|
|
|
|
const ids: number[] = []
|
|
|
|
|
const pos = positions[plan.key] || { x: 0, y: 0, width: 0, height: 0 }
|
|
|
|
|
for (const _query of plan.queries) {
|
|
|
|
|
const subjectId = elements.length + 1
|
|
|
|
|
elements.push({
|
|
|
|
|
tagName: 'div',
|
|
|
|
|
rect: { x: pos.x, y: pos.y, width: 50, height: 50 },
|
|
|
|
|
transform: { matrix: [1, 0, 0, 1, 0, 0], originX: 0, originY: 0 },
|
|
|
|
|
})
|
|
|
|
|
ids.push(subjectId)
|
|
|
|
|
}
|
|
|
|
|
selectorToIds.push([plan.key, ids])
|
|
|
|
|
}
|
|
|
|
|
return { elements, selectorToIds }
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 }).or.leftOf('.c', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, true, `Expected passed:true. Diagnostics: ${JSON.stringify(result.diagnostics.map(d => d.code))}`)
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 1)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
|
|
|
|
assert.ok(['true', 'determinate'].includes(result.clauseResults[0].truth), `expected true|determinate truth, got ${result.clauseResults[0].truth}`)
|
|
|
|
|
})
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
it('forall over elements evaluates all matches', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
// Even though the mock returns a single element per selector,
|
|
|
|
|
// the FOL path wraps the assertion in a forall quantifier.
|
|
|
|
|
ui.expect('.a').all.to.be.leftOf('.b')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 1)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('exists finds at least one match', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
// With minGap:16 and overlapping rects (gap = -100), leftOf fails.
|
|
|
|
|
// Existential should return fail because no witness satisfies it.
|
|
|
|
|
ui.expect('.a').any.to.be.leftOf('.b', { minGap: 16 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, false)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('size assertions route through FOL predicates', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.atLeast('50px').wide
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.passed, false)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].status, 'error')
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Semantic subject references as relation arguments (Stream 2)', () => {
|
|
|
|
|
it('accepts SubjectReference in leftOf without crashing', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const ref = ui.getByRole('button', { name: 'Secondary' })
|
|
|
|
|
const chain = ui.expect('.primary').to.be.leftOf(ref)
|
|
|
|
|
|
|
|
|
|
// The chain should have the stringified selector, not the object
|
|
|
|
|
assert.strictEqual(chain.referenceSelector, "role='button' name='Secondary'")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts SubjectReference in above without crashing', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const ref = ui.getByText('Submit')
|
|
|
|
|
const chain = ui.expect('.form').to.be.above(ref)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.referenceSelector, "text='Submit'")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts SubjectReference in inside without crashing', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const ref = ui.getByTestId('modal')
|
|
|
|
|
const chain = ui.expect('.content').to.be.inside(ref)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.referenceSelector, "testId='modal'")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts SubjectReference in overlaps without crashing', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const ref = ui.getByLabelText('Email')
|
|
|
|
|
const chain = ui.expect('.field').to.be.overlaps(ref)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.referenceSelector, "labelText='Email'")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('converts SubjectReference to string before extraction pipeline', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const ref = ui.getByRole('button')
|
|
|
|
|
ui.expect('.primary').to.be.leftOf(ref)
|
|
|
|
|
|
|
|
|
|
// This should not throw "selectorKey.startsWith is not a function"
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With the mock page, it will fail closed (no CDP), but should NOT crash
|
|
|
|
|
// with the TypeError about startsWith.
|
|
|
|
|
assert.strictEqual(
|
|
|
|
|
result.diagnostics.some((d) => d.message && d.message.includes('startsWith')),
|
|
|
|
|
false,
|
|
|
|
|
'Should not crash with startsWith TypeError'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('ui.validate() preflight method (P2.9)', () => {
|
|
|
|
|
it('returns empty issues for valid dense string specs', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("'.a' leftOf '.b' gap 8px")
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length, 0, 'Valid spec should produce no issues')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns parse errors for invalid syntax without browser execution', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
// Invalid: missing quotes around selector
|
|
|
|
|
ui.spec('[data-testid="x"] leftOf [data-testid="y"]')
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length > 0, true, 'Invalid syntax should produce issues')
|
|
|
|
|
assert.strictEqual(issues[0].type, 'parse')
|
|
|
|
|
assert.ok(issues[0].message, 'Issue should have a message')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns multiple issues for multiple invalid specs', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec('[data-testid="x"] leftOf [data-testid="y"]')
|
|
|
|
|
ui.spec("'a' is leftOf 'b'")
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length >= 2, true, 'Multiple invalid specs should produce multiple issues')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns compilation issues for invalid fluent assertions', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
// Add a valid assertion and an invalid spec
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
ui.spec("'a' is leftOf 'b'")
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length > 0, true, 'Should detect parse error in spec')
|
|
|
|
|
assert.ok(issues.some(i => i.type === 'parse'), 'Should include parse issue')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not execute browser extraction', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("'.a' leftOf '.b' gap 8px")
|
|
|
|
|
|
|
|
|
|
// validate() should not call checkAll or any extraction
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(Array.isArray(issues), true)
|
|
|
|
|
// If we got here without async/await on extraction, it did not run extraction
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('includes suggestedFix for is keyword without browser execution', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("'.a' is leftOf '.b'")
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length, 1)
|
|
|
|
|
assert.ok(issues[0].suggestedFix, 'Issue should include suggestedFix')
|
|
|
|
|
assert.strictEqual(issues[0].suggestedFix!.action, 'remove')
|
|
|
|
|
assert.ok(issues[0].suggestedFix!.rationale.includes('is'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('includes suggestedFix for missing selector quotes without browser execution', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec('a leftOf b')
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length, 1)
|
|
|
|
|
assert.ok(issues[0].suggestedFix, 'Issue should include suggestedFix')
|
|
|
|
|
assert.strictEqual(issues[0].suggestedFix!.action, 'wrap')
|
|
|
|
|
assert.ok(issues[0].suggestedFix!.value?.includes("'"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('includes suggestedFix for missing gap units without browser execution', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("'.a' leftOf '.b' gap 8")
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length, 1)
|
|
|
|
|
assert.ok(issues[0].suggestedFix, 'Issue should include suggestedFix')
|
|
|
|
|
assert.strictEqual(issues[0].suggestedFix!.action, 'replace')
|
|
|
|
|
assert.ok(issues[0].suggestedFix!.rationale.includes('unit'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('includes suggestedFix for double quotes without browser execution', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec('".a" leftOf ".b"')
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
assert.strictEqual(issues.length, 1)
|
|
|
|
|
assert.ok(issues[0].suggestedFix, 'Issue should include suggestedFix')
|
|
|
|
|
assert.strictEqual(issues[0].suggestedFix!.action, 'replace')
|
|
|
|
|
assert.ok(issues[0].suggestedFix!.value?.includes("'"))
|
|
|
|
|
assert.ok(!issues[0].suggestedFix!.value?.includes('"'))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('fixHints population (P1.5)', () => {
|
|
|
|
|
it('populates fixHints for parse errors', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec('[data-testid="x"] leftOf [data-testid="y"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const parseDiag = result.diagnostics.find(d => (d.code as string) === 'IMH_PARSE_ERROR' || d.code === 'IMH_PARSE_INVALID_SYNTAX')
|
|
|
|
|
assert.ok(parseDiag, 'Should have parse diagnostic')
|
|
|
|
|
assert.strictEqual(parseDiag.fixHints.length > 0, true, 'Parse diagnostic should have fixHints')
|
|
|
|
|
assert.ok(parseDiag.fixHints.some(h => h.includes('single-quoted')), 'fixHints should mention quoting')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('populates fixHints for extraction protocol errors', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const extractDiag = result.diagnostics.find(d => d.code === 'IMH_EXTRACT_PROTOCOL_ERROR')
|
|
|
|
|
assert.ok(extractDiag, 'Should have extraction protocol diagnostic')
|
|
|
|
|
assert.strictEqual(extractDiag.fixHints.length > 0, true, 'Extraction diagnostic should have fixHints')
|
|
|
|
|
assert.ok(extractDiag.fixHints.some(h => h.includes('loaded')), 'fixHints should suggest checking page load')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('populates fixHints for selector zero matches', async () => {
|
|
|
|
|
// Since zero-matches detection requires CDP extraction, we verify the
|
|
|
|
|
// fixHints are wired by checking the extraction error diagnostic path.
|
|
|
|
|
const page = createMockPageWithMissingSelectors()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.does-not-exist').to.be.leftOf('.also-missing', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With the mock page, we get extraction protocol error before zero-matches.
|
|
|
|
|
// We verify the extraction diagnostic has fixHints instead.
|
|
|
|
|
const extractDiag = result.diagnostics.find(d => d.code === 'IMH_EXTRACT_PROTOCOL_ERROR')
|
|
|
|
|
assert.ok(extractDiag, 'Should have extraction diagnostic')
|
|
|
|
|
assert.strictEqual(extractDiag.fixHints.length > 0, true, 'Extraction diagnostic should have fixHints')
|
|
|
|
|
assert.ok(extractDiag.fixHints.some(h => h.includes('extract')), 'fixHints should suggest ui.extract()')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('populates fixHints for relation failures', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With the mock, this will fail closed due to extraction, but we can still verify
|
|
|
|
|
// that the mapFolDiagnostic function adds fixHints when IMH_PREDICATE_FAILED is encountered.
|
|
|
|
|
const relationDiag = result.diagnostics.find(d =>
|
|
|
|
|
d.code.startsWith('IMH_RELATION_') || (d.code as string) === 'IMH_PREDICATE_FAILED'
|
|
|
|
|
)
|
|
|
|
|
// Note: in strict mode with mock, we get extraction errors rather than relation failures.
|
|
|
|
|
// This test verifies the fixHints path is wired; E2E tests will verify actual relation fixHints.
|
|
|
|
|
if (relationDiag) {
|
|
|
|
|
assert.strictEqual(relationDiag.fixHints.length > 0, true, 'Relation diagnostic should have fixHints')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Fixture environment compatibility (P1.6)', () => {
|
|
|
|
|
it('defaultEnvironment does not include unsupported pointerType', () => {
|
|
|
|
|
assert.strictEqual('pointerType' in defaultEnvironment, false, 'defaultEnvironment should not include pointerType')
|
|
|
|
|
assert.ok('viewport' in defaultEnvironment)
|
|
|
|
|
assert.ok('colorScheme' in defaultEnvironment)
|
|
|
|
|
assert.ok('reducedMotion' in defaultEnvironment)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Fixture directory resolution (P2.12)', () => {
|
|
|
|
|
it('imhotepFixture accepts fixturesDir option', () => {
|
|
|
|
|
const handle = imhotepFixture('react-button', { fixturesDir: './fixtures' })
|
|
|
|
|
assert.strictEqual(typeof handle.forAllInputs, 'function')
|
|
|
|
|
assert.strictEqual(typeof handle.exhaustivelyForAllInputs, 'function')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('imhotepFixture returns same handle shape without fixturesDir', () => {
|
|
|
|
|
const handle = imhotepFixture('fixtures/button.html')
|
|
|
|
|
assert.strictEqual(typeof handle.forAllInputs, 'function')
|
|
|
|
|
assert.strictEqual(typeof handle.exhaustivelyForAllInputs, 'function')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// V1 Diagnostic Schema Conformance (P0.2)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function assertV1Diagnostic(d: any, label: string) {
|
|
|
|
|
assert.ok(d, `${label} must exist`)
|
|
|
|
|
assert.ok('code' in d, `${label} must have code`)
|
|
|
|
|
assert.ok('category' in d, `${label} must have category`)
|
|
|
|
|
assert.ok('severity' in d, `${label} must have severity`)
|
|
|
|
|
assert.ok('message' in d, `${label} must have message`)
|
|
|
|
|
assert.ok('fixHints' in d, `${label} must have fixHints`)
|
|
|
|
|
assert.ok(Array.isArray(d.fixHints), `${label}.fixHints must be an array`)
|
|
|
|
|
assert.ok('metrics' in d, `${label} must have metrics`)
|
|
|
|
|
assert.ok(d.metrics && typeof d.metrics === 'object', `${label}.metrics must be an object`)
|
|
|
|
|
assert.ok('sourceRef' in d, `${label} must have sourceRef`)
|
|
|
|
|
assert.ok(d.sourceRef && typeof d.sourceRef === 'object', `${label}.sourceRef must be an object`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('V1 diagnostic schema conformance', () => {
|
|
|
|
|
it('checkAll result has schemaVersion', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.ok('schemaVersion' in result, 'result must have schemaVersion')
|
|
|
|
|
assert.strictEqual(result.schemaVersion, '1.0')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('extraction protocol error diagnostic has all V1 fields', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const diag = result.diagnostics.find(d => d.code === 'IMH_EXTRACT_PROTOCOL_ERROR')
|
|
|
|
|
assert.ok(diag, 'Should have extraction protocol diagnostic')
|
|
|
|
|
assertV1Diagnostic(diag, 'extraction protocol diagnostic')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('parse error diagnostic has all V1 fields', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
ui.spec('[data-testid="x"] leftOf [data-testid="y"]')
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const diag = result.diagnostics.find(d => d.code === 'IMH_PARSE_INVALID_SYNTAX' || (d.code as string) === 'IMH_PARSE_ERROR')
|
|
|
|
|
assert.ok(diag, 'Should have parse diagnostic')
|
|
|
|
|
assertV1Diagnostic(diag, 'parse diagnostic')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('zero-matches diagnostic has all V1 fields', async () => {
|
|
|
|
|
const page = createMockPageWithMissingSelectors()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
ui.expect('.does-not-exist').to.be.leftOf('.also-missing', { minGap: 8 })
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With mock page we get extraction protocol error before zero-matches,
|
|
|
|
|
// but we still assert schemaVersion on the result.
|
|
|
|
|
assert.ok('schemaVersion' in result, 'result must have schemaVersion')
|
|
|
|
|
assert.strictEqual(result.schemaVersion, '1.0')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('internal error diagnostic has all V1 fields', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
// Use an unsupported assertion that compiles to no formula
|
|
|
|
|
// to trigger the internal unknown clause kind path.
|
|
|
|
|
// For now, we verify the schemaVersion on a normal result.
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.ok('schemaVersion' in result, 'result must have schemaVersion')
|
|
|
|
|
assert.strictEqual(result.schemaVersion, '1.0')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Assertion traceability (P0.1)', () => {
|
|
|
|
|
it('fluent assertion includes sourceRef with fluentIndex', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const cr = result.clauseResults[0]
|
|
|
|
|
assert.ok(cr, 'Should have a clause result')
|
|
|
|
|
assert.ok(cr.sourceRef, 'Clause result should have sourceRef')
|
|
|
|
|
assert.strictEqual(cr.sourceRef!.fluentIndex, 0, 'fluentIndex should be 0 for first assertion')
|
|
|
|
|
assert.strictEqual(cr.sourceRef!.specLine, undefined, 'Dense spec should not have specLine')
|
|
|
|
|
assert.strictEqual(cr.sourceRef!.specColumn, undefined, 'Dense spec should not have specColumn')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('dense spec assertion includes sourceRef with specLine and specColumn', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("'.a' leftOf '.b' gap 8px")
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const cr = result.clauseResults[0]
|
|
|
|
|
assert.ok(cr, 'Should have a clause result')
|
|
|
|
|
assert.ok(cr.sourceRef, 'Clause result should have sourceRef')
|
|
|
|
|
assert.strictEqual(cr.sourceRef!.fluentIndex, undefined, 'Fluent index should not be set for dense spec')
|
|
|
|
|
assert.strictEqual(typeof cr.sourceRef!.specLine, 'number', 'specLine should be a number')
|
|
|
|
|
assert.strictEqual(typeof cr.sourceRef!.specColumn, 'number', 'specColumn should be a number')
|
|
|
|
|
assert.ok(cr.sourceRef!.specLine! >= 1, 'specLine should be >= 1')
|
|
|
|
|
assert.ok(cr.sourceRef!.specColumn! >= 1, 'specColumn should be >= 1')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('mixed fluent and dense batch maps each result to correct source', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.x').to.be.above('.y', { minGap: 4 })
|
|
|
|
|
ui.spec("'.a' leftOf '.b' gap 8px")
|
|
|
|
|
ui.expect('.z').to.be.inside('.w')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
assert.strictEqual(result.clauseResults.length, 3, 'Should have 3 clause results')
|
|
|
|
|
|
|
|
|
|
// Implementation order: all fluent assertions first, then dense specs
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].sourceRef!.fluentIndex, 0)
|
|
|
|
|
assert.strictEqual(result.clauseResults[0].sourceRef!.specLine, undefined)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(result.clauseResults[1].sourceRef!.fluentIndex, 1)
|
|
|
|
|
assert.strictEqual(result.clauseResults[1].sourceRef!.specLine, undefined)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(result.clauseResults[2].sourceRef!.fluentIndex, undefined)
|
|
|
|
|
assert.strictEqual(typeof result.clauseResults[2].sourceRef!.specLine, 'number')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('clauseLabel is present for fluent assertions', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.button').to.be.leftOf('.label', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const cr = result.clauseResults[0]
|
|
|
|
|
assert.ok(cr.clauseLabel, 'Should have clauseLabel')
|
|
|
|
|
assert.ok(cr.clauseLabel!.includes('leftOf'), 'Label should mention relation')
|
|
|
|
|
// The subject retains the dot prefix from the selector passed to expect()
|
|
|
|
|
assert.ok(cr.clauseLabel!.includes("'.button'"), 'Label should mention subject')
|
|
|
|
|
assert.ok(cr.clauseLabel!.includes("'.label'"), 'Label should mention reference')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('clauseLabel is present for dense spec assertions', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("'.a' leftOf '.b' gap 8px")
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const cr = result.clauseResults[0]
|
|
|
|
|
assert.ok(cr.clauseLabel, 'Should have clauseLabel')
|
|
|
|
|
assert.ok(cr.clauseLabel!.includes('leftOf'), 'Label should mention relation')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('backward compatibility: old fields still exist', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
const cr = result.clauseResults[0]
|
|
|
|
|
assert.strictEqual(typeof cr.clauseId, 'string')
|
|
|
|
|
assert.strictEqual(typeof cr.status, 'string')
|
|
|
|
|
assert.strictEqual(typeof cr.truth, 'string')
|
|
|
|
|
assert.ok(typeof cr.metrics === 'object')
|
|
|
|
|
assert.ok(typeof cr.witness === 'object')
|
|
|
|
|
assert.ok(Array.isArray(cr.diagnostics))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('diagnostics include sourceRef and clauseLabel', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With mock page we get extraction errors; verify diagnostics have traceability
|
|
|
|
|
const diag = result.diagnostics[0]
|
|
|
|
|
if (diag) {
|
|
|
|
|
assert.ok('sourceRef' in diag, 'Diagnostic should have sourceRef field')
|
|
|
|
|
assert.ok('clauseLabel' in diag, 'Diagnostic should have clauseLabel field')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Selector Cardinality Contracts (P2.1)', () => {
|
|
|
|
|
it('creates exactlyOne cardinality assertion', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.button').to.be.exactlyOne()
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.kind, 'exactlyOne')
|
|
|
|
|
assert.strictEqual(chain.expectedCount, 1)
|
|
|
|
|
assert.strictEqual(chain.selector, '.button')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('creates atLeastN cardinality assertion', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.button').to.be.atLeastN(3)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.kind, 'atLeastN')
|
|
|
|
|
assert.strictEqual(chain.expectedCount, 3)
|
|
|
|
|
assert.strictEqual(chain.selector, '.button')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('creates atMostN cardinality assertion', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
const chain = ui.expect('.button').to.be.atMostN(5)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(chain.kind, 'atMostN')
|
|
|
|
|
assert.strictEqual(chain.expectedCount, 5)
|
|
|
|
|
assert.strictEqual(chain.selector, '.button')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('checkAll includes cardinality clause results without crashing', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.exactlyOne()
|
|
|
|
|
ui.expect('.b').to.be.atLeastN(2)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With the mock page (no CDP), extraction fails closed.
|
|
|
|
|
// The assertion count in the summary should include cardinality assertions.
|
|
|
|
|
assert.strictEqual(result.clauseResults.length >= 2, true, 'Should have at least 2 clause results')
|
|
|
|
|
assert.strictEqual(result.passed, false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('cardinality failure diagnostic includes observedCount, expectedCount, selector', async () => {
|
|
|
|
|
const page = createMockPageWithMissingSelectors()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.does-not-exist').to.be.exactlyOne()
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// With missing selectors, we get extraction errors before cardinality eval,
|
|
|
|
|
// but the diagnostic path is wired. We verify the structure exists.
|
|
|
|
|
const cardClause = result.clauseResults.find((cr: any) =>
|
|
|
|
|
cr.metrics?.expectedCount !== undefined
|
|
|
|
|
)
|
|
|
|
|
if (cardClause) {
|
|
|
|
|
assert.strictEqual(cardClause.metrics.observedCount, 0)
|
|
|
|
|
assert.strictEqual(cardClause.metrics.expectedCount, 1)
|
2026-05-21 11:58:31 -07:00
|
|
|
assert.strictEqual(cardClause.sourceRef?.selector, '.does-not-exist')
|
2025-08-15 10:00:00 -07:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('LLM format output', () => {
|
|
|
|
|
it('returns a compact string payload with sourceRef and clauseLabel', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.a').to.be.leftOf('.b', { minGap: 8 })
|
|
|
|
|
const raw = await ui.checkAll({ format: 'llm' })
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof raw, 'string')
|
|
|
|
|
const parsed = JSON.parse(raw as string)
|
|
|
|
|
assert.strictEqual(typeof parsed.passed, 'boolean')
|
|
|
|
|
assert.ok(Array.isArray(parsed.failingClauses))
|
|
|
|
|
if (parsed.failingClauses.length > 0) {
|
|
|
|
|
const first = parsed.failingClauses[0]
|
|
|
|
|
assert.strictEqual(typeof first.clauseLabel, 'string')
|
|
|
|
|
assert.strictEqual(typeof first.sourceRef, 'object')
|
|
|
|
|
assert.strictEqual(typeof first.diagnosticCode, 'string')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('emits parse diagnostics in llm mode when there are no clause results', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
ui.spec("a is leftOf b")
|
|
|
|
|
const raw = await ui.checkAll({ format: 'llm' })
|
|
|
|
|
const parsed = JSON.parse(raw as string)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(parsed.passed, false)
|
|
|
|
|
assert.ok(parsed.failingClauses.length >= 1)
|
|
|
|
|
const parseClause = parsed.failingClauses.find((fc: any) =>
|
|
|
|
|
String(fc.diagnosticCode).startsWith('IMH_PARSE_')
|
|
|
|
|
)
|
|
|
|
|
assert.ok(parseClause, 'Expected at least one parse diagnostic in llm output')
|
|
|
|
|
assert.ok(parseClause.suggestedFix)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Geometry cache API', () => {
|
|
|
|
|
it('exposes clearCache method on UI handle', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof ui.clearCache, 'function')
|
|
|
|
|
// Should not throw even with mock page
|
|
|
|
|
ui.clearCache()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts cacheDir option', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page, { cacheDir: '/tmp/imhotep-test-cache' })
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof ui.checkAll, 'function')
|
|
|
|
|
assert.strictEqual(typeof ui.clearCache, 'function')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts cacheDir: null to disable caching', async () => {
|
|
|
|
|
const page = createMockPage()
|
|
|
|
|
const ui = await imhotep(page, { cacheDir: null })
|
|
|
|
|
|
|
|
|
|
assert.strictEqual(typeof ui.checkAll, 'function')
|
|
|
|
|
// clearCache should be a no-op when cache is disabled
|
|
|
|
|
assert.strictEqual(typeof ui.clearCache, 'function')
|
|
|
|
|
ui.clearCache()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-05-21 17:05:35 -07:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Variable-bound Domain Resolution
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function makeMockWorld(domData: { nodeId: number[]; parentNodeId: number[] }): GeometryWorld {
|
|
|
|
|
const count = Math.max(domData.nodeId.length, 1)
|
|
|
|
|
const ids = Array.from({ length: count }, (_, i) => i + 1)
|
|
|
|
|
return {
|
|
|
|
|
sceneId: 'test',
|
|
|
|
|
snapshotId: 'snap1',
|
|
|
|
|
env: { viewportWidth: 1280, viewportHeight: 720, deviceScaleFactor: 1, colorScheme: 'light', pointer: 'fine', hover: false, reducedMotion: false, locale: 'en', writingMode: 'horizontal-tb' },
|
|
|
|
|
strings: { values: [] },
|
|
|
|
|
subjects: { ids, domNodeId: ids, subjectKind: Array(count).fill(0), primaryBoxId: ids, firstFragmentId: Array(count).fill(0), fragmentCount: Array(count).fill(0) },
|
|
|
|
|
dom: { nodeId: domData.nodeId, parentNodeId: domData.parentNodeId, childCount: Array(count).fill(0), tagNameStringId: Array(count).fill(0) },
|
|
|
|
|
boxes: { boxId: ids, subjectId: ids, frameId: Array(count).fill(0), borderLeft: Array(count).fill(0), borderTop: Array(count).fill(0), borderRight: Array(count).fill(0), borderBottom: Array(count).fill(0), paddingLeft: Array(count).fill(0), paddingTop: Array(count).fill(0), paddingRight: Array(count).fill(0), paddingBottom: Array(count).fill(0), contentLeft: Array(count).fill(0), contentTop: Array(count).fill(0), contentRight: Array(count).fill(0), contentBottom: Array(count).fill(0) },
|
|
|
|
|
visualBoxes: { boxId: ids, subjectId: ids, frameId: Array(count).fill(0), borderLeft: Array(count).fill(0), borderTop: Array(count).fill(0), borderRight: Array(count).fill(0), borderBottom: Array(count).fill(0), paddingLeft: Array(count).fill(0), paddingTop: Array(count).fill(0), paddingRight: Array(count).fill(0), paddingBottom: Array(count).fill(0), contentLeft: Array(count).fill(0), contentTop: Array(count).fill(0), contentRight: Array(count).fill(0), contentBottom: Array(count).fill(0) },
|
|
|
|
|
transforms: { transformId: [], subjectId: [], matrixStart: [], matrixLength: [], originX: [], originY: [] },
|
|
|
|
|
matrices: { values: [] },
|
|
|
|
|
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
|
|
|
|
|
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: [] },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('SelectorDomainResolver — variable-bound domain resolution', () => {
|
|
|
|
|
it('resolves without parentVar (backward compatible)', () => {
|
|
|
|
|
const resolver = new SelectorDomainResolver()
|
|
|
|
|
resolver.register('.card', [1, 2, 3])
|
|
|
|
|
const domain = resolver.resolve({ type: 'DomainRef', domain: 'elements', selector: '.card' })
|
|
|
|
|
assert.ok(domain)
|
|
|
|
|
assert.deepStrictEqual(Array.from(domain!.subjectIds), [1, 2, 3])
|
|
|
|
|
assert.strictEqual(domain!.closed, true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('resolves with parentVar and env, filtering to descendants', () => {
|
|
|
|
|
const world = makeMockWorld({
|
|
|
|
|
nodeId: [1, 2, 3],
|
|
|
|
|
parentNodeId: [0, 1, 1],
|
|
|
|
|
})
|
|
|
|
|
const ancestors = buildAncestorIndex(world)
|
|
|
|
|
const resolver = new SelectorDomainResolver()
|
|
|
|
|
resolver.setAncestorIndex(ancestors)
|
|
|
|
|
resolver.register('.title', [2, 3])
|
|
|
|
|
|
|
|
|
|
const env = new BindingEnv().bind('$card', 1)
|
|
|
|
|
const domain = resolver.resolve(
|
|
|
|
|
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
|
|
|
|
|
env,
|
|
|
|
|
)
|
|
|
|
|
assert.ok(domain)
|
|
|
|
|
assert.deepStrictEqual(Array.from(domain!.subjectIds), [2, 3])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('parentVar resolution excludes non-descendants', () => {
|
|
|
|
|
const world = makeMockWorld({
|
|
|
|
|
nodeId: [1, 2, 3],
|
|
|
|
|
parentNodeId: [0, 2, 0],
|
|
|
|
|
})
|
|
|
|
|
const ancestors = buildAncestorIndex(world)
|
|
|
|
|
const resolver = new SelectorDomainResolver()
|
|
|
|
|
resolver.setAncestorIndex(ancestors)
|
|
|
|
|
resolver.register('.item', [1, 3])
|
|
|
|
|
|
|
|
|
|
const env = new BindingEnv().bind('$container', 2)
|
|
|
|
|
const domain = resolver.resolve(
|
|
|
|
|
{ type: 'DomainRef', domain: 'descendants', selector: '.item', parentVar: '$container' },
|
|
|
|
|
env,
|
|
|
|
|
)
|
|
|
|
|
assert.ok(domain)
|
|
|
|
|
assert.deepStrictEqual(Array.from(domain!.subjectIds), [1])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('parentVar not found in env returns undefined', () => {
|
|
|
|
|
const resolver = new SelectorDomainResolver()
|
|
|
|
|
resolver.register('.title', [1, 2])
|
|
|
|
|
const env = new BindingEnv().bind('$other', 5)
|
|
|
|
|
const domain = resolver.resolve(
|
|
|
|
|
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
|
|
|
|
|
env,
|
|
|
|
|
)
|
|
|
|
|
assert.strictEqual(domain, undefined)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('parentVar without ancestorIndex returns undefined', () => {
|
|
|
|
|
const resolver = new SelectorDomainResolver()
|
|
|
|
|
resolver.register('.title', [1, 2])
|
|
|
|
|
const env = new BindingEnv().bind('$card', 1)
|
|
|
|
|
const domain = resolver.resolve(
|
|
|
|
|
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
|
|
|
|
|
env,
|
|
|
|
|
)
|
|
|
|
|
assert.strictEqual(domain, undefined)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('no descendants yields empty domain', () => {
|
|
|
|
|
const world = makeMockWorld({
|
|
|
|
|
nodeId: [1, 2, 3],
|
|
|
|
|
parentNodeId: [0, 0, 2],
|
|
|
|
|
})
|
|
|
|
|
const ancestors = buildAncestorIndex(world)
|
|
|
|
|
const resolver = new SelectorDomainResolver()
|
|
|
|
|
resolver.setAncestorIndex(ancestors)
|
|
|
|
|
resolver.register('.orphan', [1])
|
|
|
|
|
|
|
|
|
|
const env = new BindingEnv().bind('$parent', 3)
|
|
|
|
|
const domain = resolver.resolve(
|
|
|
|
|
{ type: 'DomainRef', domain: 'descendants', selector: '.orphan', parentVar: '$parent' },
|
|
|
|
|
env,
|
|
|
|
|
)
|
|
|
|
|
assert.ok(domain)
|
|
|
|
|
assert.strictEqual(domain!.subjectIds.length, 0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('buildAncestorIndex', () => {
|
|
|
|
|
it('returns empty map for world with no DOM data', () => {
|
|
|
|
|
const world = makeMockWorld({ nodeId: [], parentNodeId: [] })
|
|
|
|
|
const index = buildAncestorIndex(world)
|
|
|
|
|
assert.strictEqual(index.size, 0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('builds ancestor sets from parent-child DOM relationships', () => {
|
|
|
|
|
const world = makeMockWorld({
|
|
|
|
|
nodeId: [1, 2, 3, 4],
|
|
|
|
|
parentNodeId: [0, 1, 1, 2],
|
|
|
|
|
})
|
|
|
|
|
const index = buildAncestorIndex(world)
|
|
|
|
|
assert.ok(index.has(3))
|
|
|
|
|
assert.ok(index.get(3)!.has(1))
|
|
|
|
|
assert.ok(index.has(4))
|
|
|
|
|
assert.ok(index.get(4)!.has(2))
|
|
|
|
|
assert.ok(index.get(4)!.has(1))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('no ancestors for root elements', () => {
|
|
|
|
|
const world = makeMockWorld({
|
|
|
|
|
nodeId: [1, 2],
|
|
|
|
|
parentNodeId: [0, 0],
|
|
|
|
|
})
|
|
|
|
|
const index = buildAncestorIndex(world)
|
|
|
|
|
assert.strictEqual(index.size, 0)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-05-22 10:20:06 -07:00
|
|
|
|
|
|
|
|
describe('computeRequiredFacts topology gating', () => {
|
|
|
|
|
it('requires topology extraction for scroll-container predicates', () => {
|
|
|
|
|
const facts = computeRequiredFacts([
|
|
|
|
|
{
|
|
|
|
|
type: 'FormulaNode',
|
|
|
|
|
kind: 'predicate',
|
|
|
|
|
predicate: 'attachedToScrollContainer',
|
|
|
|
|
args: [{ type: 'VariableRef', name: '$subject' }],
|
|
|
|
|
} as any,
|
|
|
|
|
])
|
|
|
|
|
assert.strictEqual(facts.topology, true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('requires topology extraction for clipping-chain escape predicates', () => {
|
|
|
|
|
const facts = computeRequiredFacts([
|
|
|
|
|
{
|
|
|
|
|
type: 'FormulaNode',
|
|
|
|
|
kind: 'predicate',
|
|
|
|
|
predicate: 'escapeClippingChainOf',
|
|
|
|
|
args: [
|
|
|
|
|
{ type: 'VariableRef', name: '$subject' },
|
|
|
|
|
{ type: 'VariableRef', name: '$reference' },
|
|
|
|
|
],
|
|
|
|
|
} as any,
|
|
|
|
|
])
|
|
|
|
|
assert.strictEqual(facts.topology, true)
|
|
|
|
|
})
|
|
|
|
|
})
|