diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 45c4ad89c7..f4d31580bb 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -221,7 +221,7 @@ export class Locator implements api.Locator { }); } - async _expect(expression: string, options: FrameExpectOptions): Promise<{ pass: boolean, received?: any, log?: string[] }> { + async _expect(expression: string, options: FrameExpectOptions): Promise<{ matches: boolean, received?: any, log?: string[] }> { return this._frame._wrapApiCall(async (channel: channels.FrameChannel) => { const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; if (options.expectedValue) diff --git a/packages/playwright-core/src/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/dispatchers/frameDispatcher.ts index f643174bbc..914cc7e9c1 100644 --- a/packages/playwright-core/src/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/frameDispatcher.ts @@ -232,7 +232,7 @@ export class FrameDispatcher extends Dispatcher = (injectedScript: js.JSHandle) => Promise>>; -export type DomTaskBody = (progress: InjectedScriptProgress, element: E, data: T, elements: Element[], continuePolling: any) => R; +export type DomTaskBody = (progress: InjectedScriptProgress, element: E, data: T, elements: Element[], continuePolling: symbol) => R | symbol; export class FrameManager { private _page: Page; @@ -1161,26 +1161,47 @@ export class Frame extends SdkObject { }); } - async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ pass: boolean, received?: any, log?: string[] }> { + async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[] }> { const controller = new ProgressController(metadata, this); - const isListMatcher = options.expression.endsWith('.array'); - const querySelectorAll = options.expression === 'to.have.count' || isListMatcher; + const querySelectorAll = options.expression === 'to.have.count' || options.expression.endsWith('.array'); const mainWorld = options.expression === 'to.have.property'; - const expectsEmptyList = options.expectedText?.length === 0; - const omitAttached = (isListMatcher && options.isNot !== expectsEmptyList) || (!options.isNot && options.expression === 'to.be.hidden') || (options.isNot && options.expression === 'to.be.visible'); return await this._scheduleRerunnableTaskWithController(controller, selector, (progress, element, options, elements, continuePolling) => { - // We don't have an element and we don't need an element => pass. - if (!element && options.omitAttached) - return { pass: !options.isNot }; - // We don't have an element and we DO need an element => fail. - if (!element) - return { pass: !!options.isNot }; - // We have an element. - return progress.injectedScript.expect(progress, element!, options, elements, continuePolling); - }, { omitAttached, ...options }, { strict: true, querySelectorAll, mainWorld, omitAttached, logScale: true, ...options }).catch(e => { + if (!element) { + // expect(locator).toBeHidden() passes when there is no element. + if (!options.isNot && options.expression === 'to.be.hidden') + return { matches: true }; + + // expect(locator).not.toBeVisible() passes when there is no element. + if (options.isNot && options.expression === 'to.be.visible') + return { matches: false }; + + // expect(listLocator).toHaveText([]) passes when there are no elements matching. + // expect(listLocator).not.toHaveText(['foo']) passes when there are no elements matching. + const expectsEmptyList = options.expectedText?.length === 0; + if (options.expression.endsWith('.array') && expectsEmptyList !== options.isNot) + return { matches: expectsEmptyList }; + + // When none of the above applies, keep waiting for the element. + return continuePolling; + } + + const { matches, received } = progress.injectedScript.expect(progress, element, options, elements); + if (matches === options.isNot) { + // Keep waiting in these cases: + // expect(locator).conditionThatDoesNotMatch + // expect(locator).not.conditionThatDoesMatch + progress.setIntermediateResult(received); + if (!Array.isArray(received)) + progress.log(` unexpected value "${received}"`); + return continuePolling; + } + + // Reached the expected state! + return { matches, received }; + }, options, { strict: true, querySelectorAll, mainWorld, omitAttached: true, logScale: true, ...options }).catch(e => { if (js.isJavaScriptErrorInEvaluate(e)) throw e; - return { received: controller.lastIntermediateResult(), pass: !!options.isNot, log: metadata.log }; + return { received: controller.lastIntermediateResult(), matches: options.isNot, log: metadata.log }; }); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 74c24ffdc8..672a8be9dd 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -775,7 +775,7 @@ export class InjectedScript { return error; } - expect(progress: InjectedScriptProgress, element: Element, options: FrameExpectParams, elements: Element[], continuePolling: any): { pass: boolean, received?: any } { + expect(progress: InjectedScriptProgress, element: Element, options: FrameExpectParams, elements: Element[]): { matches: boolean, received?: any } { const injected = progress.injectedScript; const expression = options.expression; @@ -808,12 +808,7 @@ export class InjectedScript { throw injected.createStacklessError('Element is not a checkbox'); if (elementState === 'error:notconnected') throw injected.createStacklessError('Element is not connected'); - if (elementState === options.isNot) { - progress.setIntermediateResult(elementState); - progress.log(` unexpected value "${elementState}"`); - return continuePolling; - } - return { pass: !options.isNot }; + return { received: elementState, matches: elementState }; } } @@ -822,12 +817,7 @@ export class InjectedScript { if (expression === 'to.have.count') { const received = elements.length; const matches = received === options.expectedNumber; - if (matches === options.isNot) { - progress.setIntermediateResult(received); - progress.log(` unexpected value "${received}"`); - return continuePolling; - } - return { pass: !options.isNot, received }; + return { received, matches }; } } @@ -836,12 +826,7 @@ export class InjectedScript { if (expression === 'to.have.property') { const received = (element as any)[options.expressionArg]; const matches = deepEquals(received, options.expectedValue); - if (matches === options.isNot) { - progress.setIntermediateResult(received); - progress.log(` unexpected value "${received}"`); - return continuePolling; - } - return { received, pass: !options.isNot }; + return { received, matches }; } } @@ -870,12 +855,7 @@ export class InjectedScript { if (received !== undefined && options.expectedText) { const matcher = new ExpectedTextMatcher(options.expectedText[0]); - if (matcher.matches(received) === options.isNot) { - progress.setIntermediateResult(received); - progress.log(` unexpected value "${received}"`); - return continuePolling; - } - return { received, pass: !options.isNot }; + return { received, matches: matcher.matches(received) }; } } @@ -891,12 +871,8 @@ export class InjectedScript { // "To match an array" is "to contain an array" + "equal length" const lengthShouldMatch = expression !== 'to.contain.text.array'; const matchesLength = received.length === options.expectedText.length || !lengthShouldMatch; - if (matchesLength === options.isNot) { - progress.setIntermediateResult(received); - return continuePolling; - } if (!matchesLength) - return { received, pass: !options.isNot }; + return { received, matches: false }; // Each matcher should get a "received" that matches it, in order. let i = 0; @@ -910,11 +886,7 @@ export class InjectedScript { break; } } - if (allMatchesFound === options.isNot) { - progress.setIntermediateResult(received); - return continuePolling; - } - return { received, pass: !options.isNot }; + return { received, matches: allMatchesFound }; } } throw this.createStacklessError('Unknown expect matcher: ' + options.expression); diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index af9be234c2..e86682e152 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -23,7 +23,7 @@ import { toEqual } from './toEqual'; import { toExpectedTextValues, toMatchText } from './toMatchText'; interface LocatorEx extends Locator { - _expect(expression: string, options: FrameExpectOptions): Promise<{ pass: boolean, received?: any, log?: string[] }>; + _expect(expression: string, options: FrameExpectOptions): Promise<{ matches: boolean, received?: any, log?: string[] }>; } export function toBeChecked( diff --git a/packages/playwright-test/src/matchers/toBeTruthy.ts b/packages/playwright-test/src/matchers/toBeTruthy.ts index 18ffee4bd8..226cb284e1 100644 --- a/packages/playwright-test/src/matchers/toBeTruthy.ts +++ b/packages/playwright-test/src/matchers/toBeTruthy.ts @@ -24,7 +24,7 @@ export async function toBeTruthy( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, log?: string[] }>, + query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, log?: string[] }>, options: { timeout?: number } = {}, ) { const testInfo = currentTestInfo(); @@ -42,11 +42,11 @@ export async function toBeTruthy( defaultExpectTimeout = 5000; const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout; - const { pass, log } = await query(this.isNot, timeout); + const { matches, log } = await query(this.isNot, timeout); const message = () => { return this.utils.matcherHint(matcherName, undefined, '', matcherOptions) + callLogText(log); }; - return { message, pass }; + return { message, pass: matches }; } diff --git a/packages/playwright-test/src/matchers/toEqual.ts b/packages/playwright-test/src/matchers/toEqual.ts index 34a5c0f417..9c12242c08 100644 --- a/packages/playwright-test/src/matchers/toEqual.ts +++ b/packages/playwright-test/src/matchers/toEqual.ts @@ -31,7 +31,7 @@ export async function toEqual( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, received?: any, log?: string[] }>, + query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: any, log?: string[] }>, expected: T, options: { timeout?: number, contains?: boolean } = {}, ) { @@ -51,7 +51,7 @@ export async function toEqual( defaultExpectTimeout = 5000; const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout; - const { pass, received, log } = await query(this.isNot, timeout); + const { matches: pass, received, log } = await query(this.isNot, timeout); const message = pass ? () => diff --git a/packages/playwright-test/src/matchers/toMatchText.ts b/packages/playwright-test/src/matchers/toMatchText.ts index 77d615185e..e45faddcac 100644 --- a/packages/playwright-test/src/matchers/toMatchText.ts +++ b/packages/playwright-test/src/matchers/toMatchText.ts @@ -31,7 +31,7 @@ export async function toMatchText( matcherName: string, receiver: any, receiverType: string, - query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, received?: string, log?: string[] }>, + query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: string, log?: string[] }>, expected: string | RegExp, options: { timeout?: number, matchSubstring?: boolean } = {}, ) { @@ -65,7 +65,7 @@ export async function toMatchText( defaultExpectTimeout = 5000; const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout; - const { pass, received, log } = await query(this.isNot, timeout); + const { matches: pass, received, log } = await query(this.isNot, timeout); const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const receivedString = received || ''; const message = pass diff --git a/tests/playwright-test/playwright.expect.text.spec.ts b/tests/playwright-test/playwright.expect.text.spec.ts index ab1ad11c76..b4d1a88272 100644 --- a/tests/playwright-test/playwright.expect.text.spec.ts +++ b/tests/playwright-test/playwright.expect.text.spec.ts @@ -184,6 +184,12 @@ test('should support toHaveText w/ array', async ({ runInlineTest }) => { await expect(locator).not.toHaveText(['Test']); }); + test('fail on not+empty', async ({ page }) => { + await page.setContent('
'); + const locator = page.locator('p'); + await expect(locator).not.toHaveText([], { timeout: 1000 }); + }); + test('pass eventually empty', async ({ page }) => { await page.setContent('

Text

'); const locator = page.locator('p'); @@ -207,7 +213,7 @@ test('should support toHaveText w/ array', async ({ runInlineTest }) => { expect(output).toContain('waiting for selector "div"'); expect(output).toContain('selector resolved to 2 elements'); expect(result.passed).toBe(6); - expect(result.failed).toBe(1); + expect(result.failed).toBe(2); expect(result.exitCode).toBe(1); });