615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
|
|
import { test, expect } from '@playwright/test'
|
||
|
|
import { resolve } from 'node:path'
|
||
|
|
import { fileURLToPath } from 'node:url'
|
||
|
|
|
||
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||
|
|
|
||
|
|
// Resolve fixture page to file:// URL.
|
||
|
|
function fixtureUrl(name: string): string {
|
||
|
|
return 'file://' + resolve(__dirname, 'pages', `${name}.html`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Injected helper: read bounding boxes and build a minimal GeometryWorld.
|
||
|
|
// Returns a world compatible with imhotep-solver evaluateLogic.
|
||
|
|
async function extractWorldFromPage(page: any): Promise<any> {
|
||
|
|
return page.evaluate(() => {
|
||
|
|
const btn = document.querySelector('[data-testid="rendered-button"]') as HTMLElement;
|
||
|
|
const card = document.querySelector('[data-testid="rendered-card"]') as HTMLElement;
|
||
|
|
|
||
|
|
const btnRect = btn ? btn.getBoundingClientRect() : { left: 0, top: 0, right: 0, bottom: 0 };
|
||
|
|
const cardRect = card ? card.getBoundingClientRect() : { left: 0, top: 0, right: 0, bottom: 0 };
|
||
|
|
|
||
|
|
// Minimal world with just enough data for width/height predicates
|
||
|
|
return {
|
||
|
|
sceneId: 'fixture_scene',
|
||
|
|
snapshotId: 'snap_1',
|
||
|
|
env: {
|
||
|
|
viewportWidth: window.innerWidth,
|
||
|
|
viewportHeight: window.innerHeight,
|
||
|
|
deviceScaleFactor: 1,
|
||
|
|
colorScheme: 'light',
|
||
|
|
pointer: 'fine',
|
||
|
|
hover: false,
|
||
|
|
reducedMotion: false,
|
||
|
|
locale: 'en',
|
||
|
|
writingMode: 'horizontal-tb',
|
||
|
|
},
|
||
|
|
strings: { values: [] },
|
||
|
|
subjects: {
|
||
|
|
ids: [1, 2],
|
||
|
|
domNodeId: [10, 20],
|
||
|
|
subjectKind: [1, 1],
|
||
|
|
primaryBoxId: [100, 200],
|
||
|
|
firstFragmentId: [0, 0],
|
||
|
|
fragmentCount: [0, 0],
|
||
|
|
},
|
||
|
|
dom: {
|
||
|
|
nodeId: [10, 20],
|
||
|
|
parentNodeId: [0, 0],
|
||
|
|
childCount: [0, 0],
|
||
|
|
tagNameStringId: [0, 0],
|
||
|
|
},
|
||
|
|
boxes: {
|
||
|
|
boxId: [100, 200],
|
||
|
|
subjectId: [1, 2],
|
||
|
|
frameId: [1, 1],
|
||
|
|
borderLeft: [btnRect.left, cardRect.left],
|
||
|
|
borderTop: [btnRect.top, cardRect.top],
|
||
|
|
borderRight: [btnRect.right, cardRect.right],
|
||
|
|
borderBottom: [btnRect.bottom, cardRect.bottom],
|
||
|
|
paddingLeft: [0, 0],
|
||
|
|
paddingTop: [0, 0],
|
||
|
|
paddingRight: [0, 0],
|
||
|
|
paddingBottom: [0, 0],
|
||
|
|
contentLeft: [0, 0],
|
||
|
|
contentTop: [0, 0],
|
||
|
|
contentRight: [0, 0],
|
||
|
|
contentBottom: [0, 0],
|
||
|
|
},
|
||
|
|
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
|
||
|
|
topology: {
|
||
|
|
containingBlockOf: [0, 0],
|
||
|
|
nearestPositionedAncestorOf: [0, 0],
|
||
|
|
scrollContainerOf: [0, 0],
|
||
|
|
stackingContextOf: [0, 0],
|
||
|
|
formattingContextOf: [0, 0],
|
||
|
|
clippingRootOf: [0, 0],
|
||
|
|
paintOrderBucket: [0, 0],
|
||
|
|
paintOrderIndex: [0, 0],
|
||
|
|
},
|
||
|
|
scroll: {
|
||
|
|
containerId: [], scrollLeft: [], scrollTop: [],
|
||
|
|
scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: []
|
||
|
|
},
|
||
|
|
clipping: {
|
||
|
|
clipNodeId: [], subjectId: [], clipKind: [],
|
||
|
|
clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: []
|
||
|
|
},
|
||
|
|
visibility: {
|
||
|
|
subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: []
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update props on a React/Vue fixture page via custom event.
|
||
|
|
async function updateProps(page: any, props: unknown): Promise<void> {
|
||
|
|
await page.evaluate((p: unknown) => {
|
||
|
|
window.dispatchEvent(new CustomEvent('imhotep:update-props', { detail: { props: p } }));
|
||
|
|
}, props);
|
||
|
|
// Allow React/Vue re-render to settle
|
||
|
|
await page.waitForTimeout(50);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update args on a Storybook-like fixture page via custom event.
|
||
|
|
async function updateArgs(page: any, args: unknown): Promise<void> {
|
||
|
|
await page.evaluate((a: unknown) => {
|
||
|
|
window.dispatchEvent(new CustomEvent('imhotep:update-args', { detail: { args: a } }));
|
||
|
|
}, args);
|
||
|
|
await page.waitForTimeout(50);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Test 1: React props-generated layout invariants
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test.describe('E2E: React property-render invariants', () => {
|
||
|
|
test('button is at least 44px wide across all enumerated prop combinations', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-react'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
const sizes = ['sm', 'md', 'lg'] as const;
|
||
|
|
const disabledStates = [false, true];
|
||
|
|
|
||
|
|
for (const size of sizes) {
|
||
|
|
for (const disabled of disabledStates) {
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size, disabled, label: 'Test' },
|
||
|
|
card: { title: 'T', content: 'C', compact: false }
|
||
|
|
});
|
||
|
|
|
||
|
|
const btn = page.locator('[data-testid="rendered-button"]');
|
||
|
|
const box = await btn.boundingBox();
|
||
|
|
expect(box).not.toBeNull();
|
||
|
|
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||
|
|
expect(box!.height).toBeGreaterThanOrEqual(32);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('card layout changes with compact prop', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-react'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-card"]');
|
||
|
|
|
||
|
|
// Normal padding
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'md', disabled: false, label: 'OK' },
|
||
|
|
card: { title: 'Title', content: 'Content', compact: false }
|
||
|
|
});
|
||
|
|
const cardNormal = await page.locator('[data-testid="rendered-card"]').boundingBox();
|
||
|
|
expect(cardNormal).not.toBeNull();
|
||
|
|
|
||
|
|
// Compact padding
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'md', disabled: false, label: 'OK' },
|
||
|
|
card: { title: 'Title', content: 'Content', compact: true }
|
||
|
|
});
|
||
|
|
const cardCompact = await page.locator('[data-testid="rendered-card"]').boundingBox();
|
||
|
|
expect(cardCompact).not.toBeNull();
|
||
|
|
|
||
|
|
// Compact should be shorter (less padding)
|
||
|
|
expect(cardCompact!.height).toBeLessThan(cardNormal!.height);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deterministic FOL evaluates over extracted React scene', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-react'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'lg', disabled: false, label: 'Big Button' },
|
||
|
|
card: { title: 'T', content: 'C', compact: false }
|
||
|
|
});
|
||
|
|
|
||
|
|
const world = await extractWorldFromPage(page);
|
||
|
|
|
||
|
|
// Import solver modules dynamically to avoid top-level dependency issues
|
||
|
|
const { evaluateLogic, registerDefaultPredicates } = await import('imhotep-solver');
|
||
|
|
registerDefaultPredicates();
|
||
|
|
|
||
|
|
const result = evaluateLogic({
|
||
|
|
formula: {
|
||
|
|
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' }]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
world,
|
||
|
|
resolver: {
|
||
|
|
resolve(domain: any) {
|
||
|
|
const key = domain.selector ?? domain.domain;
|
||
|
|
if (key === '.button') {
|
||
|
|
return {
|
||
|
|
domainId: 'dom_btn',
|
||
|
|
subjectIds: new Uint32Array([1]),
|
||
|
|
provenance: 'elements(.button)',
|
||
|
|
closed: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
options: {}
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.passed).toBe(true);
|
||
|
|
expect(result.mode).toBe('scene-determinate');
|
||
|
|
expect(result.formulaResults[0].outcome).toBe('pass');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Test 2: Vue props-generated layout invariants
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test.describe('E2E: Vue property-render invariants', () => {
|
||
|
|
test('button is at least 44px wide across all enumerated prop combinations', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-vue'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
const sizes = ['sm', 'md', 'lg'] as const;
|
||
|
|
const disabledStates = [false, true];
|
||
|
|
|
||
|
|
for (const size of sizes) {
|
||
|
|
for (const disabled of disabledStates) {
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size, disabled, label: 'Test' },
|
||
|
|
card: { title: 'T', content: 'C', compact: false }
|
||
|
|
});
|
||
|
|
|
||
|
|
const btn = page.locator('[data-testid="rendered-button"]');
|
||
|
|
const box = await btn.boundingBox();
|
||
|
|
expect(box).not.toBeNull();
|
||
|
|
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||
|
|
expect(box!.height).toBeGreaterThanOrEqual(32);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('card layout changes with compact prop in Vue', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-vue'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-card"]');
|
||
|
|
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'md', disabled: false, label: 'OK' },
|
||
|
|
card: { title: 'Title', content: 'Content', compact: false }
|
||
|
|
});
|
||
|
|
const cardNormal = await page.locator('[data-testid="rendered-card"]').boundingBox();
|
||
|
|
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'md', disabled: false, label: 'OK' },
|
||
|
|
card: { title: 'Title', content: 'Content', compact: true }
|
||
|
|
});
|
||
|
|
const cardCompact = await page.locator('[data-testid="rendered-card"]').boundingBox();
|
||
|
|
|
||
|
|
expect(cardNormal).not.toBeNull();
|
||
|
|
expect(cardCompact).not.toBeNull();
|
||
|
|
expect(cardCompact!.height).toBeLessThan(cardNormal!.height);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deterministic FOL evaluates over extracted Vue scene', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-vue'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'lg', disabled: false, label: 'Big Button' },
|
||
|
|
card: { title: 'T', content: 'C', compact: false }
|
||
|
|
});
|
||
|
|
|
||
|
|
const world = await extractWorldFromPage(page);
|
||
|
|
|
||
|
|
const { evaluateLogic, registerDefaultPredicates } = await import('imhotep-solver');
|
||
|
|
registerDefaultPredicates();
|
||
|
|
|
||
|
|
const result = evaluateLogic({
|
||
|
|
formula: {
|
||
|
|
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' }]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
world,
|
||
|
|
resolver: {
|
||
|
|
resolve(domain: any) {
|
||
|
|
const key = domain.selector ?? domain.domain;
|
||
|
|
if (key === '.button') {
|
||
|
|
return {
|
||
|
|
domainId: 'dom_btn',
|
||
|
|
subjectIds: new Uint32Array([1]),
|
||
|
|
provenance: 'elements(.button)',
|
||
|
|
closed: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
options: {}
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.passed).toBe(true);
|
||
|
|
expect(result.mode).toBe('scene-determinate');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Test 3: Storybook-like story args invariants
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test.describe('E2E: Storybook-like story args invariants', () => {
|
||
|
|
test('story button is at least 44px wide across enumerated arg combinations', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('storybook-like'));
|
||
|
|
await page.waitForSelector('[data-testid="story-button"]');
|
||
|
|
|
||
|
|
const sizes = ['sm', 'md', 'lg'] as const;
|
||
|
|
const disabledStates = [false, true];
|
||
|
|
|
||
|
|
for (const size of sizes) {
|
||
|
|
for (const disabled of disabledStates) {
|
||
|
|
await updateArgs(page, { size, disabled, label: 'Story' });
|
||
|
|
|
||
|
|
const btn = page.locator('[data-testid="story-button"]');
|
||
|
|
const box = await btn.boundingBox();
|
||
|
|
expect(box).not.toBeNull();
|
||
|
|
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('controls panel reflects current args', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('storybook-like'));
|
||
|
|
await page.waitForSelector('.controls-panel');
|
||
|
|
|
||
|
|
await updateArgs(page, { size: 'lg', disabled: true, label: 'Controlled' });
|
||
|
|
|
||
|
|
const panel = page.locator('.controls-panel');
|
||
|
|
await expect(panel.locator('.control-value').nth(0)).toHaveText('lg');
|
||
|
|
await expect(panel.locator('.control-value').nth(1)).toHaveText('true');
|
||
|
|
await expect(panel.locator('.control-value').nth(2)).toHaveText('Controlled');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deterministic FOL works inside story property run', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('storybook-like'));
|
||
|
|
await page.waitForSelector('[data-testid="story-button"]');
|
||
|
|
|
||
|
|
await updateArgs(page, { size: 'md', disabled: false, label: 'FOL Test' });
|
||
|
|
|
||
|
|
const world = await page.evaluate(() => {
|
||
|
|
const btn = document.querySelector('[data-testid="story-button"]') as HTMLElement;
|
||
|
|
const btnRect = btn ? btn.getBoundingClientRect() : { left: 0, top: 0, right: 0, bottom: 0 };
|
||
|
|
|
||
|
|
return {
|
||
|
|
sceneId: 'story_scene',
|
||
|
|
snapshotId: 'snap_1',
|
||
|
|
env: {
|
||
|
|
viewportWidth: window.innerWidth,
|
||
|
|
viewportHeight: window.innerHeight,
|
||
|
|
deviceScaleFactor: 1,
|
||
|
|
colorScheme: 'light',
|
||
|
|
pointer: 'fine',
|
||
|
|
hover: false,
|
||
|
|
reducedMotion: false,
|
||
|
|
locale: 'en',
|
||
|
|
writingMode: 'horizontal-tb',
|
||
|
|
},
|
||
|
|
strings: { values: [] },
|
||
|
|
subjects: {
|
||
|
|
ids: [1],
|
||
|
|
domNodeId: [10],
|
||
|
|
subjectKind: [1],
|
||
|
|
primaryBoxId: [100],
|
||
|
|
firstFragmentId: [0],
|
||
|
|
fragmentCount: [0],
|
||
|
|
},
|
||
|
|
dom: {
|
||
|
|
nodeId: [10],
|
||
|
|
parentNodeId: [0],
|
||
|
|
childCount: [0],
|
||
|
|
tagNameStringId: [0],
|
||
|
|
},
|
||
|
|
boxes: {
|
||
|
|
boxId: [100],
|
||
|
|
subjectId: [1],
|
||
|
|
frameId: [1],
|
||
|
|
borderLeft: [btnRect.left],
|
||
|
|
borderTop: [btnRect.top],
|
||
|
|
borderRight: [btnRect.right],
|
||
|
|
borderBottom: [btnRect.bottom],
|
||
|
|
paddingLeft: [0],
|
||
|
|
paddingTop: [0],
|
||
|
|
paddingRight: [0],
|
||
|
|
paddingBottom: [0],
|
||
|
|
contentLeft: [0],
|
||
|
|
contentTop: [0],
|
||
|
|
contentRight: [0],
|
||
|
|
contentBottom: [0],
|
||
|
|
},
|
||
|
|
visualBoxes: {
|
||
|
|
boxId: [100],
|
||
|
|
subjectId: [1],
|
||
|
|
frameId: [1],
|
||
|
|
borderLeft: [btnRect.left],
|
||
|
|
borderTop: [btnRect.top],
|
||
|
|
borderRight: [btnRect.right],
|
||
|
|
borderBottom: [btnRect.bottom],
|
||
|
|
paddingLeft: [0],
|
||
|
|
paddingTop: [0],
|
||
|
|
paddingRight: [0],
|
||
|
|
paddingBottom: [0],
|
||
|
|
contentLeft: [0],
|
||
|
|
contentTop: [0],
|
||
|
|
contentRight: [0],
|
||
|
|
contentBottom: [0],
|
||
|
|
},
|
||
|
|
transforms: {
|
||
|
|
transformId: [],
|
||
|
|
subjectId: [],
|
||
|
|
matrixStart: [],
|
||
|
|
matrixLength: [],
|
||
|
|
originX: [],
|
||
|
|
originY: [],
|
||
|
|
},
|
||
|
|
matrices: {
|
||
|
|
values: [],
|
||
|
|
},
|
||
|
|
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
|
||
|
|
topology: {
|
||
|
|
containingBlockOf: [0],
|
||
|
|
nearestPositionedAncestorOf: [0],
|
||
|
|
scrollContainerOf: [0],
|
||
|
|
stackingContextOf: [0],
|
||
|
|
formattingContextOf: [0],
|
||
|
|
clippingRootOf: [0],
|
||
|
|
paintOrderBucket: [0],
|
||
|
|
paintOrderIndex: [0],
|
||
|
|
},
|
||
|
|
scroll: {
|
||
|
|
containerId: [], scrollLeft: [], scrollTop: [],
|
||
|
|
scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: []
|
||
|
|
},
|
||
|
|
clipping: {
|
||
|
|
clipNodeId: [], subjectId: [], clipKind: [],
|
||
|
|
clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: []
|
||
|
|
},
|
||
|
|
visibility: {
|
||
|
|
subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: []
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const { evaluateLogic, registerDefaultPredicates } = await import('imhotep-solver');
|
||
|
|
registerDefaultPredicates();
|
||
|
|
|
||
|
|
const result = evaluateLogic({
|
||
|
|
formula: {
|
||
|
|
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' }]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
world,
|
||
|
|
resolver: {
|
||
|
|
resolve(domain: any) {
|
||
|
|
const key = domain.selector ?? domain.domain;
|
||
|
|
if (key === '.button') {
|
||
|
|
return {
|
||
|
|
domainId: 'dom_btn',
|
||
|
|
subjectIds: new Uint32Array([1]),
|
||
|
|
provenance: 'elements(.button)',
|
||
|
|
closed: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
options: {}
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.passed).toBe(true);
|
||
|
|
expect(result.mode).toBe('scene-determinate');
|
||
|
|
expect(result.proofs.length).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Test 4: Isolated component document route (Operator-style showroom)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test.describe('E2E: Isolated component document route', () => {
|
||
|
|
test('React fixture acts as isolated showroom component', async ({ page }) => {
|
||
|
|
// An isolated component document is just a fixture page that renders
|
||
|
|
// a single component without surrounding app chrome.
|
||
|
|
await page.goto(fixtureUrl('property-render-react'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
// Verify no navigation, no sidebars, no app chrome
|
||
|
|
const body = page.locator('body');
|
||
|
|
const html = await body.innerHTML();
|
||
|
|
expect(html).not.toContain('nav');
|
||
|
|
expect(html).not.toContain('sidebar');
|
||
|
|
expect(html).not.toContain('header');
|
||
|
|
|
||
|
|
// Component should be mountable and testable in isolation
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'md', disabled: false, label: 'Isolated' },
|
||
|
|
card: { title: 'Isolated Card', content: 'No app chrome', compact: false }
|
||
|
|
});
|
||
|
|
|
||
|
|
const btn = page.locator('[data-testid="rendered-button"]');
|
||
|
|
await expect(btn).toHaveText('Isolated');
|
||
|
|
const box = await btn.boundingBox();
|
||
|
|
expect(box).not.toBeNull();
|
||
|
|
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Vue fixture acts as isolated showroom component', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-vue'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
const body = page.locator('body');
|
||
|
|
const html = await body.innerHTML();
|
||
|
|
expect(html).not.toContain('nav');
|
||
|
|
expect(html).not.toContain('sidebar');
|
||
|
|
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'lg', disabled: true, label: 'Disabled' },
|
||
|
|
card: { title: 'T', content: 'C', compact: true }
|
||
|
|
});
|
||
|
|
|
||
|
|
const btn = page.locator('[data-testid="rendered-button"]');
|
||
|
|
await expect(btn).toHaveAttribute('disabled', '');
|
||
|
|
const box = await btn.boundingBox();
|
||
|
|
expect(box).not.toBeNull();
|
||
|
|
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('isolated showroom supports deterministic FOL evaluation', async ({ page }) => {
|
||
|
|
await page.goto(fixtureUrl('property-render-react'));
|
||
|
|
await page.waitForSelector('[data-testid="rendered-button"]');
|
||
|
|
|
||
|
|
await updateProps(page, {
|
||
|
|
button: { size: 'sm', disabled: false, label: 'Small' },
|
||
|
|
card: { title: 'T', content: 'C', compact: false }
|
||
|
|
});
|
||
|
|
|
||
|
|
const world = await extractWorldFromPage(page);
|
||
|
|
|
||
|
|
const { evaluateLogic, registerDefaultPredicates } = await import('imhotep-solver');
|
||
|
|
registerDefaultPredicates();
|
||
|
|
|
||
|
|
// Test existential: there exists a button with width >= 44
|
||
|
|
const result = evaluateLogic({
|
||
|
|
formula: {
|
||
|
|
type: 'FormulaNode',
|
||
|
|
kind: 'exists',
|
||
|
|
bindings: [{
|
||
|
|
type: 'TupleBinding',
|
||
|
|
variables: ['btn'],
|
||
|
|
domain: { type: 'DomainRef', domain: 'elements', selector: '.button' }
|
||
|
|
}],
|
||
|
|
body: {
|
||
|
|
type: 'FormulaNode',
|
||
|
|
kind: 'predicate',
|
||
|
|
predicate: 'atLeast',
|
||
|
|
args: [{ type: 'VariableRef', name: 'btn' }]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
world,
|
||
|
|
resolver: {
|
||
|
|
resolve(domain: any) {
|
||
|
|
const key = domain.selector ?? domain.domain;
|
||
|
|
if (key === '.button') {
|
||
|
|
return {
|
||
|
|
domainId: 'dom_btn',
|
||
|
|
subjectIds: new Uint32Array([1]),
|
||
|
|
provenance: 'elements(.button)',
|
||
|
|
closed: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
options: {}
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.passed).toBe(true);
|
||
|
|
expect(result.mode).toBe('scene-determinate');
|
||
|
|
expect(result.formulaResults[0].outcome).toBe('pass');
|
||
|
|
});
|
||
|
|
});
|