diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 26c30b4c40..acd5bc2927 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -29,7 +29,7 @@ import * as types from './types'; import { BrowserContext } from './browserContext'; import type { Progress } from './progress'; import { ProgressController } from './progress'; -import { assert, constructURLBasedOnBaseURL, makeWaitForNextTask } from '../utils'; +import { assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime } from '../utils'; import { ManualPromise } from '../utils/manualPromise'; import { debugLogger } from '../common/debugLogger'; import type { CallMetadata } from './instrumentation'; @@ -1359,13 +1359,27 @@ export class Frame extends SdkObject { } async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { + let timeout = this._page._timeoutSettings.timeout(options); + const start = timeout > 0 ? monotonicTime() : 0; + const resultOneShot = await this._expectInternal(metadata, selector, options, true, timeout); + if (resultOneShot.matches !== options.isNot) + return resultOneShot; + if (timeout > 0) { + const elapsed = monotonicTime() - start; + timeout -= elapsed; + } + if (timeout < 0) + return { matches: options.isNot, log: metadata.log, timedOut: true }; + return await this._expectInternal(metadata, selector, options, false, timeout); + } + + private async _expectInternal(metadata: CallMetadata, selector: string, options: FrameExpectParams, oneShot: boolean, timeout: number): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { const controller = new ProgressController(metadata, this); const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); const mainWorld = options.expression === 'to.have.property'; - const timeout = this._page._timeoutSettings.timeout(options); // List all combinations that are satisfied with the detached node(s). - let omitAttached = false; + let omitAttached = oneShot; if (!options.isNot && options.expression === 'to.be.hidden') omitAttached = true; else if (options.isNot && options.expression === 'to.be.visible') @@ -1380,7 +1394,8 @@ export class Frame extends SdkObject { omitAttached = true; return controller.run(async outerProgress => { - outerProgress.log(`${metadata.apiName}${timeout ? ` with timeout ${timeout}ms` : ''}`); + if (oneShot) + outerProgress.log(`${metadata.apiName}${timeout ? ` with timeout ${timeout}ms` : ''}`); return await this._scheduleRerunnableTaskWithProgress(outerProgress, selector, (progress, element, options, elements) => { let result: { matches: boolean, received?: any }; @@ -1395,7 +1410,7 @@ export class Frame extends SdkObject { if (options.isNot && options.expression === 'to.be.visible') return { matches: false }; // When none of the above applies, keep waiting for the element. - return progress.continuePolling; + return options.oneShot ? { matches: options.isNot } : progress.continuePolling; } result = progress.injectedScript.expectSingleElement(progress, element, options); } @@ -1407,13 +1422,13 @@ export class Frame extends SdkObject { progress.setIntermediateResult(result.received); if (!Array.isArray(result.received)) progress.log(` unexpected value "${progress.injectedScript.renderUnexpectedValue(options.expression, result.received)}"`); - return progress.continuePolling; + return options.oneShot ? result : progress.continuePolling; } // Reached the expected state! return result; - }, { ...options, isArray }, { strict: true, querySelectorAll: isArray, mainWorld, omitAttached, logScale: true, ...options }); - }, timeout).catch(e => { + }, { ...options, isArray, oneShot }, { strict: true, querySelectorAll: isArray, mainWorld, omitAttached, logScale: true, ...options }); + }, oneShot ? 0 : timeout).catch(e => { // Q: Why not throw upon isSessionClosedError(e) as in other places? // A: We want user to receive a friendly message containing the last intermediate result. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) diff --git a/tests/page/expect-boolean.spec.ts b/tests/page/expect-boolean.spec.ts index 8b9e325b5e..3cf89c10b5 100644 --- a/tests/page/expect-boolean.spec.ts +++ b/tests/page/expect-boolean.spec.ts @@ -96,6 +96,16 @@ test.describe('toBeChecked', () => { const message2 = await expect(page.locator('input')).toBeChecked({ checked: false, timeout: 1000 }).catch(e => e.message); expect(message2).toContain('unexpected value "checked"'); }); + + test('with impossible timeout', async ({ page }) => { + await page.setContent(''); + await expect(page.locator('input')).toBeChecked({ timeout: 1 }); + }); + + test('with impossible timeout .not', async ({ page }) => { + await page.setContent(''); + await expect(page.locator('input')).not.toBeChecked({ timeout: 1 }); + }); }); test.describe('toBeEditable', () => { @@ -291,6 +301,16 @@ test.describe('toBeVisible', () => { const error = await expect(locator).not.toBeVisible({ timeout: 1000 }).catch(e => e); expect(error.message).toContain(`locator resolved to `); }); + + test('with impossible timeout', async ({ page }) => { + await page.setContent('