From 3d16ef2e20bedc6f039b052be44fdbc5485678f8 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 8 Jun 2026 13:45:43 +0200 Subject: [PATCH 1/7] docs(python): drop Conda distribution (#41188) --- docs/src/intro-python.md | 24 ------------------------ docs/src/library-python.md | 13 ------------- 2 files changed, 37 deletions(-) diff --git a/docs/src/intro-python.md b/docs/src/intro-python.md index 8edb444eadd3e..dc4c7b9885b60 100644 --- a/docs/src/intro-python.md +++ b/docs/src/intro-python.md @@ -21,36 +21,12 @@ Playwright recommends using the official [Playwright Pytest plugin](./test-runne Get started by installing Playwright and running the example test to see it in action. - - - Install the [Pytest plugin](https://pypi.org/project/pytest-playwright/): ```bash pip install pytest-playwright ``` - - - -Install the [Pytest plugin](https://anaconda.org/Microsoft/pytest-playwright): - -```bash -conda config --add channels conda-forge -conda config --add channels microsoft -conda install pytest-playwright -``` - - - - Install the required browsers: ```bash diff --git a/docs/src/library-python.md b/docs/src/library-python.md index a3702962b0561..bd78b01576f39 100644 --- a/docs/src/library-python.md +++ b/docs/src/library-python.md @@ -5,8 +5,6 @@ title: "Getting started - Library" ## Installation -### Pip - [PyPI version](https://pypi.python.org/pypi/playwright/) ```bash @@ -15,17 +13,6 @@ pip install playwright playwright install ``` -### Conda - -[Anaconda version](https://anaconda.org/Microsoft/playwright) - -```bash -conda config --add channels conda-forge -conda config --add channels microsoft -conda install playwright -playwright install -``` - These commands download the Playwright package and install browser binaries for Chromium, Firefox and WebKit. To modify this behavior see [installation parameters](./browsers.md#install-browsers). ## Usage From 92243ef9985af87cb2cf677813fb7481cbbd2fa1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 8 Jun 2026 13:47:42 +0200 Subject: [PATCH 2/7] chore(mcp): proper cleanup for signalToPromise (#41160) --- packages/isomorphic/manualPromise.ts | 12 +++++++----- packages/playwright/src/mcp/test/testContext.ts | 7 +++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/isomorphic/manualPromise.ts b/packages/isomorphic/manualPromise.ts index 87858e796e001..bb16ada8e217d 100644 --- a/packages/isomorphic/manualPromise.ts +++ b/packages/isomorphic/manualPromise.ts @@ -114,13 +114,15 @@ export class LongStandingScope { } export function signalToPromise(signal: AbortSignal): { promise: Promise, dispose: () => void } { + if (signal.aborted) + return { promise: Promise.resolve(), dispose: () => {} }; + let dispose: (() => void) | undefined; const promise = new Promise(resolve => { - if (signal.aborted) - resolve(); - else - signal.addEventListener('abort', () => resolve(), { once: true }); + const onAbort = () => resolve(); + signal.addEventListener('abort', onAbort, { once: true }); + dispose = () => signal.removeEventListener('abort', onAbort); }); - return { promise, dispose: () => {} }; + return { promise, dispose: dispose! }; } function cloneError(error: Error, frames: string[]) { diff --git a/packages/playwright/src/mcp/test/testContext.ts b/packages/playwright/src/mcp/test/testContext.ts index 3c53a966676a2..f073bb752e649 100644 --- a/packages/playwright/src/mcp/test/testContext.ts +++ b/packages/playwright/src/mcp/test/testContext.ts @@ -233,8 +233,9 @@ export class TestContext { } }; - const abortPromise = signal - ? signalToPromise(signal).promise.then(() => 'interrupted' as const) + const abort = signal ? signalToPromise(signal) : undefined; + const abortPromise = abort + ? abort.promise.then(() => 'interrupted' as const) : new Promise(() => {}); try { @@ -263,6 +264,8 @@ export class TestContext { testRunnerAndScreen.output.push(String(e)); await cleanup(); return { output: testRunnerAndScreen.output.join('\n'), status }; + } finally { + abort?.dispose(); } await cleanup(); From 41b00f189d70be2ddfd5f38170f080ad30564584 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 8 Jun 2026 15:46:38 +0100 Subject: [PATCH 3/7] docs(credentials): clarify scenarios, defaults and setUserVerified (#41192) --- docs/src/api/class-credentials.md | 131 +++++++++++-- docs/src/auth.md | 69 +++++++ packages/playwright-client/types/types.d.ts | 117 +++++++++-- packages/playwright-core/types/types.d.ts | 117 +++++++++-- tests/library/browsercontext-webauthn.spec.ts | 184 +++++++++--------- 5 files changed, 474 insertions(+), 144 deletions(-) diff --git a/docs/src/api/class-credentials.md b/docs/src/api/class-credentials.md index 59af7a54d358f..ff869db9cd8b1 100644 --- a/docs/src/api/class-credentials.md +++ b/docs/src/api/class-credentials.md @@ -1,23 +1,79 @@ # class: Credentials * since: v1.61 -`Credentials` provides a virtual WebAuthn authenticator scoped to a [BrowserContext]. It lets tests -seed credentials, intercept `navigator.credentials.create()` / `navigator.credentials.get()` calls -in pages, and complete WebAuthn ceremonies without a real authenticator. +`Credentials` is a virtual WebAuthn authenticator scoped to a [BrowserContext]. It lets tests +register passkeys and answer `navigator.credentials.create()` / `navigator.credentials.get()` +ceremonies in the page, without a real authenticator or hardware security key. -Implemented in userland via an injected script, so it works across Chromium, Firefox and WebKit. +There are two common ways to use it: -**Usage** +- **Seed a known credential.** The passkey already exists — for example, your backend provisioned + it for a test user. Import it with [`method: Credentials.create`] so the app under test can sign + in right away. See the first example below. +- **Capture a credential, then reuse it.** Let the app register a passkey once in a setup test, + read it back with [`method: Credentials.get`], and seed it into later tests — the same way + [`method: BrowserContext.storageState`] reuses signed-in state. See the second example below. + +**Usage: seed a known credential** ```js const context = await browser.newContext(); + +// A passkey your backend already provisioned for a test user. +await context.credentials.create({ + rpId: 'example.com', + id: knownCredentialId, // base64url + userHandle: knownUserHandle, // base64url + privateKey: knownPrivateKey, // base64url PKCS#8 (DER) + publicKey: knownPublicKey, // base64url SPKI (DER) +}); await context.credentials.install(); -await context.credentials.create({ rpId: 'example.com' }); + const page = await context.newPage(); await page.goto('https://example.com/login'); -// Page's navigator.credentials.get() will be answered using the seeded credential. +// The page's navigator.credentials.get() is answered with the seeded passkey. ``` +**Usage: capture a passkey, then reuse it** + +```js +// setup test: let the app register a passkey, then save it. +const context = await browser.newContext(); +await context.credentials.install(); + +const page = await context.newPage(); +await page.goto('https://example.com/register'); +await page.getByRole('button', { name: 'Create a passkey' }).click(); + +// Read back the passkey the page registered — it includes the private key. +const [credential] = await context.credentials.get({ rpId: 'example.com' }); +fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); +``` + +```js +// later test: seed the captured passkey so the app starts already enrolled. +const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); +const context = await browser.newContext(); +await context.credentials.create(credential); +await context.credentials.install(); + +const page = await context.newPage(); +await page.goto('https://example.com/login'); +// navigator.credentials.get() resolves the captured passkey — already signed in. +``` + +**Defaults** + +- The authenticator presents itself as a **platform** authenticator (`authenticatorAttachment` is + `'platform'`), and `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` resolves + to `true` in the page. +- Seeded credentials are **discoverable** (resident), so both username-then-passkey and + usernameless passkey flows resolve them. +- Fresh keys are ECDSA P-256 (COSE algorithm `-7`). An omitted `id` or `userHandle` is + filled with 16 random bytes. +- User verification is **on** by default — every assertion and attestation reports the user as + verified. Toggle this with [`method: Credentials.setUserVerified`]. + ## async method: Credentials.install * since: v1.61 @@ -27,7 +83,7 @@ and future pages. Call this before the page first touches `navigator.credentials Required: until `install()` is called, no interception is in place and the page sees the platform's native (or absent) WebAuthn behaviour. Seeding credentials with -[`method: Credentials.create`] without `install()` populates the registry but the +[`method: Credentials.create`] without `install()` populates the authenticator, but the page will never see those credentials. ## async method: Credentials.create @@ -40,11 +96,17 @@ page will never see those credentials. - `privateKey` <[string]> Base64url-encoded PKCS#8 (DER) private key. - `publicKey` <[string]> Base64url-encoded SPKI (DER) public key. -Seeds a virtual WebAuthn credential. With only `rpId`, generates a fresh ECDSA P-256 keypair, -credential id and user handle. To import a pre-registered credential (e.g. authenticating as an -existing test user the server already knows about), supply all four of `id`, `userHandle`, -`privateKey` and `publicKey` together. Call [`method: Credentials.install`] before navigating to a -page that uses WebAuthn. +Seeds a virtual WebAuthn credential and returns it. + +With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The +seeded credential is discoverable (resident), so the page can resolve it from both +username-then-passkey and usernameless passkey flows. The returned object carries the `privateKey` and `publicKey`, so +it can be persisted to disk and re-seeded in a later test. + +To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and +`publicKey` together. + +Call [`method: Credentials.install`] before navigating to a page that uses WebAuthn. ### param: Credentials.create.options * since: v1.61 @@ -58,7 +120,9 @@ page that uses WebAuthn. ## async method: Credentials.delete * since: v1.61 -Removes a previously seeded credential. +Removes a credential from the authenticator by its id. Works for any credential currently held — +both those seeded with [`method: Credentials.create`] and those the page registered itself by +calling `navigator.credentials.create()`. ### param: Credentials.delete.id * since: v1.61 @@ -76,7 +140,12 @@ Base64url-encoded credential id. - `privateKey` <[string]> - `publicKey` <[string]> -Returns seeded credentials, optionally filtered by `rpId` or `id`. +Returns every credential currently held by the authenticator, optionally filtered by `rpId` or +`id`. This includes both credentials seeded with [`method: Credentials.create`] and credentials +the page registered itself by calling `navigator.credentials.create()`. + +Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just +registered can be saved and re-seeded into a later test with [`method: Credentials.create`] — see the second example in the class overview. ### option: Credentials.get.rpId * since: v1.61 @@ -93,11 +162,37 @@ Only return the credential with this base64url-encoded id. ## async method: Credentials.setUserVerified * since: v1.61 -Toggles whether the virtual authenticator auto-approves user-verification prompts. Useful for -simulating a user denying biometric verification. +Controls whether the virtual authenticator reports the user as **verified**. This is a +context-wide setting (default `true`) that toggles the user-verified (UV) flag in the +`authenticatorData` of every subsequent `navigator.credentials.create()` and +`navigator.credentials.get()` ceremony. + +When set to `false`, ceremonies still **succeed**, but the resulting assertion/attestation reports +that user verification was *not* performed — the user-present (UP) flag stays set. It does **not** +simulate a cancelled or denied prompt, and it does not reject the call. Use it to test how your +relying party or app handles an assertion that lacks user verification, for example requiring +step-up authentication. + +**Usage** + +```js +await context.credentials.install(); +await context.credentials.create({ rpId: 'example.com' }); + +// Report assertions as NOT user-verified, e.g. a presence-only tap. +await context.credentials.setUserVerified(false); + +const page = await context.newPage(); +await page.goto('https://example.com/login'); +// Assert the app requires step-up auth or rejects the unverified sign-in. + +// Restore verified assertions for later steps. +await context.credentials.setUserVerified(true); +``` ### param: Credentials.setUserVerified.value * since: v1.61 - `value` <[boolean]> -`true` to auto-approve user verification (default), `false` to refuse. +`true` to report assertions and attestations as user-verified (default), `false` to report them as +not user-verified. diff --git a/docs/src/auth.md b/docs/src/auth.md index 51145a79e8d68..36da17f1170d0 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -397,6 +397,75 @@ export const test = baseTest.extend<{}, { workerStorageState: string }>({ }); ``` +### Passkeys (WebAuthn) +* langs: js + +**When to use** +- Your app signs users in with passkeys (WebAuthn), and you want tests to start already enrolled. + +**Details** + +[`property: BrowserContext.credentials`] is a virtual WebAuthn authenticator. Unlike cookie or local storage state, a passkey is seeded **imperatively** with [`method: Credentials.create`] and [`method: Credentials.install`], so it lives in a [`context` fixture override](./test-fixtures.md#overriding-fixtures) rather than in the `storageState` config option. + +If your backend already provisioned a passkey for the test user, seed it directly — no setup project required: + +```js title="playwright/fixtures.ts" +import { test as baseTest } from '@playwright/test'; +export * from '@playwright/test'; + +export const test = baseTest.extend({ + context: async ({ context }, use) => { + // A passkey your backend provisioned for the test user. + await context.credentials.create({ + rpId: 'example.com', + id: process.env.PASSKEY_ID, + userHandle: process.env.PASSKEY_USER_HANDLE, + privateKey: process.env.PASSKEY_PRIVATE_KEY, + publicKey: process.env.PASSKEY_PUBLIC_KEY, + }); + await context.credentials.install(); + await use(context); + }, +}); +``` + +Otherwise, let the app register a passkey once in a [setup project](#basic-shared-account-in-all-tests), capture it with [`method: Credentials.get`], and save it to disk: + +```js title="tests/passkey.setup.ts" +import { test as setup } from '@playwright/test'; +import fs from 'fs'; + +setup('enroll passkey', async ({ context, page }) => { + await context.credentials.install(); + await page.goto('https://example.com/register'); + // The app calls navigator.credentials.create() to register the passkey. + await page.getByRole('button', { name: 'Create a passkey' }).click(); + + // Read back the registered passkey, including its private key, and save it. + const [credential] = await context.credentials.get({ rpId: 'example.com' }); + fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); +}); +``` + +Then seed the captured passkey into every test's context: + +```js title="playwright/fixtures.ts" +import { test as baseTest } from '@playwright/test'; +import fs from 'fs'; +export * from '@playwright/test'; + +export const test = baseTest.extend({ + context: async ({ context }, use) => { + const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); + await context.credentials.create(credential); + await context.credentials.install(); + await use(context); + }, +}); +``` + +Declare the `setup` project as a [dependency](./test-projects.md#dependencies) of your testing projects, just like in the [basic flow](#basic-shared-account-in-all-tests). The saved `passkey.json` contains a private key, so keep it under `playwright/.auth` and out of source control (see [Core concepts](#core-concepts)). + ### Multiple signed in roles * langs: js diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index c80d5655a3788..81a22d53e33a2 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -19419,32 +19419,76 @@ export interface Coverage { } /** - * `Credentials` provides a virtual WebAuthn authenticator scoped to a - * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). It lets tests seed credentials, intercept - * `navigator.credentials.create()` / `navigator.credentials.get()` calls in pages, and complete WebAuthn ceremonies - * without a real authenticator. + * `Credentials` is a virtual WebAuthn authenticator scoped to a + * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). It lets tests register passkeys and answer + * `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies in the page, without a real + * authenticator or hardware security key. * - * Implemented in userland via an injected script, so it works across Chromium, Firefox and WebKit. + * There are two common ways to use it: * - * **Usage** + * **Usage: seed a known credential** * * ```js * const context = await browser.newContext(); + * + * // A passkey your backend already provisioned for a test user. + * await context.credentials.create({ + * rpId: 'example.com', + * id: knownCredentialId, // base64url + * userHandle: knownUserHandle, // base64url + * privateKey: knownPrivateKey, // base64url PKCS#8 (DER) + * publicKey: knownPublicKey, // base64url SPKI (DER) + * }); * await context.credentials.install(); - * await context.credentials.create({ rpId: 'example.com' }); + * * const page = await context.newPage(); * await page.goto('https://example.com/login'); - * // Page's navigator.credentials.get() will be answered using the seeded credential. + * // The page's navigator.credentials.get() is answered with the seeded passkey. + * ``` + * + * **Usage: capture a passkey, then reuse it** + * + * ```js + * // setup test: let the app register a passkey, then save it. + * const context = await browser.newContext(); + * await context.credentials.install(); + * + * const page = await context.newPage(); + * await page.goto('https://example.com/register'); + * await page.getByRole('button', { name: 'Create a passkey' }).click(); + * + * // Read back the passkey the page registered — it includes the private key. + * const [credential] = await context.credentials.get({ rpId: 'example.com' }); + * fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); * ``` * + * ```js + * // later test: seed the captured passkey so the app starts already enrolled. + * const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); + * const context = await browser.newContext(); + * await context.credentials.create(credential); + * await context.credentials.install(); + * + * const page = await context.newPage(); + * await page.goto('https://example.com/login'); + * // navigator.credentials.get() resolves the captured passkey — already signed in. + * ``` + * + * **Defaults** */ export interface Credentials { /** - * Seeds a virtual WebAuthn credential. With only `rpId`, generates a fresh ECDSA P-256 keypair, credential id and - * user handle. To import a pre-registered credential (e.g. authenticating as an existing test user the server already - * knows about), supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. Call - * [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install) before navigating to - * a page that uses WebAuthn. + * Seeds a virtual WebAuthn credential and returns it. + * + * With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential + * is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey + * flows. The returned object carries the `privateKey` and `publicKey`, so it can be persisted to disk and re-seeded + * in a later test. + * + * To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. + * + * Call [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install) before + * navigating to a page that uses WebAuthn. * @param options */ create(options: { @@ -19500,13 +19544,23 @@ export interface Credentials { }>; /** - * Removes a previously seeded credential. + * Removes a credential from the authenticator by its id. Works for any credential currently held — both those seeded + * with [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) and those + * the page registered itself by calling `navigator.credentials.create()`. * @param id Base64url-encoded credential id. */ delete(id: string): Promise; /** - * Returns seeded credentials, optionally filtered by `rpId` or `id`. + * Returns every credential currently held by the authenticator, optionally filtered by `rpId` or `id`. This includes + * both credentials seeded with + * [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) and credentials + * the page registered itself by calling `navigator.credentials.create()`. + * + * Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just registered can be + * saved and re-seeded into a later test with + * [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) — see the + * second example in the class overview. * @param options */ get(options?: { @@ -19539,14 +19593,39 @@ export interface Credentials { * Required: until `install()` is called, no interception is in place and the page sees the platform's native (or * absent) WebAuthn behaviour. Seeding credentials with * [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) without - * `install()` populates the registry but the page will never see those credentials. + * `install()` populates the authenticator, but the page will never see those credentials. */ install(): Promise; /** - * Toggles whether the virtual authenticator auto-approves user-verification prompts. Useful for simulating a user - * denying biometric verification. - * @param value `true` to auto-approve user verification (default), `false` to refuse. + * Controls whether the virtual authenticator reports the user as **verified**. This is a context-wide setting + * (default `true`) that toggles the user-verified (UV) flag in the `authenticatorData` of every subsequent + * `navigator.credentials.create()` and `navigator.credentials.get()` ceremony. + * + * When set to `false`, ceremonies still **succeed**, but the resulting assertion/attestation reports that user + * verification was *not* performed — the user-present (UP) flag stays set. It does **not** simulate a cancelled or + * denied prompt, and it does not reject the call. Use it to test how your relying party or app handles an assertion + * that lacks user verification, for example requiring step-up authentication. + * + * **Usage** + * + * ```js + * await context.credentials.install(); + * await context.credentials.create({ rpId: 'example.com' }); + * + * // Report assertions as NOT user-verified, e.g. a presence-only tap. + * await context.credentials.setUserVerified(false); + * + * const page = await context.newPage(); + * await page.goto('https://example.com/login'); + * // Assert the app requires step-up auth or rejects the unverified sign-in. + * + * // Restore verified assertions for later steps. + * await context.credentials.setUserVerified(true); + * ``` + * + * @param value `true` to report assertions and attestations as user-verified (default), `false` to report them as not + * user-verified. */ setUserVerified(value: boolean): Promise; } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index c80d5655a3788..81a22d53e33a2 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19419,32 +19419,76 @@ export interface Coverage { } /** - * `Credentials` provides a virtual WebAuthn authenticator scoped to a - * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). It lets tests seed credentials, intercept - * `navigator.credentials.create()` / `navigator.credentials.get()` calls in pages, and complete WebAuthn ceremonies - * without a real authenticator. + * `Credentials` is a virtual WebAuthn authenticator scoped to a + * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). It lets tests register passkeys and answer + * `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies in the page, without a real + * authenticator or hardware security key. * - * Implemented in userland via an injected script, so it works across Chromium, Firefox and WebKit. + * There are two common ways to use it: * - * **Usage** + * **Usage: seed a known credential** * * ```js * const context = await browser.newContext(); + * + * // A passkey your backend already provisioned for a test user. + * await context.credentials.create({ + * rpId: 'example.com', + * id: knownCredentialId, // base64url + * userHandle: knownUserHandle, // base64url + * privateKey: knownPrivateKey, // base64url PKCS#8 (DER) + * publicKey: knownPublicKey, // base64url SPKI (DER) + * }); * await context.credentials.install(); - * await context.credentials.create({ rpId: 'example.com' }); + * * const page = await context.newPage(); * await page.goto('https://example.com/login'); - * // Page's navigator.credentials.get() will be answered using the seeded credential. + * // The page's navigator.credentials.get() is answered with the seeded passkey. + * ``` + * + * **Usage: capture a passkey, then reuse it** + * + * ```js + * // setup test: let the app register a passkey, then save it. + * const context = await browser.newContext(); + * await context.credentials.install(); + * + * const page = await context.newPage(); + * await page.goto('https://example.com/register'); + * await page.getByRole('button', { name: 'Create a passkey' }).click(); + * + * // Read back the passkey the page registered — it includes the private key. + * const [credential] = await context.credentials.get({ rpId: 'example.com' }); + * fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); * ``` * + * ```js + * // later test: seed the captured passkey so the app starts already enrolled. + * const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); + * const context = await browser.newContext(); + * await context.credentials.create(credential); + * await context.credentials.install(); + * + * const page = await context.newPage(); + * await page.goto('https://example.com/login'); + * // navigator.credentials.get() resolves the captured passkey — already signed in. + * ``` + * + * **Defaults** */ export interface Credentials { /** - * Seeds a virtual WebAuthn credential. With only `rpId`, generates a fresh ECDSA P-256 keypair, credential id and - * user handle. To import a pre-registered credential (e.g. authenticating as an existing test user the server already - * knows about), supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. Call - * [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install) before navigating to - * a page that uses WebAuthn. + * Seeds a virtual WebAuthn credential and returns it. + * + * With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential + * is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey + * flows. The returned object carries the `privateKey` and `publicKey`, so it can be persisted to disk and re-seeded + * in a later test. + * + * To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. + * + * Call [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install) before + * navigating to a page that uses WebAuthn. * @param options */ create(options: { @@ -19500,13 +19544,23 @@ export interface Credentials { }>; /** - * Removes a previously seeded credential. + * Removes a credential from the authenticator by its id. Works for any credential currently held — both those seeded + * with [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) and those + * the page registered itself by calling `navigator.credentials.create()`. * @param id Base64url-encoded credential id. */ delete(id: string): Promise; /** - * Returns seeded credentials, optionally filtered by `rpId` or `id`. + * Returns every credential currently held by the authenticator, optionally filtered by `rpId` or `id`. This includes + * both credentials seeded with + * [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) and credentials + * the page registered itself by calling `navigator.credentials.create()`. + * + * Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just registered can be + * saved and re-seeded into a later test with + * [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) — see the + * second example in the class overview. * @param options */ get(options?: { @@ -19539,14 +19593,39 @@ export interface Credentials { * Required: until `install()` is called, no interception is in place and the page sees the platform's native (or * absent) WebAuthn behaviour. Seeding credentials with * [credentials.create(options)](https://playwright.dev/docs/api/class-credentials#credentials-create) without - * `install()` populates the registry but the page will never see those credentials. + * `install()` populates the authenticator, but the page will never see those credentials. */ install(): Promise; /** - * Toggles whether the virtual authenticator auto-approves user-verification prompts. Useful for simulating a user - * denying biometric verification. - * @param value `true` to auto-approve user verification (default), `false` to refuse. + * Controls whether the virtual authenticator reports the user as **verified**. This is a context-wide setting + * (default `true`) that toggles the user-verified (UV) flag in the `authenticatorData` of every subsequent + * `navigator.credentials.create()` and `navigator.credentials.get()` ceremony. + * + * When set to `false`, ceremonies still **succeed**, but the resulting assertion/attestation reports that user + * verification was *not* performed — the user-present (UP) flag stays set. It does **not** simulate a cancelled or + * denied prompt, and it does not reject the call. Use it to test how your relying party or app handles an assertion + * that lacks user verification, for example requiring step-up authentication. + * + * **Usage** + * + * ```js + * await context.credentials.install(); + * await context.credentials.create({ rpId: 'example.com' }); + * + * // Report assertions as NOT user-verified, e.g. a presence-only tap. + * await context.credentials.setUserVerified(false); + * + * const page = await context.newPage(); + * await page.goto('https://example.com/login'); + * // Assert the app requires step-up auth or rejects the unverified sign-in. + * + * // Restore verified assertions for later steps. + * await context.credentials.setUserVerified(true); + * ``` + * + * @param value `true` to report assertions and attestations as user-verified (default), `false` to report them as not + * user-verified. */ setUserVerified(value: boolean): Promise; } diff --git a/tests/library/browsercontext-webauthn.spec.ts b/tests/library/browsercontext-webauthn.spec.ts index f4295c83ae4dd..2b5ed6eff255a 100644 --- a/tests/library/browsercontext-webauthn.spec.ts +++ b/tests/library/browsercontext-webauthn.spec.ts @@ -18,27 +18,6 @@ import { browserTest as it, expect } from '../config/browserTest'; it.skip(({ mode }) => mode.startsWith('service')); -// WebAuthn requires a secure context (HTTPS or localhost). The test server's default -// `same_origin = 'localhost'` satisfies this for HTTP. - -it('should seed a credential @smoke', async ({ contextFactory, server }) => { - const context = await contextFactory(); - const cred = await context.credentials.create({ rpId: server.HOSTNAME }); - expect(cred.id).toBeTruthy(); - expect(cred.rpId).toBe(server.HOSTNAME); - expect(cred.userHandle).toBeTruthy(); - // base64url has no padding and no +/ chars - expect(cred.privateKey).toMatch(/^[A-Za-z0-9_-]+$/); - expect(cred.publicKey).toMatch(/^[A-Za-z0-9_-]+$/); - - const all = await context.credentials.get(); - expect(all).toHaveLength(1); - expect(all[0].id).toBe(cred.id); - - await context.credentials.delete(cred.id); - expect(await context.credentials.get()).toHaveLength(0); -}); - it('should not intercept navigator.credentials without install()', async ({ contextFactory, server }) => { const context = await contextFactory(); // Seed a credential, but do not install the interceptor. @@ -50,10 +29,51 @@ it('should not intercept navigator.credentials without install()', async ({ cont expect(intercepted).toBe(false); }); -it('should authenticate with a seeded credential', async ({ contextFactory, server }) => { +it('should toggle user-verified flag', async ({ contextFactory, server }) => { const context = await contextFactory(); await context.credentials.install(); const seeded = await context.credentials.create({ rpId: server.HOSTNAME }); + await context.credentials.setUserVerified(false); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + + const flagsByte = await page.evaluate(async ({ rpId, credentialId }) => { + const b64UrlToBytes = (s: string) => { + let str = s.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) + str += '='; + const bin = atob(str); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) + u8[i] = bin.charCodeAt(i); + return u8; + }; + const cred = await navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array(32), + rpId, + allowCredentials: [{ type: 'public-key', id: b64UrlToBytes(credentialId) }], + }, + }) as PublicKeyCredential; + const resp = cred.response as AuthenticatorAssertionResponse; + return new Uint8Array(resp.authenticatorData)[32]; + }, { rpId: server.HOSTNAME, credentialId: seeded.id }); + + // UV bit (0x04) should be unset; UP bit (0x01) still set. + expect(flagsByte & 0x04).toBe(0); + expect(flagsByte & 0x01).toBe(0x01); +}); + +it('should seed a known credential and authenticate', async ({ contextFactory, server }) => { + // This is the easiest way to create credentials. In practice, this + // probably comes from environment. + const source = await contextFactory(); + const known = await source.credentials.create({ rpId: server.HOSTNAME }); + + // A fresh context imports the known credential and signs in with it. + const context = await contextFactory(); + await context.credentials.create(known); + await context.credentials.install(); const page = await context.newPage(); await page.goto(server.EMPTY_PAGE); @@ -86,55 +106,21 @@ it('should authenticate with a seeded credential', async ({ contextFactory, serv hasSignature: resp.signature.byteLength > 0, authDataFlags: new Uint8Array(resp.authenticatorData)[32], }; - }, { rpId: server.HOSTNAME, credentialId: seeded.id }); + }, { rpId: server.HOSTNAME, credentialId: known.id }); - expect(result.id).toBe(seeded.id); + expect(result.id).toBe(known.id); expect(result.type).toBe('public-key'); expect(result.hasClientData).toBe(true); expect(result.hasAuthData).toBe(true); expect(result.hasSignature).toBe(true); // UP (0x01) | UV (0x04) = 0x05 expect(result.authDataFlags & 0x05).toBe(0x05); -}); -it('should round-trip create then get in the page', async ({ contextFactory, server }) => { - const context = await contextFactory(); - await context.credentials.install(); - const page = await context.newPage(); - await page.goto(server.EMPTY_PAGE); - - const result = await page.evaluate(async ({ rpId }) => { - const challenge1 = crypto.getRandomValues(new Uint8Array(32)); - const created = await navigator.credentials.create({ - publicKey: { - challenge: challenge1, - rp: { id: rpId, name: 'Test RP' }, - user: { id: new Uint8Array([1, 2, 3, 4]), name: 'u', displayName: 'User' }, - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, - }, - }) as PublicKeyCredential; - const challenge2 = crypto.getRandomValues(new Uint8Array(32)); - const got = await navigator.credentials.get({ - publicKey: { challenge: challenge2, rpId, userVerification: 'preferred' }, - }) as PublicKeyCredential; - return { createdId: created.id, gotId: got.id }; - }, { rpId: server.HOSTNAME }); - - expect(result.createdId).toBe(result.gotId); - const stored = await context.credentials.get(); - expect(stored.map(c => c.id)).toContain(result.createdId); -}); - -it('should toggle user-verified flag', async ({ contextFactory, server }) => { - const context = await contextFactory(); - await context.credentials.install(); - const seeded = await context.credentials.create({ rpId: server.HOSTNAME }); - await context.credentials.setUserVerified(false); - const page = await context.newPage(); - await page.goto(server.EMPTY_PAGE); + // After the credential is deleted, the page can no longer authenticate with it. + await context.credentials.delete(known.id); + expect(await context.credentials.get()).toHaveLength(0); - const flagsByte = await page.evaluate(async ({ rpId, credentialId }) => { + const error = await page.evaluate(async ({ rpId, credentialId }) => { const b64UrlToBytes = (s: string) => { let str = s.replace(/-/g, '+').replace(/_/g, '/'); while (str.length % 4) @@ -145,42 +131,64 @@ it('should toggle user-verified flag', async ({ contextFactory, server }) => { u8[i] = bin.charCodeAt(i); return u8; }; - const cred = await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(32), - rpId, - allowCredentials: [{ type: 'public-key', id: b64UrlToBytes(credentialId) }], - }, - }) as PublicKeyCredential; - const resp = cred.response as AuthenticatorAssertionResponse; - return new Uint8Array(resp.authenticatorData)[32]; - }, { rpId: server.HOSTNAME, credentialId: seeded.id }); - - // UV bit (0x04) should be unset; UP bit (0x01) still set. - expect(flagsByte & 0x04).toBe(0); - expect(flagsByte & 0x01).toBe(0x01); -}); - -it('should reject when no credential matches', async ({ contextFactory, server }) => { - const context = await contextFactory(); - await context.credentials.install(); - const page = await context.newPage(); - await page.goto(server.EMPTY_PAGE); - - const error = await page.evaluate(async ({ rpId }) => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); try { await navigator.credentials.get({ publicKey: { - challenge: new Uint8Array(32), + challenge, rpId, - allowCredentials: [{ type: 'public-key', id: new Uint8Array([9, 9, 9, 9]) }], + allowCredentials: [{ type: 'public-key', id: b64UrlToBytes(credentialId) }], }, }); return 'no-error'; } catch (e) { return (e as DOMException).name; } + }, { rpId: server.HOSTNAME, credentialId: known.id }); + expect(error).toBe('NotAllowedError'); +}); + +it('should capture a page-created credential and reuse it in another context', async ({ contextFactory, server }) => { + // Setup context: the app registers a passkey via navigator.credentials.create(). + const setupContext = await contextFactory(); + await setupContext.credentials.install(); + const setupPage = await setupContext.newPage(); + await setupPage.goto(server.EMPTY_PAGE); + + const createdId = await setupPage.evaluate(async ({ rpId }) => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const created = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { id: rpId, name: 'Test RP' }, + user: { id: new Uint8Array([1, 2, 3, 4]), name: 'u', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }, + }) as PublicKeyCredential; + return created.id; }, { rpId: server.HOSTNAME }); - expect(error).toBe('NotAllowedError'); + const [captured] = await setupContext.credentials.get({ rpId: server.HOSTNAME }); + expect(captured.id).toBe(createdId); + expect(captured.privateKey).toMatch(/^[A-Za-z0-9_-]+$/); + expect(captured.publicKey).toMatch(/^[A-Za-z0-9_-]+$/); + + // Reuse the captured passkey in a fresh context and sign in with it. + const context = await contextFactory(); + await context.credentials.create(captured); + await context.credentials.install(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + + const gotId = await page.evaluate(async ({ rpId }) => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + // No allowCredentials — relies on the re-seeded credential being discoverable. + const cred = await navigator.credentials.get({ + publicKey: { challenge, rpId, userVerification: 'preferred' }, + }) as PublicKeyCredential; + return cred.id; + }, { rpId: server.HOSTNAME }); + + expect(gotId).toBe(createdId); }); From 26e3dc2b8d0fed23409ddfea7cb7175b4e9b2163 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 8 Jun 2026 08:33:40 -0700 Subject: [PATCH 4/7] fix(test): clarify error for test() after await in async describe (#41145) --- packages/playwright/src/common/testType.ts | 1 + tests/playwright-test/basic.spec.ts | 31 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index f08eb29b7ee01..9911efe09c78d 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -85,6 +85,7 @@ export class TestTypeImpl { `- You are calling ${title} in a file that is imported by the configuration file.`, `- You have two different versions of @playwright/test. This usually happens`, ` when one of the dependencies in your package.json depends on @playwright/test.`, + `- You are calling ${title} from an async test.describe() block. Only sync ones are supported.`, ].join('\n')); } return suite; diff --git a/tests/playwright-test/basic.spec.ts b/tests/playwright-test/basic.spec.ts index 13959c2765e29..62aa216bb140b 100644 --- a/tests/playwright-test/basic.spec.ts +++ b/tests/playwright-test/basic.spec.ts @@ -411,6 +411,37 @@ test('should support describe() without a title', async ({ runInlineTest }) => { expect(result.output).toContain('a.spec.ts:6:17 › suite1 › suite2 › my test'); }); +test('should hint at async describe() callbacks when test() is called too late', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'data.json': `{"entries":{"a":{"id":"a","title":"A"},"b":{"id":"b","title":"B"}}}`, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test.describe('theme', async () => { + const { default: data } = await import('./data.json', { with: { type: 'json' } }); + for (const entry of Object.values(data.entries)) + test(entry.title, async () => {}); + }); + `, + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Playwright Test did not expect test() to be called here.'); + expect(result.output).toContain('You are calling test() from an async test.describe() block. Only sync ones are supported.'); +}); + +test('should allow async describe() callback that registers tests synchronously', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test.describe('theme', async () => { + test('one', async () => {}); + test('two', async () => {}); + }); + `, + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); +}); + test('test.{skip,fixme} should define a skipped test', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` From 3ba155ee28e3c50af63c9f517283d41afb6800a4 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 8 Jun 2026 17:45:34 +0100 Subject: [PATCH 5/7] chore(credentials): remove setUserVerified (#41195) --- docs/src/api/class-credentials.md | 40 ------------------ examples/webauthn/user-verification.mjs | 41 ------------------- packages/isomorphic/protocolMetainfo.ts | 9 ++-- packages/playwright-client/types/types.d.ts | 32 --------------- .../playwright-core/src/client/credentials.ts | 4 -- .../playwright-core/src/protocol/validator.ts | 4 -- .../playwright-core/src/server/credentials.ts | 9 +--- .../dispatchers/browserContextDispatcher.ts | 4 -- packages/playwright-core/types/types.d.ts | 32 --------------- packages/protocol/spec/browserContext.yml | 9 ++-- packages/protocol/src/channels.d.ts | 8 ---- tests/library/browsercontext-webauthn.spec.ts | 35 ---------------- 12 files changed, 10 insertions(+), 217 deletions(-) delete mode 100644 examples/webauthn/user-verification.mjs diff --git a/docs/src/api/class-credentials.md b/docs/src/api/class-credentials.md index ff869db9cd8b1..a99def42cc10f 100644 --- a/docs/src/api/class-credentials.md +++ b/docs/src/api/class-credentials.md @@ -71,8 +71,6 @@ await page.goto('https://example.com/login'); usernameless passkey flows resolve them. - Fresh keys are ECDSA P-256 (COSE algorithm `-7`). An omitted `id` or `userHandle` is filled with 16 random bytes. -- User verification is **on** by default — every assertion and attestation reports the user as - verified. Toggle this with [`method: Credentials.setUserVerified`]. ## async method: Credentials.install * since: v1.61 @@ -158,41 +156,3 @@ Only return credentials for this relying party id. - `id` <[string]> Only return the credential with this base64url-encoded id. - -## async method: Credentials.setUserVerified -* since: v1.61 - -Controls whether the virtual authenticator reports the user as **verified**. This is a -context-wide setting (default `true`) that toggles the user-verified (UV) flag in the -`authenticatorData` of every subsequent `navigator.credentials.create()` and -`navigator.credentials.get()` ceremony. - -When set to `false`, ceremonies still **succeed**, but the resulting assertion/attestation reports -that user verification was *not* performed — the user-present (UP) flag stays set. It does **not** -simulate a cancelled or denied prompt, and it does not reject the call. Use it to test how your -relying party or app handles an assertion that lacks user verification, for example requiring -step-up authentication. - -**Usage** - -```js -await context.credentials.install(); -await context.credentials.create({ rpId: 'example.com' }); - -// Report assertions as NOT user-verified, e.g. a presence-only tap. -await context.credentials.setUserVerified(false); - -const page = await context.newPage(); -await page.goto('https://example.com/login'); -// Assert the app requires step-up auth or rejects the unverified sign-in. - -// Restore verified assertions for later steps. -await context.credentials.setUserVerified(true); -``` - -### param: Credentials.setUserVerified.value -* since: v1.61 -- `value` <[boolean]> - -`true` to report assertions and attestations as user-verified (default), `false` to report them as -not user-verified. diff --git a/examples/webauthn/user-verification.mjs b/examples/webauthn/user-verification.mjs deleted file mode 100644 index eb681d8ec9e40..0000000000000 --- a/examples/webauthn/user-verification.mjs +++ /dev/null @@ -1,41 +0,0 @@ -// Demonstrates `context.credentials.setUserVerified()` — simulating a user -// refusing biometric verification (e.g. a wrong fingerprint). -// -// We configure webauthn.io to require user verification, then flip the -// authenticator's UV flag off and try to log in. The relying party sees -// UV=0 in the assertion flags and rejects the login. Flipping UV back on -// makes the next attempt succeed. - -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: false, slowMo: 250 }); - const context = await browser.newContext(); - await context.credentials.install(); - const page = await context.newPage(); - await page.goto('https://webauthn.io/'); - - // Force the relying party to require user verification at both ends. - await page.locator('button:has-text("Advanced Settings")').click(); - await page.locator('#optRegUserVerification').selectOption('required'); - await page.locator('#optAuthUserVerification').selectOption('required'); - - await page.locator('#input-email').fill(`pw-demo-${Date.now()}`); - await page.locator('#register-button').click(); - await page.getByText(/success!.*try to authenticate/i).waitFor(); - console.log(` ✓ registered (UV=required)`); - - // Simulate failed biometric — UV bit will be 0 in the next assertion. - await context.credentials.setUserVerified(false); - await page.locator('#login-button').click(); - await page.getByText(/authentication failed/i).waitFor(); - console.log(` ✓ login rejected: server got UV=0 but required UV=1`); - - // Recovery: biometric succeeds, UV=1 in the assertion. - await context.credentials.setUserVerified(true); - await page.locator('#login-button').click(); - await page.getByText(/you're logged in/i).waitFor(); - console.log(` ✓ login succeeded after UV restored`); - - await browser.close(); -})(); diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index b0e779d1b061a..95683a55ac9e8 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -107,11 +107,10 @@ export const methodMetainfo = new Map([ ['BrowserContext.clockRunFor', { title: 'Run clock "{ticksNumber|ticksString}"', }], ['BrowserContext.clockSetFixedTime', { title: 'Set fixed time "{timeNumber|timeString}"', }], ['BrowserContext.clockSetSystemTime', { title: 'Set system time "{timeNumber|timeString}"', }], - ['BrowserContext.credentialsInstall', { title: 'Install virtual WebAuthn authenticator', }], - ['BrowserContext.credentialsCreate', { title: 'Create virtual credential for "{rpId}"', }], - ['BrowserContext.credentialsGet', { title: 'Get virtual credentials', }], - ['BrowserContext.credentialsDelete', { title: 'Delete virtual credential', }], - ['BrowserContext.credentialsSetUserVerified', { title: 'Set virtual authenticator user verified', }], + ['BrowserContext.credentialsInstall', { title: 'Install virtual WebAuthn authenticator', group: 'configuration', }], + ['BrowserContext.credentialsCreate', { title: 'Create virtual credential for "{rpId}"', group: 'configuration', }], + ['BrowserContext.credentialsGet', { title: 'Get virtual credentials', group: 'configuration', }], + ['BrowserContext.credentialsDelete', { title: 'Delete virtual credential', group: 'configuration', }], ['BrowserType.launch', { title: 'Launch browser', }], ['BrowserType.launchPersistentContext', { title: 'Launch persistent context', }], ['BrowserType.connectOverCDP', { title: 'Connect over CDP', }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 81a22d53e33a2..e880a095ae58b 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -19596,38 +19596,6 @@ export interface Credentials { * `install()` populates the authenticator, but the page will never see those credentials. */ install(): Promise; - - /** - * Controls whether the virtual authenticator reports the user as **verified**. This is a context-wide setting - * (default `true`) that toggles the user-verified (UV) flag in the `authenticatorData` of every subsequent - * `navigator.credentials.create()` and `navigator.credentials.get()` ceremony. - * - * When set to `false`, ceremonies still **succeed**, but the resulting assertion/attestation reports that user - * verification was *not* performed — the user-present (UP) flag stays set. It does **not** simulate a cancelled or - * denied prompt, and it does not reject the call. Use it to test how your relying party or app handles an assertion - * that lacks user verification, for example requiring step-up authentication. - * - * **Usage** - * - * ```js - * await context.credentials.install(); - * await context.credentials.create({ rpId: 'example.com' }); - * - * // Report assertions as NOT user-verified, e.g. a presence-only tap. - * await context.credentials.setUserVerified(false); - * - * const page = await context.newPage(); - * await page.goto('https://example.com/login'); - * // Assert the app requires step-up auth or rejects the unverified sign-in. - * - * // Restore verified assertions for later steps. - * await context.credentials.setUserVerified(true); - * ``` - * - * @param value `true` to report assertions and attestations as user-verified (default), `false` to report them as not - * user-verified. - */ - setUserVerified(value: boolean): Promise; } /** diff --git a/packages/playwright-core/src/client/credentials.ts b/packages/playwright-core/src/client/credentials.ts index 3d9db076d4fc8..3d01f395aaa28 100644 --- a/packages/playwright-core/src/client/credentials.ts +++ b/packages/playwright-core/src/client/credentials.ts @@ -42,8 +42,4 @@ export class Credentials implements api.Credentials { async delete(id: string): Promise { await this._browserContext._channel.credentialsDelete({ id }); } - - async setUserVerified(value: boolean): Promise { - await this._browserContext._channel.credentialsSetUserVerified({ value }); - } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 75600eabe1d85..523e59a669eb2 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -956,10 +956,6 @@ scheme.BrowserContextCredentialsDeleteParams = tObject({ id: tString, }); scheme.BrowserContextCredentialsDeleteResult = tOptional(tObject({})); -scheme.BrowserContextCredentialsSetUserVerifiedParams = tObject({ - value: tBoolean, -}); -scheme.BrowserContextCredentialsSetUserVerifiedResult = tOptional(tObject({})); scheme.BrowserTypeInitializer = tObject({ executablePath: tString, name: tString, diff --git a/packages/playwright-core/src/server/credentials.ts b/packages/playwright-core/src/server/credentials.ts index 43702a19976a3..1f70392e588cd 100644 --- a/packages/playwright-core/src/server/credentials.ts +++ b/packages/playwright-core/src/server/credentials.ts @@ -44,7 +44,6 @@ export class Credentials { private _initScripts: InitScript[] = []; private _installed = false; private _registry = new Map(); - private _userVerified = true; constructor(browserContext: BrowserContext) { this._browserContext = browserContext; @@ -91,10 +90,6 @@ export class Credentials { this._registry.delete(id); } - setUserVerified(value: boolean) { - this._userVerified = value; - } - async dispose(progress: Progress) { await progress.race(Promise.all(this._initScripts.map(s => s.dispose()))); this._initScripts = []; @@ -160,7 +155,7 @@ export class Credentials { crossOrigin: false, })); const rpIdHash = crypto.createHash('sha256').update(rpId).digest(); - const flags = 0x01 | (this._userVerified ? 0x04 : 0) | 0x40; // UP | UV? | AT + const flags = 0x01 | 0x04 | 0x40; // UP | UV | AT const signCountBuf = u32ToBytes(record.signCount); const cosePublicKey = encodeCoseEs256PublicKey(pair.publicKey); const credIdLenBuf = Buffer.from([(credentialId.length >> 8) & 0xff, credentialId.length & 0xff]); @@ -205,7 +200,7 @@ export class Credentials { crossOrigin: false, })); const rpIdHash = crypto.createHash('sha256').update(rpId).digest(); - const flags = 0x01 | (this._userVerified ? 0x04 : 0); // UP | UV? + const flags = 0x01 | 0x04; // UP | UV candidate.signCount += 1; const signCountBuf = u32ToBytes(candidate.signCount); const authData = Buffer.concat([rpIdHash, Buffer.from([flags]), signCountBuf]); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index e0763acdf7809..4db2f8adfd955 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -422,10 +422,6 @@ export class BrowserContextDispatcher extends Dispatcher { - this._context.credentials.setUserVerified(params.value); - } - async updateSubscription(params: channels.BrowserContextUpdateSubscriptionParams, progress: Progress): Promise { if (params.enabled) this._subscriptions.add(params.event); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 81a22d53e33a2..e880a095ae58b 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19596,38 +19596,6 @@ export interface Credentials { * `install()` populates the authenticator, but the page will never see those credentials. */ install(): Promise; - - /** - * Controls whether the virtual authenticator reports the user as **verified**. This is a context-wide setting - * (default `true`) that toggles the user-verified (UV) flag in the `authenticatorData` of every subsequent - * `navigator.credentials.create()` and `navigator.credentials.get()` ceremony. - * - * When set to `false`, ceremonies still **succeed**, but the resulting assertion/attestation reports that user - * verification was *not* performed — the user-present (UP) flag stays set. It does **not** simulate a cancelled or - * denied prompt, and it does not reject the call. Use it to test how your relying party or app handles an assertion - * that lacks user verification, for example requiring step-up authentication. - * - * **Usage** - * - * ```js - * await context.credentials.install(); - * await context.credentials.create({ rpId: 'example.com' }); - * - * // Report assertions as NOT user-verified, e.g. a presence-only tap. - * await context.credentials.setUserVerified(false); - * - * const page = await context.newPage(); - * await page.goto('https://example.com/login'); - * // Assert the app requires step-up auth or rejects the unverified sign-in. - * - * // Restore verified assertions for later steps. - * await context.credentials.setUserVerified(true); - * ``` - * - * @param value `true` to report assertions and attestations as user-verified (default), `false` to report them as not - * user-verified. - */ - setUserVerified(value: boolean): Promise; } /** diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index 5abdc57a15c7f..b930f2a6bc3ba 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -316,9 +316,11 @@ BrowserContext: credentialsInstall: title: Install virtual WebAuthn authenticator + group: configuration credentialsCreate: title: Create virtual credential for "{rpId}" + group: configuration parameters: rpId: string id: string? @@ -330,6 +332,7 @@ BrowserContext: credentialsGet: title: Get virtual credentials + group: configuration parameters: rpId: string? id: string? @@ -340,14 +343,10 @@ BrowserContext: credentialsDelete: title: Delete virtual credential + group: configuration parameters: id: string - credentialsSetUserVerified: - title: Set virtual authenticator user verified - parameters: - value: boolean - events: bindingCall: diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index c97f2e689d227..9a19765c00ecb 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1366,7 +1366,6 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe credentialsCreate(params: BrowserContextCredentialsCreateParams, progress?: Progress): Promise; credentialsGet(params: BrowserContextCredentialsGetParams, progress?: Progress): Promise; credentialsDelete(params: BrowserContextCredentialsDeleteParams, progress?: Progress): Promise; - credentialsSetUserVerified(params: BrowserContextCredentialsSetUserVerifiedParams, progress?: Progress): Promise; } export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, @@ -1785,13 +1784,6 @@ export type BrowserContextCredentialsDeleteOptions = { }; export type BrowserContextCredentialsDeleteResult = void; -export type BrowserContextCredentialsSetUserVerifiedParams = { - value: boolean, -}; -export type BrowserContextCredentialsSetUserVerifiedOptions = { - -}; -export type BrowserContextCredentialsSetUserVerifiedResult = void; export interface BrowserContextEvents { 'bindingCall': BrowserContextBindingCallEvent; diff --git a/tests/library/browsercontext-webauthn.spec.ts b/tests/library/browsercontext-webauthn.spec.ts index 2b5ed6eff255a..61409c5a27b77 100644 --- a/tests/library/browsercontext-webauthn.spec.ts +++ b/tests/library/browsercontext-webauthn.spec.ts @@ -29,41 +29,6 @@ it('should not intercept navigator.credentials without install()', async ({ cont expect(intercepted).toBe(false); }); -it('should toggle user-verified flag', async ({ contextFactory, server }) => { - const context = await contextFactory(); - await context.credentials.install(); - const seeded = await context.credentials.create({ rpId: server.HOSTNAME }); - await context.credentials.setUserVerified(false); - const page = await context.newPage(); - await page.goto(server.EMPTY_PAGE); - - const flagsByte = await page.evaluate(async ({ rpId, credentialId }) => { - const b64UrlToBytes = (s: string) => { - let str = s.replace(/-/g, '+').replace(/_/g, '/'); - while (str.length % 4) - str += '='; - const bin = atob(str); - const u8 = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) - u8[i] = bin.charCodeAt(i); - return u8; - }; - const cred = await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(32), - rpId, - allowCredentials: [{ type: 'public-key', id: b64UrlToBytes(credentialId) }], - }, - }) as PublicKeyCredential; - const resp = cred.response as AuthenticatorAssertionResponse; - return new Uint8Array(resp.authenticatorData)[32]; - }, { rpId: server.HOSTNAME, credentialId: seeded.id }); - - // UV bit (0x04) should be unset; UP bit (0x01) still set. - expect(flagsByte & 0x04).toBe(0); - expect(flagsByte & 0x01).toBe(0x01); -}); - it('should seed a known credential and authenticate', async ({ contextFactory, server }) => { // This is the easiest way to create credentials. In practice, this // probably comes from environment. From f81cf948a6ecc228cb9f37ff1883c8b33902fee0 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:10:20 -0700 Subject: [PATCH 6/7] feat(firefox-beta): roll to r1523 (#41187) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 7336cf80ee9b5..2a230650a7003 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -38,7 +38,7 @@ }, { "name": "firefox-beta", - "revision": "1522", + "revision": "1523", "installByDefault": false, "browserVersion": "152.0b1", "title": "Firefox Beta" From 7b652ba30edc53dc83b9d5ddc380406ac1810aed Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:24:42 -0700 Subject: [PATCH 7/7] feat(webkit): roll to r2305 (#41174) --- packages/playwright-core/browsers.json | 2 +- tests/library/har.spec.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2a230650a7003..91a2f0bfa5e40 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -45,7 +45,7 @@ }, { "name": "webkit", - "revision": "2302", + "revision": "2305", "installByDefault": true, "revisionOverrides": { "mac14": "2251", diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index b10f0d240f773..a1cb818b933a0 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -627,7 +627,6 @@ it('should have connection details', async ({ contextFactory, server, browserNam }); it('should have security details', async ({ contextFactory, httpsServer, browserName, platform, mode }, testInfo) => { - it.fail(browserName === 'webkit' && platform === 'linux', 'https://github.com/microsoft/playwright/issues/6759'); it.fail(browserName === 'webkit' && platform === 'win32'); const { page, getLog } = await pageWithHar(contextFactory, testInfo); @@ -639,7 +638,7 @@ it('should have security details', async ({ contextFactory, httpsServer, browser expect(serverIPAddress).toMatch(/^127\.0\.0\.1|\[::1\]/); expect(port).toBe(httpsServer.PORT); } - if (browserName === 'webkit' && platform === 'darwin') + if (browserName === 'webkit') expect(securityDetails).toEqual({ protocol: 'TLS 1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); else expect(securityDetails).toEqual({ issuer: 'playwright-test', protocol: 'TLS 1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); @@ -696,8 +695,6 @@ it('should return server address directly from response', async ({ page, server, }); it('should return security details directly from response', async ({ contextFactory, httpsServer, browserName, platform, channel }) => { - it.fail(browserName === 'webkit' && (platform === 'linux' || channel === 'webkit-wsl'), 'https://github.com/microsoft/playwright/issues/6759'); - const context = await contextFactory({ ignoreHTTPSErrors: true }); const page = await context.newPage(); const response = await page.goto(httpsServer.EMPTY_PAGE);