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
Version
1.63.0
Steps to reproduce
npx playwright test repro.spec.mjs --browser=webkitExpected behavior
The assertion fails after about 5s with a timeout error, as requested by
timeout: 5_000Actual behavior
The assertion never times out on its own. It hangs until the
testtimeout, 30s here, kills the test: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
toBeVisiblewith a 15s expect timeout ran for 2.6 minutes, until our 180s test timeout stopped it. Withretries: 1, each failure costs about 6 minutes. The error also misleads:Received: undefinedreads 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.expectfirst runs a one-shot check via_expectInternal(..., /* noAbort */ true). WithnoAbortset,progressis replaced bynullProgress, whoserace()is a plainPromise.race([promise])without the controller's abort promise. TheProgressControllertimer does fire at the deadline. It only rejects_forceAbortPromiseand aborts the signal, and nothing in the one-shot path observes either. If the page never answers the firstcallOnSelector/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 reportsTimeout 5000ms exceeded.Reproduces with Chromium (Google Chrome channel) and WebKit.
Environment