2026-10-21 06:01:44 UTC
A passkey lets a user sign in without a password. The browser does this through WebAuthn. Before v1.61, a passkey test needed a real security key, or a virtual one that works only in Chromium. Because of that, many test suites did not test the passkey step at all.
v1.61 adds browserContext.credentials. It is a virtual authenticator that works in Chromium, Firefox, and WebKit. Your test creates a passkey, and then the page can sign in with it.
You must call install(). If you call only create(), Playwright keeps the passkey, but the page cannot see it. Call install() before the page uses navigator.credentials for the first time.
This test creates a passkey. Then the page asks for a passkey, and the test checks that the page gets the correct one. The test does not do a full sign-in: no server verifies the response, and no login session starts.
import { test, expect } from "@playwright/test";
test("seed a passkey and check the WebAuthn response", async ({ browser }) => {
const context = await browser.newContext();
// Create a passkey for this website and make it available to the page.
const credential = await context.credentials.create("demo.playwright.dev");
await context.credentials.install();
const page = await context.newPage();
await page.goto("https://demo.playwright.dev/todomvc/");
// The page asks for a passkey. The virtual authenticator answers.
const id = await page.evaluate(async ({ rpId }) => {
const challenge = crypto.getRandomValues(new Uint8Array(32));
const cred = (await navigator.credentials.get({
publicKey: { challenge, rpId, userVerification: "preferred" },
})) as PublicKeyCredential;
return cred.id;
}, { rpId: "demo.playwright.dev" });
expect(id).toBe(credential.id);
// delete() is not in the release notes, but you can use it.
await context.credentials.delete(credential.id);
expect(await context.credentials.get()).toHaveLength(0);
await context.close();
});
The first argument of create() is the relying party ID. In most cases, that is the domain of your website. If you pass only this value, Playwright generates the key pair, the credential ID, and the user handle. To import a key that you already have, pass all four values: id, userHandle, privateKey, and publicKey. All values are base64url strings. The private key uses the PKCS#8 DER format and the public key uses the SPKI DER format.
There is a second way to work: capture a passkey one time, then use it again. Call install() and let the application register a passkey. Read the passkey with credentials.get({ rpId }), and give that object to create() in later tests. Each credential that get() returns includes its private key.
Note: The virtual authenticator acts as an authenticator that is built into the device, such as Touch ID or Windows Hello. You cannot save passkeys into the storage state in v1.61.0. That option arrives in v1.62.0.
Many applications keep data, such as a login token, in localStorage or sessionStorage. Before v1.61, tests usually called page.evaluate() to read or change that data directly. Now you can use page.localStorage and page.sessionStorage directly. They work on the storage of the current origin.
The release notes show setItem(), getItem(), and items(). The same version also has removeItem() and clear().
import { test, expect } from "@playwright/test";
test("round-trip localStorage", async ({ page }) => {
await page.goto("https://demo.playwright.dev/todomvc/");
await page.localStorage.setItem("token", "abc");
expect(await page.localStorage.getItem("token")).toBe("abc");
expect(await page.localStorage.items()).toContainEqual({ name: "token", value: "abc" });
await page.localStorage.removeItem("token"); // No error if the key is not there.
expect(await page.localStorage.getItem("token")).toBeNull();
await page.sessionStorage.setItem("draft", "1");
await page.sessionStorage.clear();
expect(await page.sessionStorage.items()).toEqual([]);
});
items() returns a list of { name, value } objects.
APIResponse.securityDetails() and APIResponse.serverAddr() are new. The first one returns details of the HTTPS certificate. The second one returns the IP address and the port of the server. The browser Response already had these two methods.
browserType.connectOverCDP() has a new artifactsDir option. It sets the folder for traces and downloads when you connect to a browser that is already open.
page.screencast.showActions() has a new cursor option. By default, the recording shows an animated mouse pointer. Set cursor: "none" to hide it.
The onFrame callback of page.screencast.start() now gets a timestamp. It tells you when the browser showed the frame.
The video option now has the same modes as trace. The new values are 'on-all-retries', 'retain-on-first-failure', and 'retain-on-failure-and-retries'. The video modes table in the docs shows what each mode records and keeps.
-G is the short form of --grep-invert. For example, npx playwright test -G "@slow" runs only the tests that do not match @slow.
If a test fails with many errors at one time (an AggregateError), TestInfo.errors now lists each error as a separate entry.
FullConfig.failOnFlakyTests tells a custom reporter if the failOnFlakyTests option is on. With that, the reporter can explain why a run with flaky tests failed.
You can now use expect.poll() together with soft assertions. expect.poll() calls your function again and again until the result is correct. A soft assertion does not stop the test when it fails.
await expect.soft.poll(async () => {
const response = await page.request.get("https://demo.playwright.dev/todomvc/");
return response.status();
}).toBe(200);
You can also pass your own arguments to a test run. Put them after -- on the command line. Playwright does not use the arguments after --, so they are free for you.
npx playwright test -- --env=staging
The new FullConfig.argv property gives you the full command line as a list of strings. It is a copy of process.argv from Node.js. You can read it in a test, in a global setup file, or in a reporter:
import { test } from "@playwright/test";
test("open the correct site", async ({ page }, testInfo) => {
const isStaging = testInfo.config.argv.includes("--env=staging");
await page.goto(isStaging ? "https://staging.example.com" : "https://example.com");
});
testIdAttribute accepts a list of names with commas between them. Then getByTestId() matches each of those attributes.
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: "data-pw,data-ti",
},
});
A project can set a timeout only for screenshot assertions. Use expect.toHaveScreenshot.timeout in the project config.
There are new device descriptors: the iPhone 16 and iPhone 17 families, iPhone Air, more Pixel 6 to Pixel 10 models, and Galaxy Z Fold 6 and 7 and Galaxy Z Flip 6 and 7.
Aria snapshots now include aria-invalid. An invalid field shows as [invalid]. The values grammar and spelling show as [invalid=grammar] and [invalid=spelling].
--last-failed-file <file> sets the location of the file that --last-failed uses. The default is .last-run.json in the output folder.
The list reporter has a printFailuresInline option. It prints the details of a failure immediately after the failed test, and not at the end of the run.
npx playwright install --no-progress hides the download progress bars. This keeps the logs of your CI short.
The environment variable NO_COLOR turns off colors in the list, line, and dot reporters.
HAR files and traces now record WebSocket messages. The release notes mention this, but they do not tell you how to turn it off. Set PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES=1 or PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES=1.
UI Mode has a "Stop on first failure" checkbox. Its status bar now shows the number of passed, failed, and skipped tests.
The official notes for v1.61 have no "Breaking changes" section. We found two things that you must check:
Firefox now applies isMobile. Before v1.61, a Firefox project with isMobile: true stopped with the error options.isMobile is not supported in Firefox. Now it runs with mobile emulation, so the results of those tests can change.
The docs now list Node.js 22, 24, and 26. Node.js 18 and 20 are not in the list, but they still work in v1.61. This changes in v1.62.
v1.61 also adds support for Ubuntu 26.04. This version uses Chromium 149, Firefox 151, and WebKit 26.5.
v1.61.1 fixes some bugs that v1.61.0 added. If you upgrade to v1.61, use v1.61.1 or later.Playwright v1.62.0
A component test checks one part of the user interface, for example a button, without the full application. Until now, Playwright did this with the experimental @playwright/experimental-ct-* packages. v1.62 replaces them with a new model that has three parts:
A story shows your component in one situation, with fixed props and mock data.
A gallery is a page that you serve. It shows a story on request.
The new mount fixture opens the gallery, mounts a story by its ID, and returns a locator for the story.
test("click should expand", async ({ mount }) => {
const component = await mount("components/Expandable/Stateful");
await component.getByRole("button").click();
await expect(component.getByTestId("expanded")).toHaveValue("true");
});
The locator has two more methods. update(props) gives new props to the story. It does not mount the component again, so the component keeps its state. unmount() removes the story. mount() opens the page at baseURL, so you must set baseURL in your config.
To check the types of the props, pass the story type as a template argument:
import type { WithTitle } from "./Button.story";
test("renders the title", async ({ mount }) => {
const component = await mount<typeof WithTitle>("Button/WithTitle", { title: "Hello" });
await expect(component).toContainText("Hello");
});
An AbortSignal is the standard JavaScript way to cancel work that is in progress. From v1.62, most actions, waits, assertions, and API requests accept a signal option. When the signal aborts, the operation stops and throws an error.
A signal does not turn off the default timeout. Pass timeout: 0 to do that.
In this test, the click and the assertion finish before the signal aborts, so the test passes:
import { test, expect } from "@playwright/test";
test("pass a signal into an action and an assertion", async ({ page }) => {
await page.setContent(`<button>Submit</button><p>Done</p>`);
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await page.getByRole("button", { name: "Submit" }).click({ signal: controller.signal });
await expect(page.getByText("Done")).toBeVisible({ signal: controller.signal });
});
If the signal aborts first, the call throws. In the next example, the text "Done" never appears and the timeout is off. The assertion fails when the signal aborts after 200 milliseconds:
const controller = new AbortController();
setTimeout(() => controller.abort(), 200);
await expect(page.getByText("Done")).toBeVisible({ signal: controller.signal, timeout: 0 });
WebP is an image format that usually gives smaller files than PNG. page.screenshot() and locator.screenshot() now accept type: "webp". Playwright also selects WebP when the path ends in .webp. The default quality is 100, which keeps the full image quality. A value from 0 to 99 gives a smaller file with some loss of quality.
toHaveScreenshot() can also store its reference images as WebP. Give the snapshot a name that ends in .webp. These images always keep the full quality.
import { test, expect } from "@playwright/test";
test("webp screenshot", async ({ page }) => {
await page.goto("https://demo.playwright.dev/todomvc/");
// Visual comparison: the reference image is a WebP file with full quality.
await expect(page).toHaveScreenshot("homepage.webp");
// Normal screenshot: quality 50 gives a smaller file.
await page.screenshot({ path: "homepage.webp", quality: 50 });
});
A reporter is the part of Playwright that shows your test results, for example the list in the terminal or the HTML report. You can also write your own reporter.
From v1.62, a reporter can change the test run before it starts. It does this in the new preprocess() method. For example, it can skip the tests that do not apply to the current environment.
class MyReporter {
async preprocess({ suite, testRun }) {
for (const test of suite.allTests()) {
if (shouldSkip(test))
testRun.skip(test, "not in this environment");
if (shouldDrop(test))
testRun.exclude(test); // The test does not appear in the report.
}
// You can also use testRun.fail(test) and testRun.fixme(test).
}
}
exclude() removes a test from the report. A test that you mark with skip() or fixme() stays in the report as skipped. The TestRun docs describe each method.
When a test fails, Playwright can run it again. By default, the retry starts as soon as a worker is free. A worker is a process that runs tests. At that time, the other workers still run other tests, and those tests can disturb the retry.
The new retryStrategy option changes this. With 'isolated', all retries run at the end, one at a time, in one worker.
// playwright.config.ts
export default defineConfig({
retries: 2,
retryStrategy: "isolated",
});
The option applies to the full test run. You cannot set it for one project only.
storageState({ credentials: true }) saves the virtual passkeys of the context. When you load that state into a new context, the passkeys are ready to use. Keep this file private, because it contains the private keys.
Actions such as click(), hover(), and check() have a new scroll option. By default, Playwright scrolls the element into view before the action. With scroll: "none", it does not scroll, and the action fails if the element is not in the viewport.
APIResponse.timing() returns the timing of an API request, for example how long the connection and the response took.
The HTML reporter has a mergeFiles option. It groups tests by the title of the top-level test.describe(), and not by file. Before, you could change this only in the report page.
The playwright package now includes the Playwright MCP server and playwright-cli. You start them with npx playwright mcp and npx playwright cli.
locator.waitForFunction() waits until your function returns a true value for the element:
const toggle = page.getByRole("button", { name: "Menu" });
await toggle.click();
await toggle.waitForFunction(element => element.hasAttribute("aria-expanded"));
You can now put functions inside the argument of page.evaluate() and addInitScript(). The function does not move to the page. It stays in your test and runs in Node.js. When the page calls it, the page gets a Promise with the result. You must add { exposeFunctions: true }. Without it, evaluate() throws an error, and addInitScript() drops the functions without an error.
const greeting = await page.evaluate(
({ greet }) => greet("world"),
{ greet: (name: string) => `hello ${name}` },
{ exposeFunctions: true },
);
reuseContext is a new experimental test option. With true, all tests in a worker share one browser context. That makes the tests faster, but one test can then affect the next one. The option is made for component tests. The docs tell you not to use it for end-to-end tests.
WebKit now accepts the camera and microphone permissions in grantPermissions().
networkidle does not wait for open server-sent events connections any more. In v1.61, a page with such a connection could wait until the timeout.
There is a new Docker image for Ubuntu 26.04.
Node.js 20 is now the minimum. On Node.js 18, Playwright stops with the message Playwright requires Node.js 20 or higher. The release notes do not mention this change.
Debian 11 is not supported any more.
In headless WebKit on macOS, navigator.clipboard does not read or change the clipboard of your operating system any more. Each headless browser now has its own clipboard. A test that used the system clipboard to move data into or out of the browser can fail. Headed mode did not change.
If a custom reporter throws an error, Playwright now shows the error and the run fails.
If the extends or references field in your tsconfig.json points to a file that Playwright cannot find, the run now stops with the error Failed to resolve "extends" path. Before, Playwright ignored the problem. v1.62.0 also rejected some valid setups by mistake. Use v1.62.1 or later.
This version uses Chromium 151, Firefox 153, and WebKit 26.5.
v1.62.1 fixes some bugs that v1.62.0 added, including the tsconfig.json problems. If you upgrade to v1.62, use v1.62.1 or later.
Some tests use something that only one test can use at a time. Examples are an external service or a global account setting. Until now, the usual solution was serial mode or one worker, and that made the full suite slower. In v1.63, you give those tests a named lock.
Tests with the same lock name never run at the same time. This is true across files, workers, and projects. All other tests continue to run in parallel. A test can hold more than one lock, and test.describe() accepts a lock for a full group.
import { test, expect } from "@playwright/test";
test("update user settings", { lock: "user-settings" }, async ({ page }) => {
// This test never runs at the same time as other tests with "user-settings".
});
test("reset the database", { lock: ["database", "external-api"] }, async () => {
// This test runs only when the two locks are free.
});
test.describe("billing", { lock: "user-settings" }, () => {
test("change plan", async ({ page }) => { /* ... */ });
test("cancel", async ({ page }) => { /* ... */ });
});
Note: In the default mode and in serial mode, all tests of a file run together in order. Thus a lock on one test stays held for the full file.
An iframe is a page inside a page. To click something in an iframe, you first had to find the iframe with a selector. Now you can call page.frameLocator() without a selector. Playwright then searches in all frames of the page. frame.frameLocator() also works without a selector. It searches in that frame and in its child frames.
import { test, expect } from "@playwright/test";
test("click a button in the iframe that has it", async ({ page }) => {
await page.setContent(`
<iframe srcdoc="<button>Save</button>"></iframe>
<button>Not in a frame</button>
`);
await page.frameLocator().getByRole("button", { name: "Save" }).click();
});
The element must be in one frame only. If elements in two or more frames match, Playwright throws an error. You also cannot use first(), last(), or nth() to select a frame when there is no selector.
locator.visible() returns a locator that matches only visible elements. Playwright checks the visibility when you use the locator, and not when you create it. The team recommends this method in place of the :visible CSS pseudo-class. To match only hidden elements, use locator.filter({ visible: false }).
import { test, expect } from "@playwright/test";
test("click the visible button", async ({ page }) => {
await page.setContent(`
<button style="display: none">Invisible</button>
<button>Visible</button>
`);
await page.locator("button").visible().click();
await expect(page.locator("button").visible()).toHaveText("Visible");
});
test.step() accepts two new options: subtitle and params. The subtitle is a short text that appears next to the title of the step. The params are extra data about the step.
import { test, expect } from "@playwright/test";
test("add a todo with a subtitle and params", async ({ page }) => {
await page.goto("https://demo.playwright.dev/todomvc/");
await test.step("Add a todo", async () => {
await page.getByPlaceholder("What needs to be done?").fill("Write blog");
await page.getByPlaceholder("What needs to be done?").press("Enter");
}, { subtitle: "as a demo user", params: { user: "demo" } });
});
Playwright also adds a subtitle to its own steps. For example, a Click step has the subtitle getByRole('button').
Note: The release notes say that the HTML report shows the subtitle and the params. In v1.63.0, the HTML report shows only the subtitle. You can see the params in the trace viewer.
A trace is a recording of a test run that you can open after the test. An aria snapshot is a text view of the page, as a screen reader sees it.
In v1.63, a trace can also keep aria snapshots and screenshots around each action. You select them in the snapshots option of trace or tracing.start():
// playwright.config.ts
export default defineConfig({
use: {
trace: {
mode: "on",
snapshots: { dom: true, aria: true, screen: true },
},
},
});
dom records the DOM snapshot and the network activity.
aria records aria snapshots of the page around each action.
screen records screenshots around each action. For a click, that is one before the click, one at the click, and one after it.
With aria and screen on, the trace viewer has a new Display Aria mode. It shows the screenshot next to the aria snapshot. When you hover over a line of the aria snapshot, the viewer highlights that element on the screenshot.
If you set snapshots: true, Playwright records only dom. To get the other two, you must use the object.
httpCredentials accepts an array. Thus you can give a different user name and password for each website. Playwright uses the first entry whose origin matches the request.
// playwright.config.ts
export default defineConfig({
use: {
httpCredentials: [
{ username: "user", password: "pass", origin: "https://admin.example.com" },
{ username: "user2", password: "pass2" }, // No origin: matches all requests.
],
},
});
browserContext.setHTTPCredentials() is not marked as deprecated any more.
storageState({ opfs: true }) also saves the origin private file system. That is a private file storage that a website can use in the browser. This option does not work in WebKit contexts that are not persistent. That includes the contexts from browser.newContext().
The new dialogclosed event fires when a JavaScript dialog, such as an alert, is accepted or dismissed. The same event is also on browserContext.
locator.ariaSnapshotJSON() and page.ariaSnapshotJSON() return the aria snapshot as JSON, and not as YAML text. They accept the mode, depth, and boxes options.
reducedMotion, forcedColors, and contrast are now test options of their own. Before, you set them through contextOptions.
The list, line, dot, github, and junit reporters have an omitTags option. It hides the tags that Playwright adds to test titles.
The new perfetto reporter writes a file that you can open in the Perfetto UI or in chrome://tracing. It shows the test run as a timeline, with one row for each worker.
The HTML report shows a duration waterfall next to the test steps. You can see quickly which steps take the most time.
Reporters get the new testStep.subtitle and testStep.params values.
Story IDs that you give to mount() can now have types, from the generated Stories registry.
npx playwright install --no-remove keeps the browsers of other Playwright installations on your machine.
npx playwright codegen --http-credentials="username:password" can record pages behind HTTP authentication. Be careful with the output, because codegen writes the user name and the password into the generated code.
The request methods accept a type argument. With it, TypeScript knows the type of the response data. Playwright does not check the data.
const response = await request.get<User>("/api/users/42");
const user = await response.json(); // TypeScript sees this as User.
--add-reporter adds a reporter to the reporters in your config. --reporter replaces them.
npx playwright test --add-reporter=github,./my-reporter.ts
TestStepInfo.annotations is new. With it, you can add an annotation to one step: stepInfo.annotations.push({ type, description }).
--last-failed now also runs tests that did not run in the last run, or that were skipped without a skip mark. For example, a failed beforeAll skips the tests that follow it.
Traces now store the annotations of the test. Thus the Annotations tab of the trace viewer shows them also outside UI Mode.
Ubuntu 20.04 is not supported any more.
The experimental component testing packages (@playwright/experimental-ct-react, -react17, and -vue) are removed. The release notes say only that they get no more updates. But the component testing guide says that they are not published any more. Stay on v1.62 until you move your tests to stories.
AndroidInput.swipe() now starts the swipe at the from point. Before, Playwright ignored from. An existing swipe can now move along a different path.
The firefox-beta and chromium-tip-of-tree browser builds are removed. npx playwright install cannot download them any more. The release notes do not mention this.
This version uses Chromium 153, Firefox 155, and WebKit 26.6. On Linux arm64, Playwright now downloads Chrome for Testing. That is the same build as on all other platforms.
Use Node.js 20 or later.
If you test on WebKit on macOS, check tests that use the clipboard.
Make sure that the extends and references paths in tsconfig.json are correct. Use v1.62.1 or later.
Move off the @playwright/experimental-ct-* packages before you go to v1.63.
Do not use Debian 11 or Ubuntu 20.04 on your CI machines.
Check Firefox projects that set isMobile: true.
While we wrote this post, we compared the docs and the release notes with the code. In some places, they did not agree. The first one that we found was the isMobile note for Firefox. We reported it to the Playwright team as #42835, and the team fixed it on the same day. After that, we reported eight more.
What the docs or the release notes said |
What is correct |
Issue |
Result |
|---|---|---|---|
|
Firefox supports it from v1.61 |
Fixed in v1.64.0 |
|
|
It was added in v1.62 |
Fixed on |
|
The |
They were added in v1.63 |
Closed without a fix |
|
|
It was added in v1.63 |
Closed without a fix |
|
The HTML report shows the |
It shows only the subtitle |
Closed without a fix |
|
A reporter can mark tests as "fixed" |
The method is |
Closed without a fix |
|
Nothing about how to turn off WebSocket recording |
Two environment variables do this |
Closed without a fix |
|
Nothing about Node.js 20 in the v1.62 notes |
v1.62 needs Node.js 20 or later |
Closed without a fix |
|
Nothing about removed browser builds in the v1.63 notes |
|
Closed without a fix |