TL;DR

This article covers what changes for CI when you upgrade to Playwright 1.65: the new failures reporter and onlyFailures option, the built-in exclusive test lock, and running the test runner under Bun. It also covers the breaking changes that can turn a green suite red without a single test changing, such as class fields in page objects, sharded runs that match no tests, blob report directories and inert elements.

  1. Move CI's Node.js version to 22, 24 or 26 in the same pull request as the Playwright bump.
  2. Move locator initializers into page object constructors, and give each blob invocation its own directory.
  3. Add --pass-with-no-tests only where a filter can legitimately match nothing.
  4. Fix whatever still breaks, and only then turn on the failures reporter and onlyFailures.

Bump Playwright to 1.65 on a suite that was green, and it can go red without a single test changing. A page object throws as soon as a test builds it, because a field initializer that reads a constructor parameter property now runs before that property is set. Clicks and fills behind an open drawer now wait for the element to stop being inert, and time out if it stays inert. A filtered, sharded run that matches no tests in any shard fails with "No tests found". A pipeline that writes several blob reports into one directory is left with only the last one to merge.

The fixes are a handful of config and pipeline lines you can change before the bump.

What's new in Playwright 1.65

  • TypeScript class fields now follow useDefineForClassFields from your tsconfig.json, the same as tsc, so a field initializer that reads a constructor parameter property now fails, because the initializer runs first.
  • A sharded run now fails with "No tests found" when no tests match across all shards; pass --pass-with-no-tests to allow this.
  • The blob reporter again deletes the content of its output directory before it writes the report.
  • Actions such as locator.click() now wait for the element to be not inert, and text that the browser does not render is no longer part of accessible names and aria snapshots.
  • Cookie matching now follows RFC 6265, and proxy credentials are no longer sent to websites: the credentials in the proxy option now answer only proxy challenges.
  • String and URLSearchParams values of the params option in APIRequestContext requests are now appended to the query that the URL already has.
  • .css, .less and .scss imports in test files now load as empty modules.
  • Test locations of imported calls in CommonJS output now point to the start of the callee.
  • Playwright no longer tests on Node.js 20 (use Node.js 22, 24 or 26), and Docker images now include Node.js 26 instead of Node.js 24.
  • The test runner now runs under Bun when you pass the --bun flag, because without it bunx playwright test runs on Node.js.
  • A new failures reporter prints failures and successful retries as they occur, followed by a summary, without progress output, and a new onlyFailures option of the HTML, JSON and JUnit reporters keeps only tests with unexpected outcomes, flaky tests and interrupted tests.
  • A new built-in -exclusive- test lock runs a test only when no other tests are running.
  • The full list of changes is in the Playwright 1.65 release notes.

Failure-only reports in CI

Swap your CI reporter for the new failures reporter. Playwright's default reporter is list locally and dot on CI. The new failures reporter prints failures and successful retries as they occur, followed by a summary, without progress output. The reporter docs pair it with a complete HTML report. On a large suite, filter the HTML report and keep the JUnit file whole:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: process.env.CI
    ? [
        ['failures'],
        // onlyFailures is set per reporter, so only the HTML report is filtered
        ['html', { onlyFailures: true }],
        // Full JUnit file: tools that count passes still see every test
        ['junit', { outputFile: 'test-results/junit.xml' }],
      ]
    : 'list',
});

onlyFailures keeps tests with unexpected outcomes (unexpected passes included), flaky tests even when they pass on retry, and interrupted tests, with every attempt of each kept test. A test that passed on its first attempt is not in the report at all. That is the trade: smaller files, and no record in them of tests that passed on their first attempt.

Turn it on for HTML when report artifacts have grown too big to be worth uploading. A smaller file fixes the upload, not the other limits of the HTML reporter on large suites. The PLAYWRIGHT_HTML_ONLY_FAILURES environment variable sets it for the HTML reporter, so you can try it on one CI job without editing the config.

Leave onlyFailures off the JUnit reporter when a CI tool reads pass counts from the JUnit file. With the option on, that tool sees only the tests onlyFailures kept, and on JUnit the option retains every attempt regardless of includeRetries. Set it on JUnit only when nothing downstream counts passes. If you want the passes on record across runs anyway, Currents Test History shows how each test performed across runs and branches, and it treats results with the same spec file, project and title as one test, so renaming a test detaches its history.

Anti-pattern

Merging without the config

You set onlyFailures on the HTML reporter in playwright.config.ts, the shards write blob reports, and a later job merges them. The option is configured per reporter, and the merge step below never reads the config that sets it:

# Anti-pattern: onlyFailures lives in playwright.config.ts, which this merge never reads
npx playwright merge-reports --reporter=html ./blob-report

Fix

Pass the config file to the merge step, which is how onlyFailures applies when you merge blob reports:

npx playwright merge-reports --config=playwright.config.ts ./blob-report

The exclusive test lock

Some tests need the whole environment to themselves: one flips a global setting, another resets data that parallel tests read. The new built-in -exclusive- lock runs a test only when no other tests are running, and all other tests continue to run in parallel with each other.

Use it for exactly those tests, and sparingly. While an exclusive test runs, no other test does. Each one adds about its full duration to the run's wall-clock time. Tests that only clash with each other over one shared account can share a named lock instead: tests sharing a lock name never run at the same time, while all other tests continue to run in parallel. Our 1.64 guide covers keeping shared-account tests apart with a lock.

Anti-pattern

Exclusive as a flake fix

A group of flaky tests races over shared state, so someone marks them all exclusive. The failures go quiet. Each of those tests now stalls every other worker while it runs. The race is still in the code.

Fix

Give each test its own data so it can run in parallel, and keep the lock for tests that change something every other test depends on.

Running tests under Bun

The test runner now runs under Bun, but only with the --bun flag: without it, bunx playwright test runs on Node.js.

Anti-pattern

Calling bunx without the flag

The step calls bunx, so it looks switched over, and every run still goes through Node.js:

# Anti-pattern: without --bun, bunx still starts the test runner on Node.js
bunx playwright test

Fix

Pass --bun so the test runner runs under Bun:

bunx --bun playwright test

What changes under Bun

Bun transpiles your TypeScript test files itself, so Playwright uses neither Babel nor its compilation cache. The new PLAYWRIGHT_TRANSFORM_CACHE_DIR environment variable sets the directory for transpiled test files, for example on a Linux machine shared with other users.

Playwright's own CI runs the test runner tests under Bun 1.4.3 on Linux. If you adopt Bun, pin that version and use Linux runners.

Some features work differently under Bun, and these are the ones a pipeline feels:

  • --tsconfig and the JSX options in tsconfig.json have no effect, because Bun loads modules itself.
  • Custom ESM loaders are not supported.
  • Stack traces do not include async frames.
  • API names drop the class name (click instead of locator.click), which changes some step locations, step titles and error messages.
  • Console output of a test does not get into the trace or UI mode.

Locally, there is no watch mode, and --only-changed does not follow imports.

When to skip it

Skip Bun if you debug CI failures from traces and need the test's console output in them, or if your setup depends on --tsconfig or a custom ESM loader. Also skip it if anything compares step titles or error messages across runs, since API names drop the class name under Bun, which changes some step titles and error messages. Otherwise, try it on one Linux job before moving the rest.

Empty shards and blob reports

The new "No tests found" failure is narrower than it sounds. An empty shard still passes when other shards have tests, and a sharded run fails with "No tests found" when no tests match across all shards.

That happens when a filter matches nothing in the whole suite, such as a nightly --grep for a tag no test carries yet. Add --pass-with-no-tests to jobs whose filter can legitimately match nothing, and to no others. Everywhere else, a zero-test run means a broken filter, and CI should go red. For that nightly job, shard 1 of 3 runs:

# The @experimental tag may be on no test yet, so this job allows an empty match
npx playwright test --grep @experimental --shard=1/3 --pass-with-no-tests

If this change has you rethinking how the suite is split in the first place, we compare fixed shards, workers and orchestration in a separate post.

Blob reports in one directory

The blob reporter again deletes the content of its output directory before it writes the report, where Playwright 1.64 kept reports with other names. That directory is the reporter's outputDir: PLAYWRIGHT_BLOB_OUTPUT_DIR sets it, and it defaults to blob-report.

Blob files are named report-<hash>.zip, or report-<hash>-<shard_number>.zip when sharding, with the hash computed from --grep, --grep-invert, --project, tag and file filters passed on the command line. In 1.64, invocations with different filters left differently named files side by side. In 1.65, each invocation empties the directory first, so only the last report is left to merge.

Anti-pattern

Several runs, one blob directory

One runner runs the suite once per project into the default directory, then merges:

# Anti-pattern: the firefox run empties blob-report/ first, so the chromium report is gone before the merge
npx playwright test --project=chromium --reporter=blob
npx playwright test --project=firefox --reporter=blob
npx playwright merge-reports --reporter=html ./blob-report

Fix

Give each invocation its own PLAYWRIGHT_BLOB_OUTPUT_DIR, then copy the reports into one folder for the merge. --project is one of the hash inputs, so the two files get different names:

PLAYWRIGHT_BLOB_OUTPUT_DIR=blob-chromium npx playwright test --project=chromium --reporter=blob
PLAYWRIGHT_BLOB_OUTPUT_DIR=blob-firefox npx playwright test --project=firefox --reporter=blob
mkdir -p all-blob-reports
cp blob-chromium/*.zip blob-firefox/*.zip all-blob-reports/
npx playwright merge-reports --reporter=html ./all-blob-reports

Page object class fields

The class-field change is the one most likely to break a whole suite at once.

TypeScript class fields now follow useDefineForClassFields from your tsconfig.json, the same as tsc. The setting is enabled for ES2022 and later targets, and when target is not specified, so check your target first.

Anti-pattern

Locators as field initializers

A page object builds its locator from this.page in a field initializer. A field initializer that reads a constructor parameter property now fails, because the initializer runs first:

// Anti-pattern: under useDefineForClassFields, this initializer runs before this.page is assigned
import type { Locator, Page } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator = this.page.getByLabel('Email');

  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }
}

Fix

Move the initialization into the constructor:

import type { Locator, Page } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;

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

  async goto() {
    await this.page.goto('/login');
  }
}

The tsconfig opt-out

The release notes give a second fix: set "useDefineForClassFields": false in tsconfig.json. Prefer the constructor fix. The tsconfig switch changes how every class field compiles only to rescue a few page objects, and it has a trap of its own. One user reported that a decorated class with a static field fails to load in 1.65 with useDefineForClassFields set to false (#43936). The issue was closed as completed on 2026-11-05, with no release named.

Inert elements and accessible names

Expect new timeouts in tests that act on the page while a modal or drawer is open. locator.click(), locator.check(), locator.tap(), locator.fill(), locator.clear() and locator.selectOption() now wait for the element to be not inert. An action on an element with the inert attribute, or inside such an element, now times out if the element stays inert.

Anti-pattern

Raising the timeout

Clicks start timing out after the upgrade, so someone raises the action timeout. If the element stays inert, the action times out at any timeout, and the run only takes longer to fail.

Fix

Close the overlay in the test, or finish the step inside it, before acting on the page behind it.

Accessible names

The same release changes what role locators and aria snapshots see. Text that the browser does not render, such as text that is not assigned to a slot or text directly inside a closed <details>, is no longer part of accessible names and aria snapshots. That can change the results of page.getByRole() with a name, locator.ariaSnapshot() and expect(locator).toMatchAriaSnapshot().

Review those diffs one by one instead of bulk-updating snapshots. A role locator with a name that stopped matching after the upgrade may have matched text the browser does not render.

Other breaking changes to check

The rest are smaller, but each can fail a test far from the feature it changes.

Node.js and browsers

Playwright no longer tests on Node.js 20; use Node.js 22, 24 or 26, as listed in the system requirements. Move CI's Node.js version in the same pull request as the Playwright bump. Docker images now include Node.js 26 instead of Node.js 24, and the latest patch of Chromium on the same branch. The 1.65 browsers are Chromium 158.0.8127.18, Firefox 159.0 and WebKit 27.2.

Cookies and requests

Cookie matching now follows RFC 6265. With a URL, browserContext.cookies() and apiRequestContext.cookies() no longer return host-only cookies for subdomains or Path=/api cookies for /apiv2, and a Path=/foo/ cookie does not match /foo. That breaks a test that passes a subdomain URL to those calls and expects a host-only cookie back, or passes /apiv2 and expects a Path=/api cookie. APIRequestContext sends a cookie without a name as Cookie: x instead of Cookie: =x.

String and URLSearchParams values of the params option are now appended to the query that the URL already has, instead of replacing it. That hits an API test whose URL already holds a query string and also passes params as a string or URLSearchParams: the request now carries both queries, so a test that counted on params to override a value in the URL sends both values.

Check API tests that post string bodies with a JSON content type, too. A regression report against 1.65.0 says that string data that is not valid JSON, sent with an application/json;charset=UTF-8 header, is now JSON-encoded (#43937). In 1.64.1, the report says, only an exact application/json header did that. The issue was closed as completed on 2026-11-06.

Proxy credentials

Proxy credentials are no longer sent to websites: the credentials in the proxy option now answer only proxy challenges.

Anti-pattern

Site login through the proxy

A config puts the site's login in the proxy option, so the site never receives it:

// Anti-pattern: proxy credentials now answer only proxy challenges, so the site never gets them
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    proxy: {
      server: 'http://proxy.example.com:3128',
      username: process.env.SITE_USER,
      password: process.env.SITE_PASSWORD,
    },
  },
});

Fix

To authenticate on a website, use httpCredentials, and keep the proxy credentials for the proxy:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    proxy: {
      server: 'http://proxy.example.com:3128',
      username: process.env.PROXY_USER,
      password: process.env.PROXY_PASSWORD,
    },
    httpCredentials: {
      username: process.env.SITE_USER!,
      password: process.env.SITE_PASSWORD!,
    },
  },
});

CSS imports

.css, .less and .scss imports in test files now load as empty modules instead of being removed, so the imported file must exist. Make sure the CI job checks out or builds every stylesheet a test file imports.

Upgrade order

Do the bump on a branch with full reports, so you can compare test counts and passes against your last 1.64 run, not just the failures. On that branch, move CI's Node.js version, move locator initializers into page object constructors, give each blob invocation its own directory, and add --pass-with-no-tests only where a filter can legitimately match nothing. Fix whatever still breaks there. Only then turn on the failures reporter and onlyFailures: a report without passed tests can't tell you whether the upgrade lost any.

Frequently asked questions

What are the breaking changes in Playwright 1.65?

TypeScript class fields now follow useDefineForClassFields from your tsconfig.json, and a sharded run now fails with "No tests found" when no tests match across all shards. The blob reporter again deletes the content of its output directory before it writes the report, and actions such as locator.click() now wait for the element to be not inert.

Why does my page object fail after upgrading to Playwright 1.65?

A field initializer that reads a constructor parameter property now fails, because the initializer runs first. TypeScript class fields now follow useDefineForClassFields from your tsconfig.json, and the setting is enabled for ES2022 and later targets, and when target is not specified. Move the initialization into the constructor, as Page object class fields shows.

Why does my sharded Playwright run fail with No tests found?

A sharded run fails with "No tests found" when no tests match across all shards, while an empty shard still passes when other shards have tests. Add --pass-with-no-tests to jobs whose filter can legitimately match nothing, and to no others, as Empty shards and blob reports explains.

How do I run Playwright tests with Bun?

Run bunx --bun playwright test. Without the --bun flag, bunx playwright test runs on Node.js, as Running tests under Bun shows.

What does the Playwright failures reporter do?

The new failures reporter prints failures and successful retries as they occur, followed by a summary, without progress output. The reporter docs pair it with a complete HTML report, as Failure-only reports in CI shows.

What does onlyFailures keep in a Playwright report?

onlyFailures keeps tests with unexpected outcomes (unexpected passes included), flaky tests even when they pass on retry, and interrupted tests, with every attempt of each kept test. A test that passed on its first attempt is not in the report at all. Leave onlyFailures off the JUnit reporter when a CI tool reads pass counts from the JUnit file, as Failure-only reports in CI explains.

What is the Playwright exclusive test lock?

The new built-in -exclusive- lock runs a test only when no other tests are running, and all other tests continue to run in parallel with each other. Use it for tests that need the whole environment to themselves, and sparingly, since no other test runs while an exclusive test runs, as The exclusive test lock explains.

Why do clicks time out after upgrading to Playwright 1.65?

locator.click(), locator.fill(), locator.check() and similar actions now wait for the element to be not inert, so an action on an element with the inert attribute, or inside such an element, now times out if the element stays inert. Close the overlay in the test, or finish the step inside it, before acting on the page behind it, as Inert elements and accessible names shows.