diff --git a/docs/src/api/class-credentials.md b/docs/src/api/class-credentials.md
index 59af7a54d358f..a99def42cc10f 100644
--- a/docs/src/api/class-credentials.md
+++ b/docs/src/api/class-credentials.md
@@ -1,23 +1,77 @@
# 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();
+
+const page = await context.newPage();
+await page.goto('https://example.com/login');
+// 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();
-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.
+// 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.
+
## async method: Credentials.install
* since: v1.61
@@ -27,7 +81,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 +94,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 +118,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 +138,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
@@ -89,15 +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
-
-Toggles whether the virtual authenticator auto-approves user-verification prompts. Useful for
-simulating a user denying biometric verification.
-
-### param: Credentials.setUserVerified.value
-* since: v1.61
-- `value` <[boolean]>
-
-`true` to auto-approve user verification (default), `false` to refuse.
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/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
-
[
](https://pypi.python.org/pypi/playwright/)
```bash
@@ -15,17 +13,6 @@ pip install playwright
playwright install
```
-### Conda
-
-[
](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
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/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/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 c80d5655a3788..e880a095ae58b 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,16 +19593,9 @@ 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.
- */
- setUserVerified(value: boolean): Promise;
}
/**
diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json
index 7336cf80ee9b5..91a2f0bfa5e40 100644
--- a/packages/playwright-core/browsers.json
+++ b/packages/playwright-core/browsers.json
@@ -38,14 +38,14 @@
},
{
"name": "firefox-beta",
- "revision": "1522",
+ "revision": "1523",
"installByDefault": false,
"browserVersion": "152.0b1",
"title": "Firefox Beta"
},
{
"name": "webkit",
- "revision": "2302",
+ "revision": "2305",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
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 c80d5655a3788..e880a095ae58b 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,16 +19593,9 @@ 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.
- */
- setUserVerified(value: boolean): Promise;
}
/**
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/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();
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 f4295c83ae4dd..61409c5a27b77 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,16 @@ 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 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 seeded = await context.credentials.create({ rpId: server.HOSTNAME });
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
@@ -86,55 +71,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 +96,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);
});
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);
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': `