Skip to content

[Bug]: expect(locator).toBeVisible() ignores its timeout when the page is unresponsive; hangs until test timeout #42880

Description

Version

1.63.0

Steps to reproduce

import { test, expect } from "@playwright/test";

test("toBeVisible ignores its timeout on a frozen page", async ({ page }) => {
  test.setTimeout(30_000);
  await page.goto("https://playwright.dev/");
  await page.evaluate(() => setTimeout(() => { for (;;) {} }));
  await expect(page.getByRole("link", { name: "Get started" })).toBeVisible({ timeout: 5_000 });
});

npx playwright test repro.spec.mjs --browser=webkit

Expected behavior

The assertion fails after about 5s with a timeout error, as requested by timeout: 5_000

Actual behavior

The assertion never times out on its own. It hangs until the test timeout, 30s here, kills the test:

Test timeout of 30000ms exceeded.

Error: expect(locator).toBeVisible() failed

Locator:  getByRole('link', { name: 'Get started' })
Expected: visible
Received: undefined

Call log:
  - Expect "toBeVisible" getByRole('link', { name: 'Get started' }) with timeout 5000ms
  - waiting for getByRole('link', { name: 'Get started' })

The call log shows the 5000ms timeout was received, but it is not enforced.

Additional context

Real-world impact. We hit this in CI when WebKit's renderer hung intermittently mid-test. A toBeVisible with a 15s expect timeout ran for 2.6 minutes, until our 180s test timeout stopped it. With retries: 1 , each failure costs about 6 minutes. The error also misleads: Received: undefined reads like a missing element, but the element was present and the page had simply stopped responding.

Likely cause, from reading the bundled server code. Frame.expect first runs a one-shot check via _expectInternal(..., /* noAbort */ true). With noAbort set, progress is replaced by nullProgress, whose race() is a plain Promise.race([promise]) without the controller's abort promise. The ProgressController timer does fire at the deadline. It only rejects _forceAbortPromise and aborts the signal, and nothing in the one-shot path observes either. If the page never answers the first callOnSelector/evaluate, the call stays pending until the test runner's own timeout. The retry loop that runs after the one-shot check is correctly bounded; only the initial probe is not.

Suggested fix. Bound the one-shot probe by the same deadline, for example by racing it against the progress abort promise while still suppressing its logs. Then throw a normal TimeoutError, so the message reports Timeout 5000ms exceeded.

Reproduces with Chromium (Google Chrome channel) and WebKit.

Environment

System: macOS 27.0 (arm64)
Node: 24.21.0
@playwright/test: 1.63.0
Browsers: WebKit 26.6 (playwright build 2359), Google Chrome (stable channel)
Also observed on: Linux CI (Ubuntu noble container, Playwright 1.63.0), WebKit

Activity

  1. dimkin-eu commented on Sep 23, 2026

    @dimkin-eu
    ContributorAuthor

    Related: #36702 is the same class of bug for page.screenshot, fixed in #40901 (1.61) by racing cleanup against progress. expect has the same gap in its one-shot probe. #13253 (no timeout on evaluate against a busy page) is the underlying cause.

    The same one-shot code path is present in 1.62.1 and 1.64.0-alpha-2026-09-14.

  2. alok-108 commented on Sep 26, 2026

    @alok-108
    Contributor

    I've submitted a fix in PR #42948 with accompanying regression test coverage. It restricts
    oAbort\ during the initial probe to impossible timeouts (\ imeout <= 100), ensuring configured timeouts are strictly honored even when the page event loop is blocked.

  3. dimkin-eu commented on Sep 28, 2026

    @dimkin-eu
    ContributorAuthor

    Alok Pandey (@alok-108) my fix was declined as "not-so-bug". Dmitry Gozman (@dgozman) not fixing this is against "fail fast" - test is waiting not expect timeout, but test (which usually could be way longer ) :(

  4. alok-108 commented on Sep 28, 2026

    @alok-108
    Contributor

    Dmitry Munda (@dimkin-eu) yeah saw your PR got closed, that's rough. The fail-fast argument makes sense to me. 15 seconds turning into 2.6 minutes, and then with retries it's basically 6 minutes per failure. That's brutal on CI.

    I'm still waiting on Dmitry Gozman (@dgozman) to say whether #42948 is worth pursuing. If he says go ahead, I'll probably rework it the way you suggested. Racing the one-shot probe against the progress abort promise and suppressing the logs seems cleaner than the threshold hack I have right now. Your read of the code matches mine too. The retry loop is bounded, but the initial probe isn't.

    If they decide it's out of scope, I'll at least push for a doc note about expect timeout not applying when the event loop is blocked. Otherwise people keep getting bitten by this in CI with no explanation.

  5. pavelfeldman commented on Oct 8, 2026

    @pavelfeldman
    Member

    We aren't particularly good at pages with tight loops, closing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions