From 8a295ef7dbb71940d78394f1fb422c52b52149f4 Mon Sep 17 00:00:00 2001 From: Dmitrijs Munda <7899764+dimkin-eu@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:35:10 +0300 Subject: [PATCH] fix(expect): honor timeout when the page is unresponsive The one-shot expect check is allowed to finish after the deadline, so that `expect(locator).toBeVisible({ timeout: 1 })` passes when the element is already visible. It was not bounded at all, though: when the page's event loop was blocked, the call hung until the test timeout and reported `Received: undefined` instead of a timeout. Give the one-shot a short grace period after the call is aborted, then fail with the regular timeout error. Fixes https://github.com/microsoft/playwright/issues/42880 --- packages/playwright-core/src/server/frames.ts | 28 +++++++++++++++++-- tests/page/expect-timeout.spec.ts | 13 +++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index c68329b61028e..c0623e18ff66d 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -510,6 +510,28 @@ export type FrameEventMap = { [FrameEvent.RemoveLifecycle]: [event: types.LifecycleEvent]; }; +// How long the one-shot expect check may keep running after the call has been aborted. +const kOneShotGracePeriod = 1000; + +async function raceWithGracePeriod(progress: Progress, promise: Promise, gracePeriod: number): Promise { + let timer: ReturnType | undefined; + const onAbort = () => { + timer = setTimeout(() => rejectOnAbort(progress.signal.reason), gracePeriod); + }; + let rejectOnAbort: (reason: any) => void = () => {}; + const aborted = new Promise((_, reject) => rejectOnAbort = reject); + if (progress.signal.aborted) + onAbort(); + else + progress.signal.addEventListener('abort', onAbort, { once: true }); + try { + return await Promise.race([promise, aborted]); + } finally { + clearTimeout(timer); + progress.signal.removeEventListener('abort', onAbort); + } +} + export class Frame extends SdkObject { static Events = FrameEvent; @@ -1483,11 +1505,13 @@ export class Frame extends SdkObject { progress.log(`waiting for ${this._asLocator(selector)}`); await this._page.performActionPreChecks(progress); - // Step 2: perform one-shot expect check without a timeout. + // Step 2: perform one-shot expect check that may run past the deadline. // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. + // The grace period prevents an unresponsive page (e.g. a blocked event loop) + // from holding the call past its timeout. try { - const resultOneShot = await this._expectInternal(progress, selector, options, lastIntermediateResult, true); + const resultOneShot = await raceWithGracePeriod(progress, this._expectInternal(progress, selector, options, lastIntermediateResult, true), kOneShotGracePeriod); if (resultOneShot.matches !== options.isNot) return; } catch (e) { diff --git a/tests/page/expect-timeout.spec.ts b/tests/page/expect-timeout.spec.ts index 04f8b6544d61c..6e3fa5414f02b 100644 --- a/tests/page/expect-timeout.spec.ts +++ b/tests/page/expect-timeout.spec.ts @@ -211,3 +211,16 @@ Error: The assertion was aborted: stop it `); } }); + +test('should respect timeout when the page is unresponsive', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42880' } }, async ({ page }) => { + test.setTimeout(10000); + await page.setContent('
hello
'); + await page.evaluate(() => { + setTimeout(() => { + while (true) {} + }, 0); + }); + await new Promise(f => setTimeout(f, 100)); + const error = await expect(page.locator('div')).toBeVisible({ timeout: 1000 }).catch(e => e); + expect(stripAnsi(error.message)).toContain('Timeout: 1000ms'); +});