Skip to content

Commit ab96cd2

Browse files
authored
Merge pull request #19990 from mozilla/spike/passkey-chromium-func-test
chore(fxa): Add passkey functional test example
2 parents a5ff41a + 8e9afe6 commit ab96cd2

8 files changed

Lines changed: 523 additions & 2 deletions

File tree

packages/functional-tests/README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ The environments that this suite may run against are:
1414

1515
Each has its own named script in [package.json](./package.json) or you can use `--project` when running `playwright test` manually. They are implemented in `lib/targets`.
1616

17+
There is also a very small subset of tests that can _only_ run against Chromium browsers. These have slightly different project names, listed below, and should only be used when running the Passkey tests.
18+
19+
- local-chromium
20+
- stage-chromium
21+
- production-chromium
22+
1723
### Running the tests
1824

1925
If this is your first time running the tests, run `npx playwright install --with-deps` to install the browsers. Then:
@@ -114,6 +120,95 @@ test('totp test with proper cleanup', async ({
114120
// `testAccountTracker` cleanup will use secret attached to Credentials to destroy the account
115121
```
116122

123+
### Passkey Virtual Authenticator
124+
125+
Testing passkeys requires simulating physical authenticator devices. The `PasskeyVirtualAuthenticator` uses Chrome DevTools Protocol (CDP) to create virtual authenticators in Chromium browsers, allowing automated testing of WebAuthn flows without real hardware.
126+
127+
**Important**: Passkey tests only run in Chromium projects due to CDP dependency.
128+
129+
#### Why the wrapper is necessary
130+
131+
WebAuthn operations are asynchronous and require precise timing coordination. The browser must have a virtual authenticator registered and listening _before_ triggering passkey prompts, and to help with possible race conditions, _before_ the page loads and checks if the browser supports passkeys. The `success()` and `fail()` wrappers handle this coordination by:
132+
133+
1. Setting up user verification state
134+
2. Enabling presence simulation
135+
3. Listening for CDP events (credential added/asserted)
136+
4. Executing your trigger function
137+
5. Waiting for operation completion or failure
138+
139+
#### Basic Setup
140+
141+
Whichever page you're on that should support passkeys can extend from `PasskeyPage` instead of the `BaseLayout` page. This provides the framework for setting up and using the faked authentication.
142+
143+
```ts
144+
// change BaseLayout
145+
export class SigninPage extends BaseLayout {
146+
/** */
147+
}
148+
// to PasskeyPage
149+
export class SigninPage extends PasskeyPage {
150+
/** */
151+
}
152+
```
153+
154+
Initialize once per test via the `initPasskeys` function now available on the page. Call this as soon as possible, before navigating to the page if possible.
155+
156+
```ts
157+
test('passkey registration', async ({ pages: { page, signin } }) => {
158+
// Skip non-Chromium browsers
159+
test.skip(
160+
page.context().browser()?.browserType().name() !== 'chromium',
161+
'Passkeys tests run on chromium only'
162+
);
163+
164+
await signin.initPasskeys(page);
165+
// Now ready to test passkey operations
166+
});
167+
```
168+
169+
#### Testing successful passkey operations
170+
171+
Use `success()` to simulate a user approving the passkey prompt:
172+
173+
```ts
174+
// Do setup as describe above.
175+
// Wrap the action that triggers the passkey prompt
176+
await signin.passkeyAuth.success(async () => {
177+
return page.getByRole('button', { name: 'Sign in with passkey' }).click();
178+
});
179+
180+
// Verify success (e.g., check we're signed in)
181+
await expect(page.getByText('Welcome back')).toBeVisible();
182+
183+
// Optional: verify a credential was created
184+
const creds = await signin.passkeyAuth.getCredentials();
185+
expect(creds.length).toBeGreaterThan(0);
186+
```
187+
188+
#### Testing cancelled/failed passkey operations
189+
190+
Use `fail()` to simulate a user cancelling or failing verification. Since CDP doesn't emit failure events, you must provide a `postCheck` callback that waits for the failure to be processed:
191+
192+
```ts
193+
await signin.passkeyAuth.fail(
194+
async () => {
195+
// Trigger action that would show passkey prompt
196+
return page.getByRole('button', { name: 'Sign in with passkey' }).click();
197+
},
198+
async () => {
199+
// Wait for error message or use small timeout
200+
return expect(page.getByText('Authentication failed')).toBeVisible();
201+
}
202+
);
203+
204+
// Verify failure state (e.g., Error message)
205+
await expect(page.getByText('Failed to create Passkey')).toBeVisible();
206+
207+
// Optional: verify no credential was created
208+
const creds = await signin.passkeyAuth.getCredentials();
209+
expect(creds.length).toBe(0);
210+
```
211+
117212
### MFA Guard
118213

119214
To support increased security, there are multiple locations that have an MFA Guard wrapping them within settings. If your test needs to interact with or bypass this, there are two options.
@@ -255,6 +350,7 @@ When tests fail in CircleCI, the CI reporter automatically generates clickable t
255350
2. **Summary** - At the end of the test run with all failed test traces grouped together
256351

257352
Example output:
353+
258354
```
259355
[26/35] ❌ tests/settings/avatar.spec.ts: open and close avatar drop-down menu (19s)
260356
Error: Timed out 10000ms waiting for expect(locator).toBeVisible()
@@ -263,6 +359,7 @@ Error: Timed out 10000ms waiting for expect(locator).toBeVisible()
263359
```
264360

265361
The summary at the end groups traces by test (including retries):
362+
266363
```
267364
📊 Failed test traces:
268365
tests/settings/avatar.spec.ts: open and close avatar drop-down menu
@@ -271,6 +368,7 @@ The summary at the end groups traces by test (including retries):
271368
```
272369

273370
The reporter also tracks:
371+
274372
- **Progress**: `[5/35]` shows completed tests out of total
275373
- **Retries**: Total retry count and which attempt `(retry #1)`
276374
- **Flaky tests**: Tests that failed initially but passed on retry

packages/functional-tests/lib/fixtures/standard.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { create as createPages } from '../../pages';
1515
import { ServerTarget, TargetName, create } from '../targets';
1616
import { BaseTarget } from '../targets/base';
1717
import { TestAccountTracker } from '../testAccountTracker';
18+
import { PasskeyPage } from '../../pages/passkey';
1819
import { existsSync, readFileSync } from 'fs';
1920
import { join, dirname, basename } from 'path';
2021

@@ -47,6 +48,12 @@ export const test = base.extend<TestOptions, WorkerOptions>({
4748
pages: async ({ target, page }, use) => {
4849
const pages = createPages(page, target);
4950
await use(pages);
51+
// Cleanup passkey CDP sessions from any page that used them
52+
for (const pageInstance of Object.values(pages)) {
53+
if (pageInstance instanceof PasskeyPage) {
54+
await pageInstance.cleanupPasskeys();
55+
}
56+
}
5057
},
5158

5259
syncBrowserPages: async ({ target }, use, testInfo) => {
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import * as pwCore from 'playwright-core/types/protocol';
6+
import { CDPSession } from '@playwright/test';
7+
8+
const DEFAULT_OPTS: pwCore.Protocol.WebAuthn.VirtualAuthenticatorOptions = {
9+
protocol: 'ctap2',
10+
transport: 'internal',
11+
hasResidentKey: true,
12+
hasUserVerification: true,
13+
isUserVerified: true,
14+
automaticPresenceSimulation: false,
15+
};
16+
17+
type Trigger = () => Promise<void>;
18+
type PostCheck = () => Promise<void>;
19+
20+
export class PasskeyVirtualAuthenticator {
21+
private authenticatorId = '';
22+
23+
constructor(
24+
private readonly client: CDPSession,
25+
private readonly opts: pwCore.Protocol.WebAuthn.VirtualAuthenticatorOptions = DEFAULT_OPTS
26+
) {}
27+
28+
/**
29+
* Creates the virtual authenticator in the browser via CDP.
30+
*
31+
* Be sure to call this before using any other methods.
32+
* @param overrides
33+
* @returns
34+
*/
35+
async create(
36+
overrides?: Partial<pwCore.Protocol.WebAuthn.VirtualAuthenticatorOptions>
37+
) {
38+
await this.client.send('WebAuthn.enable');
39+
40+
const { authenticatorId } = await this.client.send(
41+
'WebAuthn.addVirtualAuthenticator',
42+
{
43+
options: {
44+
...this.opts,
45+
...overrides,
46+
automaticPresenceSimulation: false,
47+
},
48+
}
49+
);
50+
51+
this.authenticatorId = authenticatorId;
52+
return authenticatorId;
53+
}
54+
55+
/**
56+
* Removes the virtual authenticator from the browser via CDP.
57+
* @returns
58+
*/
59+
async dispose() {
60+
if (!this.authenticatorId) return;
61+
await this.client.send('WebAuthn.removeVirtualAuthenticator', {
62+
authenticatorId: this.authenticatorId,
63+
});
64+
this.authenticatorId = '';
65+
}
66+
67+
/**
68+
* Cleanup the virtual authenticator and detach the CDP session.
69+
* Safe to call even if not initialized - will silently skip.
70+
*/
71+
async cleanup() {
72+
await this.dispose();
73+
try {
74+
await this.client.send('WebAuthn.disable');
75+
} catch {
76+
// Ignore errors if already disabled
77+
}
78+
await this.client.detach();
79+
}
80+
81+
/**
82+
* Check how many credentials are stored in the virtual authenticator.
83+
*
84+
* Useful for verifying that a credential was created during registration,
85+
* or testing multiple registered credential flows.
86+
* @returns
87+
*/
88+
async getCredentials() {
89+
this.assertInit();
90+
const result = await this.client.send('WebAuthn.getCredentials', {
91+
authenticatorId: this.authenticatorId,
92+
});
93+
return result.credentials;
94+
}
95+
96+
/**
97+
* Simulate a *successful* passkey operation.
98+
*
99+
* Example:
100+
* ```ts
101+
* await passkeyAuth.success(async () => {
102+
* // Action that would show the Passkey prompt - this should return a promise
103+
* return signInPage.signinWithPasskey();
104+
* // event listener will resolve when operation completes
105+
* }
106+
* });
107+
* // then test asserts we're signed in or other success condition
108+
* ```
109+
* @param trigger
110+
*/
111+
async success(trigger: Trigger) {
112+
this.assertInit();
113+
114+
// Enable presence simulation FIRST, before anything else
115+
// This gives the browser maximum time to activate it
116+
await this.client.send('WebAuthn.setAutomaticPresenceSimulation', {
117+
authenticatorId: this.authenticatorId,
118+
enabled: true,
119+
});
120+
121+
const operationCompleted = this.waitForOneOf([
122+
'WebAuthn.credentialAdded',
123+
'WebAuthn.credentialAsserted',
124+
]);
125+
126+
await this.client.send('WebAuthn.setUserVerified', {
127+
authenticatorId: this.authenticatorId,
128+
isUserVerified: true,
129+
});
130+
131+
// Wait to ensure presence simulation is fully active
132+
await new Promise((resolve) => setTimeout(resolve, 500));
133+
134+
try {
135+
await trigger();
136+
await operationCompleted;
137+
} finally {
138+
await this.client.send('WebAuthn.setAutomaticPresenceSimulation', {
139+
authenticatorId: this.authenticatorId,
140+
enabled: false,
141+
});
142+
}
143+
}
144+
145+
/**
146+
* Simulate a *failed/cancelled* passkey operation.
147+
* There’s no CDP event for failure, so you must provide a post-check or use a small timeout.
148+
*
149+
* Example:
150+
* ```ts
151+
* await passkeyAuth.fail(async () => {
152+
* // Same as `success`, trigger an action that
153+
* // would show the Passkey prompt - this should return a promise
154+
* return signInPage.signinWithPasskey();
155+
* }, async () => {
156+
* // Return a promise that will resolve after the 'cancellation' is processed.
157+
* // This could be a small timeout, or an assertion that an error message is shown.
158+
* return expect(page.locator('#error-message')).toBeVisible();
159+
* });
160+
* // then test asserts we're still signed out or other failure condition
161+
* // or check the `getCredentials()` to verify no new credentials were created
162+
* ```
163+
*/
164+
async fail(trigger: Trigger, postCheck: PostCheck) {
165+
this.assertInit();
166+
167+
// Enable presence simulation FIRST, before anything else
168+
await this.client.send('WebAuthn.setAutomaticPresenceSimulation', {
169+
authenticatorId: this.authenticatorId,
170+
enabled: true,
171+
});
172+
173+
await this.client.send('WebAuthn.setUserVerified', {
174+
authenticatorId: this.authenticatorId,
175+
isUserVerified: false,
176+
});
177+
178+
// Wait to ensure presence simulation is fully active
179+
await new Promise((resolve) => setTimeout(resolve, 500));
180+
181+
try {
182+
await trigger();
183+
await postCheck();
184+
} finally {
185+
await this.client.send('WebAuthn.setAutomaticPresenceSimulation', {
186+
authenticatorId: this.authenticatorId,
187+
enabled: false,
188+
});
189+
}
190+
}
191+
192+
private waitForOneOf(
193+
events: Array<'WebAuthn.credentialAdded' | 'WebAuthn.credentialAsserted'>
194+
) {
195+
return new Promise<void>((resolve) => {
196+
let done = false;
197+
198+
const handlers = events.map(() => {
199+
const handler = () => {
200+
if (done) return;
201+
done = true;
202+
// remove all handlers when one fires
203+
for (let i = 0; i < events.length; i++) {
204+
this.client.off(events[i], handlers[i]);
205+
}
206+
resolve();
207+
};
208+
return handler;
209+
});
210+
211+
for (let i = 0; i < events.length; i++) {
212+
this.client.on(events[i], handlers[i]);
213+
}
214+
});
215+
}
216+
217+
private assertInit() {
218+
if (!this.authenticatorId) {
219+
throw new Error(
220+
'PasskeyVirtualAuthenticator not initialized. Call init() first.'
221+
);
222+
}
223+
}
224+
}

packages/functional-tests/pages/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import { TotpPage } from './settings/totp';
3737
import { InlineRecoveryKey } from './inlineRecoveryKey';
3838
import { SignupConfirmedSyncPage } from './signupConfirmedSync';
3939
import { InlineTotpSetupPage } from './inlineTotpSetup';
40+
import { PasskeyExamplePage } from './passkey';
41+
42+
export { PasskeyPage } from './passkey';
4043

4144
export function create(page: Page, target: BaseTarget) {
4245
return {
@@ -74,5 +77,6 @@ export function create(page: Page, target: BaseTarget) {
7477
signupConfirmedSync: new SignupConfirmedSyncPage(page, target),
7578
termsOfService: new TermsOfService(page, target),
7679
totp: new TotpPage(page, target),
80+
passkeysExample: new PasskeyExamplePage(page, target),
7781
};
7882
}

0 commit comments

Comments
 (0)