Most Playwright upgrades ask you to learn a new locator. Playwright 1.65 asks a blunter question: which runtime runs the suite, what noise belongs in the report, and which one test is allowed to own the whole machine for a moment.
This continues the version track after Playwright 1.64. WebMCP and within() stay where they are. Here the useful parts are Bun as a first-class host, quieter failure reports, the built-in -exclusive- lock, and a breaking list that will bite page objects and cookie fixtures before it bites marketing copy.
npm install -D @playwright/test@1.65.0
npx playwright install
Three features worth trying first
Bun: the runner can leave Node
The test runner now runs under Bun. Bun transpiles TypeScript itself, so Playwright skips Babel and its transform cache for that path.
bunx --bun playwright test
The --bun flag matters. Plain bunx playwright test still launches the Node.js runner. Microsoft’s own CI exercises the runner under Bun 1.4.3 on Linux.
I would try Bun when install and cold start dominate local feedback. I would not switch a CI matrix overnight. Measure wall clock and failure shape on one package before and after; do not treat Bun as a free speedup.
Under Bun several things work differently:
- no watch mode — UI mode does not re-run on file changes, and
--only-changeddoes not follow import chains; - Bun loads modules itself —
--tsconfigand JSX options intsconfig.jsonhave no effect, and custom ESM loaders are unsupported; - stack traces drop async frames, and API names lose the class prefix (
clickinstead oflocator.click), which changes step titles and some error text; console.logfrom a test does not land in traces or UI mode.
If your debugging loop depends on UI mode watch or custom loaders, stay on Node for that project and revisit later.
Reports that only care about what broke
Large suites drown useful signal in progress spam. 1.65 adds two ways to cut that.
The new failures reporter prints failures and successful retries as they happen, then a summary. No per-test progress line in between. In a terminal that looks closer to:
1) [chromium] › checkout.spec.ts:42 › pays with saved card
Error: expect(locator).toBeVisible() failed
✓ retry 1) [chromium] › checkout.spec.ts:42 › pays with saved card
1 flaky
0 failed
import { defineConfig } from '@playwright/test';
export default defineConfig({
reporter: [
['failures'],
['html', { onlyFailures: true }],
],
});
onlyFailures on HTML, JSON, and JUnit keeps unexpected outcomes (including unexpected passes), flaky tests, and interrupted tests. Flaky tests stay in the report even when the retry passed. Every attempt of an included test is retained.
That pairing works on a noisy CI job: terminal stays readable with failures, the HTML artifact stays small with onlyFailures: true. Keep a full HTML report on nightlies when you still need the green list.
Keep an unfiltered JUnit report if your CI dashboard relies on complete test counts, pass-rate history, or coverage keyed off every case ID. A filtered JUnit feed will look like the suite shrank overnight.
-exclusive-: one test, zero neighbors
Named locks from 1.63 serialize tests that share a resource. The new built-in lock -exclusive- goes further: the marked test runs only when no other test is running. Everyone else can still parallelize with each other.
test('restart the server', { lock: '-exclusive-' }, async () => {
// No other test runs while this one is running.
});
In default and serial modes, a lock declared on one test can be held for the entire file, not only the marked test — the same scheduling trap as named locks in 1.63. Put globally disruptive cases in dedicated files so ten unrelated specs do not wait behind a restart.
-exclusive- also coordinates inside one Playwright process tree. It does not synchronize independent GitHub Actions jobs, separate npx playwright test invocations, or two shards on different machines. Cross-job isolation still needs a shared mutex outside Playwright, or a dedicated job that owns the disruptive step.
Use it for true global disruption: restart the shared server, wipe a singleton cache, flip an environment-wide feature that other workers would race. Do not use it as a lazy substitute for test-data isolation. Prefer named locks from 1.63 when two tests share a user or sandbox and the rest of the grid can keep working.
Smaller runner notes
test.skip(), test.fixme(), test.fail(), and test.slow() can now accept a description string without a condition. The description is recorded as a test annotation. Conditional forms still work:
test('upload a file', async ({ page }) => {
test.skip('Not implemented yet');
});
test('webkit-only bug', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
'This feature is not supported in WebKit'
);
});
PLAYWRIGHT_TRANSFORM_CACHE_DIR points the transpile cache at an explicit directory — useful on a Linux box shared by several users where the default cache location collides.
cdpSession.send() gains timeout and signal. testRun.mode() returns the suite execution mode inside reporter.preprocess(). Neither is a reason to upgrade alone.
Breaking changes most likely to hit your suite
Grouped by where they land in a real framework.
TypeScript and page objects
Class fields now follow useDefineForClassFields from your tsconfig.json. For ES2022+ targets (and when target is omitted), a field initializer that reads a constructor parameter property fails, because the field runs first:
// Before — breaks under the default ES2022+ field semantics
class LoginPage {
readonly submit = this.page.getByRole('button', { name: 'Submit' });
constructor(private readonly page: Page) {}
}
Prefer moving initialization into the constructor instead of turning the flag off:
import type { Locator, Page } from '@playwright/test';
export class LoginPage {
readonly submit: Locator;
constructor(private readonly page: Page) {
this.submit = page.getByRole('button', { name: 'Submit' });
}
}
Setting "useDefineForClassFields": false is an escape hatch, not the default fix. Grep for = this.page in page objects before you chase “random” TypeErrors after the bump.
Browser interactions
Actions wait for not-inert. click, fill, check, tap, clear, and selectOption now wait until the element is not inert. An element with the inert attribute (or inside one) times out if it stays inert.
A short negative case to keep in the suite:
await page.setContent('<button inert>Save</button>');
await expect(
page.getByRole('button', { name: 'Save' }).click({ timeout: 1_000 })
).rejects.toThrow(/Timeout|inert/i);
Dialogs and route transitions that leave inert shells around will start failing for the right reason.
Aria snapshots drop unrendered text. Text not assigned to a slot, or sitting directly inside a closed <details>, leaves accessible names and ariaSnapshot. getByRole({ name }) and snapshot assertions can change without a visual UI change.
API and network
Cookies follow RFC 6265 more strictly. With a URL, cookies() no longer returns host-only cookies for subdomains, and path matching is tighter:
// Cookie set with Path=/api
// matches /api and /api/orders
// does NOT match /apiv2
// Cookie set with Path=/foo/
// does NOT match /foo
APIRequestContext also changes how nameless cookies are sent and stored. Re-check fixtures that assumed the old path or subdomain leak.
Proxy credentials stay on the proxy. Credentials in the proxy option answer proxy challenges only. Site auth belongs in httpCredentials.
API params append. String and URLSearchParams values on APIRequestContext now append to an existing query string instead of replacing it. Helpers that relied on overwrite need an explicit URL rebuild.
Reporting and execution infrastructure
Empty sharded runs fail. If no tests match across all shards, the run fails with “No tests found.” Pass --pass-with-no-tests when that empty result is intentional. An empty individual shard still passes when other shards have tests.
Style imports must exist. .css, .scss, and .less imports in test files resolve as empty modules instead of being stripped, so the file path must be real.
Blob reporter cleans its output directory again. 1.64 kept other-named reports in that folder. 1.65 deletes the directory contents before writing.
CommonJS test locations. Imported test( call sites in CJS output point at the start of the callee (same as ESM). Stack frames and tooling that keyed off the old column may shift.
Node.js, Docker, and browsers
Playwright no longer tests on Node.js 20. Supported lines are Node.js 22, 24, and 26. Official Docker images move to Node.js 26 (from 24).
That is not a guarantee that every install on Node 20 will refuse to start. It means regressions on 20 are outside the support matrix. Treat 22+ as the upgrade prerequisite, not as optional polish.
Bundled browsers: Chromium 158.0.8127.18, Firefox 159.0, WebKit 27.2 (also checked against Chrome 157 and Edge 156). Expect visual and timing drift on top of the API breaks.
Upgrade checklist
Run this after the dependency bump, before you argue about Bun or reporters.
node -v
npm install -D @playwright/test@1.65.0
npx playwright install
rg "=\s*this\.page" -g "*.ts"
rg "Path=/api|cookies\(" -g "*.ts"
npx playwright test --grep "inert|aria|cookie|shard"
npx playwright test --reporter=failures
node -v should report 22, 24, or 26. The two rg lines catch page-object field initializers and cookie path assumptions. The first Playwright command is a smoke filter; the second is the quieter terminal reporter on a job that already has a full HTML or JUnit artifact elsewhere.
Then verify in order:
- Node and Docker images are on 22+.
- Page objects initialize locators in the constructor.
- Cookie and API helpers still see the paths and domains you expect.
- Inert and aria flows still find the controls you name.
- Shard jobs that can filter to zero tests either keep
--pass-with-no-testsor accept a hard fail. - Reporters:
failures+ filtered HTML for humans; unfiltered JUnit for dashboards that count every test. -exclusive-cases live in their own files if you use default/serial mode.- Bun only after the suite is green on Node — compare wall clock on one package.
Should you upgrade now?
Upgrade when you can spend an afternoon on class fields, cookies, and inert. Skip that afternoon and the “random” failures will look like product bugs.
Bun is optional polish for teams already on Bun for package installs. Do not make Bun the upgrade gate for a Node 20 fleet. The quiet reporters are a quality-of-life win; the exclusive lock is for rare global disruption. The breaking list is why this release matters for a mature automation framework.
Technical details checked against the Playwright 1.65 release notes. External references kept to the official docs.