fix: compound .and/.or chaining silently ignored in fluent API

The proxy in buildExpectation() returned FluentRelation raw (not
proxied), so .and/.or chaining happened outside the assertion store.
The second relation was never stored — checkAll() only evaluated
the first relation (false positive).

Add ensureAndOrProxied() to override .and/.or on FluentRelation
instances so compound builders flow through the assertion store
proxy. Preserves instanceof FluentRelation checks (no JS Proxy on
the instance, just property overrides).

3 regression tests cover .and, .or, and triple-chaining.
This commit is contained in:
John Dvorak
2026-05-21 12:31:35 -07:00
parent 70f528fbab
commit 771ddaea4e
2 changed files with 74 additions and 0 deletions
@@ -163,6 +163,56 @@ describe('Playwright public API', () => {
assert.strictEqual(chain.relation, 'leftOf') assert.strictEqual(chain.relation, 'leftOf')
}) })
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')
})
it('supports quantifier entry via ui.expect.all(subject)', async () => { it('supports quantifier entry via ui.expect.all(subject)', async () => {
const page = createMockPage() const page = createMockPage()
const ui = await imhotep(page) const ui = await imhotep(page)
+24
View File
@@ -285,6 +285,29 @@ export async function imhotep(
}) })
} }
// Ensure .and / .or on a FluentRelation result return proxy-wrapped
// builders so compound assertions flow through the assertion store.
function ensureAndOrProxied(result: any): void {
if (!result || typeof result !== 'object') return
const proto = Object.getPrototypeOf(result)
const andDesc = Object.getOwnPropertyDescriptor(proto, 'and')
const orDesc = Object.getOwnPropertyDescriptor(proto, 'or')
if (andDesc?.get) {
Object.defineProperty(result, 'and', {
get() { return wrapBeProxy(andDesc.get!.call(result)) },
configurable: true,
enumerable: true,
})
}
if (orDesc?.get) {
Object.defineProperty(result, 'or', {
get() { return wrapBeProxy(orDesc.get!.call(result)) },
configurable: true,
enumerable: true,
})
}
}
const wrapBeProxy = (obj: any): any => const wrapBeProxy = (obj: any): any =>
new Proxy(obj, { new Proxy(obj, {
get(target, prop) { get(target, prop) {
@@ -305,6 +328,7 @@ export async function imhotep(
currentInStore = result currentInStore = result
} }
assertionStore.set(ui, currentList) assertionStore.set(ui, currentList)
ensureAndOrProxied(result)
if (result && typeof result === 'object' && typeof (result as any).relation !== 'string') { if (result && typeof result === 'object' && typeof (result as any).relation !== 'string') {
return wrapBeProxy(result) return wrapBeProxy(result)
} }