2025-08-15 10:00:00 -07:00
|
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
|
import { imhotep } from 'imhotep-playwright'
|
|
|
|
|
import type { Page } from 'playwright'
|
|
|
|
|
import {
|
|
|
|
|
touchTarget,
|
|
|
|
|
toolbarAlignment,
|
|
|
|
|
cardSpacing,
|
|
|
|
|
formLabelAlignment,
|
|
|
|
|
modalContainment,
|
|
|
|
|
} from 'imhotep-playwright'
|
|
|
|
|
|
|
|
|
|
// Create a simple test page inline so we don't depend on fixture files.
|
|
|
|
|
async function loadTestPage(page: Page) {
|
|
|
|
|
await page.setContent(`
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<style>
|
|
|
|
|
body { margin: 0; padding: 20px; font-family: sans-serif; }
|
|
|
|
|
.container { width: 400px; height: 400px; position: relative; background: #f0f0f0; }
|
|
|
|
|
.box { width: 50px; height: 50px; background: blue; position: absolute; }
|
|
|
|
|
.box1 { left: 10px; top: 10px; }
|
|
|
|
|
.box2 { left: 100px; top: 10px; }
|
|
|
|
|
.box3 { left: 10px; top: 100px; }
|
|
|
|
|
.modal { width: 200px; height: 200px; background: red; position: absolute; left: 50px; top: 50px; }
|
|
|
|
|
.button { width: 44px; height: 44px; background: green; }
|
|
|
|
|
.small-button { width: 30px; height: 30px; background: orange; }
|
|
|
|
|
.overlap1 { width: 100px; height: 100px; background: purple; position: absolute; left: 50px; top: 50px; }
|
|
|
|
|
.overlap2 { width: 100px; height: 100px; background: cyan; position: absolute; left: 80px; top: 80px; }
|
|
|
|
|
.wide-box { width: 150px; height: 50px; background: yellow; }
|
|
|
|
|
.tall-box { width: 50px; height: 150px; background: pink; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="container" data-testid="container">
|
|
|
|
|
<div class="box box1" data-testid="box1"></div>
|
|
|
|
|
<div class="box box2" data-testid="box2"></div>
|
|
|
|
|
<div class="box box3" data-testid="box3"></div>
|
|
|
|
|
<div class="modal" data-testid="modal"></div>
|
|
|
|
|
<div class="overlap1" data-testid="overlap1"></div>
|
|
|
|
|
<div class="overlap2" data-testid="overlap2"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="button" data-testid="button"></div>
|
|
|
|
|
<div class="small-button" data-testid="small-button"></div>
|
|
|
|
|
<div class="wide-box" data-testid="wide-box"></div>
|
|
|
|
|
<div class="tall-box" data-testid="tall-box"></div>
|
|
|
|
|
<div class="label" data-testid="label">Label</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`)
|
|
|
|
|
// Wait for layout to settle.
|
|
|
|
|
await page.waitForTimeout(50)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.describe('Edge Feature Tests', () => {
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// Size Assertions
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('atLeast(number, dimension) - width passes', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="wide-box"]').to.be.atLeast(100, 'width')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atLeast(number, dimension) - width fails', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="button"]').to.be.atLeast(100, 'width')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => d.code === 'IMH_SIZE_AT_LEAST_FAILED')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atMost(number, dimension) - height passes', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="button"]').to.be.atMost(200, 'height')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atMost(number, dimension) - height fails', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="tall-box"]').to.be.atMost(100, 'height')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => d.code === 'IMH_SIZE_AT_MOST_FAILED')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('between(min, max, dimension) - width passes', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="wide-box"]').to.be.between(100, 200, 'width')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('between(min, max, dimension) - width fails', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="button"]').to.be.between(100, 200, 'width')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => d.code === 'IMH_SIZE_BETWEEN_FAILED')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// Cardinality Assertions
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('exactlyOne passes when 1 match', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="button"]').to.be.exactlyOne()
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('exactlyOne fails when 0 matches', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.nonexistent').to.be.exactlyOne()
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => (d.code as string) === 'IMH_CARDINALITY_EXACTLYONE_FAILED')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atLeastN passes', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.box').to.be.atLeastN(2)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atLeastN fails', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.box').to.be.atLeastN(10)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => (d.code as string) === 'IMH_CARDINALITY_ATLEASTN_FAILED')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atMostN passes', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.box').to.be.atMostN(5)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('atMostN fails', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.box').to.be.atMostN(1)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => (d.code as string) === 'IMH_CARDINALITY_ATMOSTN_FAILED')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// Topology: clippedBy
|
|
|
|
|
// ───────────────────────────────────────────────
|
2026-05-21 10:10:11 -07:00
|
|
|
test.skip('clippedBy - documented but not yet implemented', async () => {
|
|
|
|
|
// TODO: implement clippedBy topology predicate
|
2025-08-15 10:00:00 -07:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// checkAll options: format llm
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('checkAll with format: llm returns string', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="box1"]').to.be.leftOf('[data-testid="box2"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll({ format: 'llm' })
|
|
|
|
|
expect(typeof result).toBe('string')
|
|
|
|
|
|
|
|
|
|
const parsed = JSON.parse(result)
|
|
|
|
|
expect(typeof parsed.passed).toBe('boolean')
|
|
|
|
|
expect(Array.isArray(parsed.failingClauses)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('checkAll with format: llm on failure', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="box2"]').to.be.leftOf('[data-testid="box1"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll({ format: 'llm' })
|
|
|
|
|
expect(typeof result).toBe('string')
|
|
|
|
|
|
|
|
|
|
const parsed = JSON.parse(result)
|
|
|
|
|
expect(parsed.passed).toBe(false)
|
|
|
|
|
expect(parsed.failingClauses.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// checkAll options: includeNormalized
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('checkAll with includeNormalized: true', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="box1"]').to.be.leftOf('[data-testid="box2"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll({ includeNormalized: true })
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
expect(Array.isArray(result.normalizedContracts)).toBe(true)
|
|
|
|
|
expect(result.normalizedContracts!.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// clearCache
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('clearCache does not throw', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
// Should be callable without error.
|
|
|
|
|
expect(() => ui.clearCache()).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// Presets
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('touchTarget preset exists and works', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
const preset = touchTarget(ui, '[data-testid="button"]')
|
|
|
|
|
expect(preset.assertions.length).toBe(2)
|
|
|
|
|
expect(preset.clauses.length).toBe(2)
|
|
|
|
|
|
|
|
|
|
// Apply the assertions.
|
|
|
|
|
for (const a of preset.assertions) {
|
|
|
|
|
ui.expect(a.assertion.getSubject()).to.be.atLeast(44, a.assertion.getSubject() === '[data-testid="button"]' ? 'width' : 'height')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('toolbarAlignment preset exists', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
const preset = toolbarAlignment(ui, ['[data-testid="box1"]', '[data-testid="box2"]', '[data-testid="box3"]'])
|
|
|
|
|
expect(preset.assertions.length).toBeGreaterThan(0)
|
|
|
|
|
expect(preset.clauses.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('cardSpacing preset exists', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
const preset = cardSpacing(ui, '[data-testid="box1"]', '[data-testid="box2"]')
|
|
|
|
|
expect(preset.assertions.length).toBe(2)
|
|
|
|
|
expect(preset.clauses.length).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('formLabelAlignment preset exists', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
const preset = formLabelAlignment(ui, '[data-testid="label"]', '[data-testid="button"]')
|
|
|
|
|
expect(preset.assertions.length).toBeGreaterThan(0)
|
|
|
|
|
expect(preset.clauses.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('modalContainment preset exists', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
const preset = modalContainment(ui, '[data-testid="modal"]')
|
|
|
|
|
expect(preset.assertions.length).toBe(2)
|
|
|
|
|
expect(preset.clauses.length).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// Failure Cases
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('zero-match selector fails with actionable error', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('.nonexistent').to.be.leftOf('[data-testid="label"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => d.code === 'IMH_SELECTOR_ZERO_MATCHES')).toBe(true)
|
|
|
|
|
|
|
|
|
|
const diag = result.diagnostics.find(d => (d.code as string) === 'IMH_SELECTOR_ZERO_MATCHES')
|
|
|
|
|
expect(diag).toBeDefined()
|
|
|
|
|
expect(diag!.fixHints.length).toBeGreaterThan(0)
|
|
|
|
|
expect(diag!.fixHints.some(h => h.includes('ui.extract'))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('invalid relation option produces validation error', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
// Pass an unknown option; the system should reject it with a clear error.
|
|
|
|
|
ui.expect('[data-testid="box1"]').to.be.leftOf('[data-testid="box2"]', { invalidOption: true } as any)
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// Invalid options should cause the assertion to fail with a validation error.
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
expect(result.diagnostics.some(d => d.severity === 'error')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('overlapping elements are detected', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="overlap1"]').to.be.overlaps('[data-testid="overlap2"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
expect(result.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('overlapping elements - separatedFrom fails', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="overlap1"]').to.be.separatedFrom('[data-testid="overlap2"]')
|
|
|
|
|
|
|
|
|
|
const result = await ui.checkAll()
|
|
|
|
|
// separatedFrom is not implemented per audit.
|
|
|
|
|
expect(result.passed).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
// Hidden / Edge Behaviors
|
|
|
|
|
// ───────────────────────────────────────────────
|
|
|
|
|
test('checkAll clears assertion store after call', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="box1"]').to.be.leftOf('[data-testid="box2"]')
|
|
|
|
|
|
|
|
|
|
const result1 = await ui.checkAll()
|
|
|
|
|
expect(result1.clauseResults.length).toBe(1)
|
|
|
|
|
|
|
|
|
|
// Second call with no new assertions should be empty.
|
|
|
|
|
const result2 = await ui.checkAll()
|
|
|
|
|
expect(result2.clauseResults.length).toBe(0)
|
|
|
|
|
expect(result2.passed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('validate returns empty array for valid assertions', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.expect('[data-testid="box1"]').to.be.leftOf('[data-testid="box2"]')
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
expect(issues.length).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('validate catches invalid dense spec', async ({ page }) => {
|
|
|
|
|
const ui = await imhotep(page)
|
|
|
|
|
await loadTestPage(page)
|
|
|
|
|
|
|
|
|
|
ui.spec(`'box1' is leftOf 'box2'`)
|
|
|
|
|
|
|
|
|
|
const issues = ui.validate()
|
|
|
|
|
expect(issues.length).toBeGreaterThan(0)
|
|
|
|
|
expect(issues.some(i => i.message.includes('is'))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|