diff --git a/e2e/src/conformance/specs/assertions-missing.spec.ts b/e2e/src/conformance/specs/assertions-missing.spec.ts index 7ff97e7..a9b7cff 100644 --- a/e2e/src/conformance/specs/assertions-missing.spec.ts +++ b/e2e/src/conformance/specs/assertions-missing.spec.ts @@ -13,6 +13,8 @@ export const missingElementAssertionsSpec = async (page: Page, expect: Expect): // Presence matchers: a missing element is simply absent. await expect(missing).not.toBeVisible(); await expect(missing).toHaveCount(0); + await expect(missing).toBeHidden(); + await expectOrdinaryFailure(expect, expect(missing).toBeVisible({ timeout: 1_000 })); // Value matchers: Playwright fails these — even negated — because there is no // element to read a value from. What matters here is that they fail as @@ -20,12 +22,20 @@ export const missingElementAssertionsSpec = async (page: Page, expect: Expect): await expectOrdinaryFailure(expect, expect(missing).toHaveText('go', { timeout: 1_000 })); await expectOrdinaryFailure(expect, expect(missing).not.toHaveText('go', { timeout: 1_000 })); await expectOrdinaryFailure(expect, expect(missing).not.toHaveAttribute('id', 'present', { timeout: 1_000 })); + + // An element that is not in the DOM yet: toBeVisible keeps polling until it + // shows up, rather than giving up on the first look. + await page.goto(pageWithBody(``)); + await expect(page.locator('#late')).toBeVisible({ timeout: 10_000 }); }; +const LATE_ELEMENT_DELAY_MS = 1_500; + // An assertion on a missing element must reject with a normal matcher failure, // never with Playwright's internal "Unexpected return from a matcher function". async function expectOrdinaryFailure(expect: Expect, assertion: Promise): Promise { const error = await assertion.then(() => null, (e: Error) => e); expect(error).toBeTruthy(); expect(error!.message).not.toContain('Unexpected return from a matcher function'); + expect(error!.message).toContain('expect('); } diff --git a/package-lock.json b/package-lock.json index 5b06781..e890677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1898,9 +1898,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/packages/mobilewright-core/src/web-expect-matcher.test.ts b/packages/mobilewright-core/src/web-expect-matcher.test.ts index 3ebeb67..90289e0 100644 --- a/packages/mobilewright-core/src/web-expect-matcher.test.ts +++ b/packages/mobilewright-core/src/web-expect-matcher.test.ts @@ -1,5 +1,15 @@ import { test, expect as playwrightExpect } from '@playwright/test'; -import { buildExpectEvaluate, textValue, type FrameExpectParams } from './web-expect-matcher.js'; +import { buildExpectEvaluate, missingElementVerdict, textValue, type ExpectResult, type FrameExpectParams } from './web-expect-matcher.js'; + +function verdictWhenNothingMatches(expression: string, opts: { isNot: boolean } = { isNot: false }): ExpectResult { + return missingElementVerdict({ expression, isNot: opts.isNot, timeout: 0 }); +} + +function evaluateFor(expression: string): string { + return buildExpectEvaluate('.btn', { expression, isNot: false, timeout: 0 }); +} + +const MISSING_ELEMENT_GUARD = 'if (elements.length === 0) { return null; }'; test.describe('web-expect-matcher', () => { test('textValue builds a string matcher with flags', () => { @@ -20,4 +30,43 @@ test.describe('web-expect-matcher', () => { playwrightExpect(js).toContain('"expression":"to.have.text"'); playwrightExpect(js).toContain('"string":"Hi"'); }); + + test('buildExpectEvaluate returns null instead of calling the matcher when no element matches', () => { + playwrightExpect(evaluateFor('to.be.visible')).toContain(MISSING_ELEMENT_GUARD); + }); + + test('buildExpectEvaluate lets array matchers see an empty element list', () => { + playwrightExpect(evaluateFor('to.have.count')).not.toContain(MISSING_ELEMENT_GUARD); + playwrightExpect(evaluateFor('to.have.text.array')).not.toContain(MISSING_ELEMENT_GUARD); + }); +}); + +// Mirrors the no-element branch of playwright-core's Frame._expectInternal. +test.describe('missingElementVerdict', () => { + test('a missing element is hidden', () => { + playwrightExpect(verdictWhenNothingMatches('to.be.hidden')).toEqual({ matches: true }); + }); + + test('a missing element is detached', () => { + playwrightExpect(verdictWhenNothingMatches('to.be.detached')).toEqual({ matches: true }); + }); + + test('a missing element is not visible, so toBeVisible keeps waiting', () => { + playwrightExpect(verdictWhenNothingMatches('to.be.visible')).toEqual({ matches: false, missingReceived: true }); + }); + + for (const expression of ['to.be.visible', 'to.be.attached', 'to.be.in.viewport']) { + test(`not ${expression} passes on a missing element`, () => { + playwrightExpect(verdictWhenNothingMatches(expression, { isNot: true })).toEqual({ matches: false }); + }); + } + + test('not.toBeHidden keeps waiting on a missing element', () => { + playwrightExpect(verdictWhenNothingMatches('to.be.hidden', { isNot: true })).toEqual({ matches: true, missingReceived: true }); + }); + + test('any other matcher reports the element as missing and does not pass', () => { + playwrightExpect(verdictWhenNothingMatches('to.have.text')).toEqual({ matches: false, missingReceived: true }); + playwrightExpect(verdictWhenNothingMatches('to.have.text', { isNot: true })).toEqual({ matches: true, missingReceived: true }); + }); }); diff --git a/packages/mobilewright-core/src/web-expect-matcher.ts b/packages/mobilewright-core/src/web-expect-matcher.ts index a81da77..e2f661a 100644 --- a/packages/mobilewright-core/src/web-expect-matcher.ts +++ b/packages/mobilewright-core/src/web-expect-matcher.ts @@ -1,6 +1,8 @@ // Builds the calling convention for Playwright's injected expect() matcher. -// Verified against playwright-core@1.58.2: injected.expect(element, params, -// elements) returns { matches, received, missingReceived }; pass = matches !== isNot. +// Verified against playwright-core@1.63.0: injected.expect(element, params, +// elements) returns { matches, received }; pass = matches !== isNot. The injected +// matcher requires an element, so a selector that matches nothing is decided +// here (missingElementVerdict), as playwright-core does in Frame._expectInternal. export interface ExpectedTextValue { string?: string; @@ -38,15 +40,42 @@ export function textValue( return { string: value, ...flags }; } +// Matchers that judge the whole element list, so an empty list is a valid input. +function isArrayExpression(expression: string): boolean { + return expression === 'to.have.count' || expression.endsWith('.array'); +} + +// Matchers that hold for an element that does not exist. +const TRUE_WHEN_MISSING = new Set(['to.be.hidden', 'to.be.detached']); + +// Negated matchers that pass outright on a missing element. +const NEGATION_PASSES_WHEN_MISSING = new Set(['to.be.visible', 'to.be.attached', 'to.be.in.viewport']); + +// The verdict for a selector that matches no element. Mirrors the no-element +// branch of playwright-core's Frame._expectInternal. +export function missingElementVerdict(params: FrameExpectParams): ExpectResult { + if (!params.isNot && TRUE_WHEN_MISSING.has(params.expression)) { + return { matches: true }; + } + if (params.isNot && NEGATION_PASSES_WHEN_MISSING.has(params.expression)) { + return { matches: false }; + } + return { matches: params.isNot, missingReceived: true }; +} + // A single self-contained evaluate: resolve the selector, run the injected -// matcher, return its serializable verdict. No JSHandles needed. +// matcher, return its serializable verdict. No JSHandles needed. Resolves to +// null when a single-element matcher finds no element; the caller then uses +// missingElementVerdict. export function buildExpectEvaluate(selector: string, params: FrameExpectParams): string { const sel = JSON.stringify(selector); const opts = JSON.stringify(params); + const missingElementGuard = isArrayExpression(params.expression) ? '' : 'if (elements.length === 0) { return null; }'; return `(async () => { const is = window.__mwInjected; const elements = is.querySelectorAll(is.parseSelector(${sel}), document); + ${missingElementGuard} const r = await is.expect(elements[0], ${opts}, elements); - return { matches: r.matches, received: r.received, missingReceived: r.missingReceived }; + return { matches: r.matches, received: r.received }; })()`; } diff --git a/packages/mobilewright-core/src/web-locator.test.ts b/packages/mobilewright-core/src/web-locator.test.ts index cda3fbc..cebed2f 100644 --- a/packages/mobilewright-core/src/web-locator.test.ts +++ b/packages/mobilewright-core/src/web-locator.test.ts @@ -508,3 +508,33 @@ test.describe('WebLocator step instrumentation', () => { playwrightExpect(titles).toEqual([]); }); }); + +// ─── expect() on an element that is not in the DOM yet ─────── +// The injected evaluate resolves to null when the selector matches nothing. + +test.describe('_expect on a missing element', () => { + const NO_ELEMENT = null; + + test('toBeVisible keeps polling until the element appears', async () => { + const { session, evaluateCalls } = sessionReturning(NO_ELEMENT, NO_ELEMENT, { matches: true }); + const loc = new WebLocator(session, '.not-in-the-dom-yet'); + const result = await loc._expect('to.be.visible', { timeout: 5_000 }); + playwrightExpect(result).toMatchObject({ matches: true, timedOut: false }); + playwrightExpect(evaluateCalls).toHaveLength(3); + }); + + test('toBeVisible times out when the element never appears', async () => { + const { session } = sessionAlwaysReturning(NO_ELEMENT); + const loc = new WebLocator(session, '.never-there'); + const result = await loc._expect('to.be.visible', { timeout: 200 }); + playwrightExpect(result).toMatchObject({ matches: false, timedOut: true }); + }); + + test('toBeHidden passes immediately', async () => { + const { session, evaluateCalls } = sessionAlwaysReturning(NO_ELEMENT); + const loc = new WebLocator(session, '.never-there'); + const result = await loc._expect('to.be.hidden', { timeout: 5_000 }); + playwrightExpect(result).toMatchObject({ matches: true, timedOut: false }); + playwrightExpect(evaluateCalls).toHaveLength(1); + }); +}); diff --git a/packages/mobilewright-core/src/web-locator.ts b/packages/mobilewright-core/src/web-locator.ts index c9e2473..a0be989 100644 --- a/packages/mobilewright-core/src/web-locator.ts +++ b/packages/mobilewright-core/src/web-locator.ts @@ -16,7 +16,7 @@ import { TEST_ID_ATTR, evaluateWithEngine, } from './playwright-engine.js'; -import { buildExpectEvaluate, type FrameExpectParams, type ExpectResult, type ExpectedTextValue } from './web-expect-matcher.js'; +import { buildExpectEvaluate, missingElementVerdict, type FrameExpectParams, type ExpectResult, type ExpectedTextValue } from './web-expect-matcher.js'; const DEFAULT_TIMEOUT = 5_000; const EXPECT_POLL_INTERVAL = 100; @@ -401,7 +401,8 @@ export class MobileWebViewLocator { // return its raw verdict. The assertion layer (expect.ts) decides pass/fail // (pass = matches !== isNot) and handles retry/negation/messages. async _runInjectedExpect(params: FrameExpectParams): Promise { - return this.evalEngine(buildExpectEvaluate(this.selector, params)); + const verdict = await this.evalEngine(buildExpectEvaluate(this.selector, params)); + return verdict ?? missingElementVerdict(params); } // The private hook Playwright's web-first matchers call: expect(locator).toBeX()