#42880·Playwright

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

Author: dimkin-euCreated Sep 23, 2026Updated Sep 28, 2026
LabelsP3-collecting-feedbackfeature-expect

Version

1.63.0

Steps to reproduce

javascript
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:

javascript
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

bash
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