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()