Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions e2e/src/conformance/specs/assertions-missing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,29 @@ 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
// ordinary assertion failures.
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(`<script>setTimeout(() => { document.body.insertAdjacentHTML('beforeend', '<p id="late">here</p>'); }, ${LATE_ELEMENT_DELAY_MS});</script>`));
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<void>): Promise<void> {
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(');
}
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 50 additions & 1 deletion packages/mobilewright-core/src/web-expect-matcher.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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 });
});
});
37 changes: 33 additions & 4 deletions packages/mobilewright-core/src/web-expect-matcher.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 };
})()`;
}
30 changes: 30 additions & 0 deletions packages/mobilewright-core/src/web-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 3 additions & 2 deletions packages/mobilewright-core/src/web-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ExpectResult> {
return this.evalEngine<ExpectResult>(buildExpectEvaluate(this.selector, params));
const verdict = await this.evalEngine<ExpectResult | null>(buildExpectEvaluate(this.selector, params));
return verdict ?? missingElementVerdict(params);
}

// The private hook Playwright's web-first matchers call: expect(locator).toBeX()
Expand Down
Loading