Playwright 1.64 and 1.65 Upgrade: Why Your Tests Fail Now

8 min read.
Tags: TypeScriptJavaScript

Playwright 1.64 (October 2026) and 1.65 (November 2026) changed enough defaults that a plain version bump can turn a green suite red. Common symptoms are a toBeVisible() that suddenly says hidden, a fill() that times out with element is inert, or a page object that throws TypeError: Cannot read properties of undefined. Every failure below was reproduced by running one test suite on 1.63.0, then 1.64.1, then 1.65.0, and each fix was checked on 1.65.

Quick answer

Find your error message in this list:

  • Received: "1920x1080" for screen.width or a matchMedia check: add screen: undefined next to ...devices['Desktop Chrome'] (1.64).
  • toBeVisible() fails with Received: hidden inside an iframe, or the call log says element is inside a hidden frame: make the iframe visible first (1.64).
  • Cannot find module 'react/jsx-runtime' or React is not defined in a .spec.tsx: install react or set jsxImportSource (1.64).
  • The call log says element is inert: close the modal or dialog that set inert before acting (1.65).
  • TypeError: Cannot read properties of undefined in a page-object field: move the field assignment into the constructor (1.65).
  • toMatchAriaSnapshot() or getByRole({ name }) lost text: the text isn't rendered, so update the snapshot or use hasText (1.65).
  • cookies() returns []: give the cookie a leading-dot domain or fix the path (1.65).
  • Error: No tests found in CI with --shard and --grep: add --pass-with-no-tests (1.65).

The details for each are below.

1.64: window.screen is now the device size

Before 1.64, spreading devices['Desktop Chrome'] set the viewport to 1280x720, and window.screen matched it. In 1.64 the descriptor also sets screen, and for Desktop Chrome that's 1920x1080. The viewport (innerWidth) stays 1280x720, but screen.width and device-width media queries change:

Error: expect(received).toBe(expected) // Object.is equality
Expected: "1280x720"
Received: "1920x1080"

If your app or your test reads screen.width or uses (max-device-width: ...), reset it:

// playwright.config.ts
use: {
  ...devices['Desktop Chrome'],
  screen: undefined,
},

With screen: undefined, screen.width goes back to 1280 and the old media query results return.

1.64: elements inside a hidden iframe are hidden

An element inside an iframe with visibility: hidden used to count as visible. Now it doesn't, for assertions and for actions:

Error: expect(locator).toBeVisible() failed
Locator:  locator('#chat').contentFrame().getByRole('button', { name: 'Send' })
Expected: visible
Received: hidden

fill() keeps retrying until it times out:

TimeoutError: locator.fill: Timeout 2000ms exceeded.
Call log:
  - waiting for locator('#chat').contentFrame().getByLabel('Message')
    19 × locator resolved to <input aria-label="Message"/>
       - element is inside a hidden frame, retrying

click() already failed in this case on 1.63 (with an intercepts pointer events message), so the newly broken calls are toBeVisible(), isVisible() and the non-pointer actions like fill().

The old passes were wrong. A user can't type into a hidden iframe. Open it the way a user would, then assert:

const chat = page.locator('#chat').contentFrame();
await expect(chat.getByRole('button', { name: 'Send' })).toBeHidden();

await page.getByRole('button', { name: 'Open chat' }).click();
await expect(chat.getByRole('button', { name: 'Send' })).toBeVisible();
await chat.getByLabel('Message').fill('hi');

1.64: JSX in test files follows tsconfig

Up to 1.63, Playwright ignored your tsconfig jsx setting and compiled JSX in test files into its own plain objects. In 1.64 it follows tsconfig and defaults to the automatic runtime. A project without react installed, for example an E2E package in a monorepo, now stops before running anything:

Error: Cannot find module 'react/jsx-runtime'
Require stack:
- .../tests/jsx.spec.tsx
...
Error: No tests found

That happens with "jsx": "preserve", which is the Next.js default, and with "react-jsx". With "jsx": "react" you get ReferenceError: React is not defined instead.

Install react in the package that runs Playwright, or point jsxImportSource at whatever JSX runtime you do have. With react installed the test passes, but the values are real React elements now. A test that compared them to plain objects will need updating.

1.64: --update-snapshots=missing now passes

This one goes from red to green. On 1.63, --update-snapshots=missing wrote the new snapshot and still exited 1. On 1.64 it writes the snapshot and exits 0. If a CI step relied on that failure to catch tests with missing snapshots, it no longer does. Run without the flag in CI, which still fails on a missing snapshot.

1.65: actions wait for inert elements

click() on an element behind a modal with inert set already timed out before 1.65, but the call log blamed something else (<body>…</body> intercepts pointer events). 1.65 names the cause:

TimeoutError: locator.click: Timeout 2000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Save draft' })
    - locator resolved to <button>Save draft</button>
  - attempting click action
    2 × waiting for element to be visible, enabled and stable
      - element is inert

The bigger change is in fill(), clear() and selectOption(). On 1.63 they "passed" on an inert element. fill() did nothing and left the value empty, and selectOption() changed the value even though a user couldn't. A test that filled a field while a modal was still open passed the action and only failed on the next toHaveValue(). On 1.65 the action itself times out:

TimeoutError: locator.fill: Timeout 2000ms exceeded.
Call log:
  - waiting for getByLabel('Title')
    - locator resolved to <input aria-label="Title"/>
    - fill("hello")
  - attempting fill action
    2 × waiting for element to be visible, enabled and editable
      - element is inert

If the page removes inert shortly after, 1.65 waits and the action passes. If it stays inert, your test is acting while the modal is still open. Close the modal first:

await page.getByRole('button', { name: 'Close' }).click();
await page.getByLabel('Title').fill('hello');

To assert the blocked state on purpose, check the attribute instead of acting:

await expect(page.locator('main')).toHaveAttribute('inert');

click({ force: true }) still clicks an inert element on 1.65, but that hides the bug instead of fixing it.

1.65: page objects throw TypeError

Plenty of page objects are written like this:

class LoginPage {
  readonly email = this.page.getByLabel('Email');

  constructor(private readonly page: Page) {}
}

On 1.65, with a tsconfig target of ES2022 or later (or no target at all), it throws:

TypeError: Cannot read properties of undefined (reading 'getByLabel')

  3 | class LoginPage {
> 4 |   readonly email = this.page.getByLabel('Email');
    |                              ^

Playwright's TypeScript transform now follows useDefineForClassFields, which is on by default for ES2022 targets. Field initializers run before the constructor parameter page is assigned. Projects with target: "ES2017" still pass.

Assign the fields in the constructor:

class LoginPage {
  readonly email: Locator;

  constructor(private readonly page: Page) {
    this.email = page.getByLabel('Email');
  }
}

That works on every version. The quick bridge is "useDefineForClassFields": false in the tsconfig Playwright uses, but that changes how all class fields compile.

TypeScript already reports this before the upgrade: error TS2729: Property 'page' is used before its initialization. If your e2e folder isn't covered by tsc --noEmit, add it.

1.65: aria snapshots skip text that isn't rendered

Accessible names and aria snapshots now leave out text the browser doesn't render. That includes the contents of a closed <details> and light-DOM text a web component doesn't slot.

Error: expect(locator).toMatchAriaSnapshot(expected) failed
Expected: "- group: Shipping Orders ship in 2 days."
Received: "- group: Shipping"

A custom element <fancy-button role="button">Buy now</fancy-button> whose shadow root only renders an icon now has the accessible name 🛒 instead of Buy now🛒. getByRole('button', { name: 'Buy now' }) finds nothing. Screen readers wouldn't read that text either, so update the snapshot (or open the <details> first) and match on text when you need to:

await page.getByRole('button').filter({ hasText: 'Buy now' }).click();

The better fix is in the component. Add a <slot> and the name becomes 🛒 Buy now.

1.65: cookies() follows RFC 6265

Two cookie queries return less than before:

await context.addCookies([{ name: 'session', value: '1', url: 'https://example.com/' }]);
await context.cookies('https://app.example.com/'); // 1.65: []

A cookie added with url is host-only, so it doesn't belong to subdomains. Older versions returned it anyway. A Path=/api cookie also no longer matches /apiv2.

To share a cookie with subdomains, use a leading dot:

await context.addCookies([
  { name: 'session', value: '1', domain: '.example.com', path: '/' },
]);

domain: 'example.com' without the dot is still stored as host-only and still returns [] for app.example.com.

1.65: Error: No tests found with --shard

An empty shard still passes when the other shards have tests. A single test with --shard=2/2 exits 0 on 1.65. The change hits a sharded run where nothing matches at all, typically a --grep that filters everything out:

npx playwright test --grep @smoke --shard=1/2
# 1.64: exit 0, no output
# 1.65: Error: No tests found, exit 1

If that's expected in some CI jobs, add the flag:

npx playwright test --grep @smoke --shard=1/2 --pass-with-no-tests

Smaller 1.65 changes that can bite

request.get('/search?page=2', { params: 'q=shoes' }) used to drop page=2 and request /search?q=shoes. Now it appends: /search?page=2&q=shoes. That only applies to string and URLSearchParams params. Object params already appended. Keep the query in one place.

import './styles.css' in a test file now loads as an empty module, so the file has to exist. A missing CSS import used to be ignored. On 1.65 it stops the whole run with Cannot find module './does-not-exist.css'.

test.skip('Not implemented yet') now records the string as the skip description. Before, it was treated as a truthy condition. test.skip(condition, 'reason') still works the same, so no code change is needed.

Playwright no longer tests on Node 20, though engines still says >=20. The Docker images moved to Node 26.

Verify it works

npx playwright --version
npx playwright test --trace off

You should see Version 1.65.0. For a failure not covered above, read the call log under the timeout. In 1.65 it tells you whether the element was inert, hidden, or inside a hidden frame.

Conclusion

Most Playwright 1.64 and 1.65 upgrade failures were tests that passed for the wrong reason: filling inputs behind a modal, asserting on a hidden iframe, or reading text a user couldn't see. Fix those in the test. Add screen: undefined if you depend on the old screen size, and move page-object fields into the constructor.