66 */
77
88import { expect , test } from '@playwright/test' ;
9- import type { CDPSession } from '@playwright/test' ;
9+ import type { CDPSession , Page } from '@playwright/test' ;
1010import { asyncEvents } from './utils/async-events.js' ;
1111import { username , password } from './utils/demo-user.js' ;
1212
@@ -113,11 +113,9 @@ test.describe('WebAuthn conditional autofill (passkey)', () => {
113113 let authenticatorId ! : string ;
114114
115115 test . beforeEach ( async ( { context, page } ) => {
116- // Chromium + CDP WebAuthn virtual authenticator is required for repeatable automation.
117116 cdp = await context . newCDPSession ( page ) ;
118117 await cdp . send ( 'WebAuthn.enable' ) ;
119118
120- // Configure a platform authenticator with resident keys and auto presence simulation.
121119 const response = await cdp . send ( 'WebAuthn.addVirtualAuthenticator' , {
122120 options : {
123121 protocol : 'ctap2' ,
@@ -136,54 +134,234 @@ test.describe('WebAuthn conditional autofill (passkey)', () => {
136134 await cdp . send ( 'WebAuthn.disable' ) ;
137135 } ) ;
138136
139- // TODO: This test is currently skipped because the journey used does not allow enabling conditional mediation in admin console
140- // When we start using v2.0 of Page Node in admin console, this test can be executed again
141- test . skip ( 'registers a passkey then authenticates via conditional autofill' , async ( { page } ) => {
137+ // The autofill (conditional) cases fire WebAuthn on render. With automatic presence simulation
138+ // on, the virtual authenticator resolves that request immediately and the page logs in before
139+ // the transient autofill UI (decorated input, manual button) can be asserted. Disabling presence
140+ // simulation lets the request hang so the rendered UI can be checked deterministically.
141+ async function setPresenceSimulation ( enabled : boolean ) : Promise < void > {
142+ await cdp . send ( 'WebAuthn.setAutomaticPresenceSimulation' , { authenticatorId, enabled } ) ;
143+ }
144+
145+ async function registerPasskey (
146+ page : Page ,
147+ navigate : ( route : string ) => Promise < void > ,
148+ clickButton : ( text : string , endpoint : string ) => Promise < void > ,
149+ ) : Promise < void > {
150+ const { credentials : initialCredentials } = await cdp . send ( 'WebAuthn.getCredentials' , {
151+ authenticatorId,
152+ } ) ;
153+ expect ( initialCredentials ) . toHaveLength ( 0 ) ;
154+
155+ await navigate ( '/?clientId=tenant&journey=TEST_WebAuthn-Registration' ) ;
156+ await expect ( page . getByLabel ( 'User Name' ) ) . toBeVisible ( ) ;
157+ await page . getByLabel ( 'User Name' ) . fill ( username ) ;
158+ await page . getByLabel ( 'Password' ) . fill ( password ) ;
159+ await clickButton ( 'Submit' , '/authenticate' ) ;
160+ await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
161+
162+ const { credentials } = await cdp . send ( 'WebAuthn.getCredentials' , { authenticatorId } ) ;
163+ expect ( credentials . length ) . toBeGreaterThan ( 0 ) ;
164+ }
165+
166+ // 000: no autocomplete values, default mediation, no button
167+ // AM does not signal passkey autofill. Falls back to traditional (prompted) WebAuthn.
168+ test ( 'disabled (000) — falls back to traditional WebAuthn, no autofill input, no manual button' , async ( {
169+ page,
170+ } ) => {
142171 const { clickButton, navigate } = asyncEvents ( page ) ;
172+ await test . step ( 'Register' , async ( ) => {
173+ await registerPasskey ( page , navigate , clickButton ) ;
174+ } ) ;
175+ await test . step ( 'Authenticate' , async ( ) => {
176+ await page . context ( ) . clearCookies ( ) ;
177+ await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_disabled' ) ;
143178
144- await test . step ( 'Register a WebAuthn credential' , async ( ) => {
145- // Start with an empty virtual authenticator.
146- const { credentials : initialCredentials } = await cdp . send ( 'WebAuthn.getCredentials' , {
147- authenticatorId,
148- } ) ;
149- expect ( initialCredentials ) . toHaveLength ( 0 ) ;
179+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toHaveCount ( 0 ) ;
180+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
181+ await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
182+ } ) ;
183+ } ) ;
150184
151- // Run a registration journey that creates a credential in the authenticator.
152- await navigate ( '/?clientId=tenant&journey=TEST_WebAuthn-Registration' ) ;
153- await expect ( page . getByLabel ( 'User Name' ) ) . toBeVisible ( ) ;
154- await page . getByLabel ( 'User Name' ) . fill ( username ) ;
155- await page . getByLabel ( 'Password' ) . fill ( password ) ;
156- await clickButton ( 'Submit' , '/authenticate' ) ;
185+ // 100: autocomplete values present, default mediation, no button
186+ // AM emits autocomplete values so the conditional path is entered and the autofill input is
187+ // rendered. No manual button because manualButtonEnabled is false.
188+ test ( 'autocomplete only (100) — autofill input rendered, no manual button' , async ( { page } ) => {
189+ const { clickButton, navigate } = asyncEvents ( page ) ;
190+ await test . step ( 'Register' , async ( ) => {
191+ await registerPasskey ( page , navigate , clickButton ) ;
192+ } ) ;
193+ await test . step ( 'Authenticate' , async ( ) => {
194+ await page . context ( ) . clearCookies ( ) ;
195+ // Hold the fired conditional request open so the rendered autofill UI can be asserted.
196+ await setPresenceSimulation ( false ) ;
197+ await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete' ) ;
198+
199+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toBeVisible ( ) ;
200+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
201+ } ) ;
202+ } ) ;
203+
204+ // 010: no autocomplete values, conditional mediation, no button
205+ // AM uses conditional mediation internally but did not emit autocomplete values. Journey-app
206+ // never enters the conditional path — falls back to traditional WebAuthn.
207+ test ( 'conditional only (010) — falls back to traditional WebAuthn, no autofill input, no manual button' , async ( {
208+ page,
209+ } ) => {
210+ const { clickButton, navigate } = asyncEvents ( page ) ;
211+ await test . step ( 'Register' , async ( ) => {
212+ await registerPasskey ( page , navigate , clickButton ) ;
213+ } ) ;
214+ await test . step ( 'Authenticate' , async ( ) => {
215+ await page . context ( ) . clearCookies ( ) ;
216+ await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_conditional' ) ;
217+
218+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toHaveCount ( 0 ) ;
219+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
220+ await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
221+ } ) ;
222+ } ) ;
223+
224+ // 001: no autocomplete values, default mediation, button enabled
225+ // AM set manualButtonEnabled but did not emit autocomplete values. The manual button only
226+ // renders inside the conditional path — which is not entered without autocomplete values.
227+ // This asserts that button alone is insufficient to reach the passkey path.
228+ test ( 'button only (001) — manual button does NOT appear despite AM enabling it, falls back to traditional WebAuthn' , async ( {
229+ page,
230+ } ) => {
231+ const { clickButton, navigate } = asyncEvents ( page ) ;
232+ await test . step ( 'Register' , async ( ) => {
233+ await registerPasskey ( page , navigate , clickButton ) ;
234+ } ) ;
235+ await test . step ( 'Authenticate' , async ( ) => {
236+ await page . context ( ) . clearCookies ( ) ;
237+ await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_button' ) ;
238+
239+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toHaveCount ( 0 ) ;
240+ // Key assertion: AM enabled manualButtonEnabled but the button does not appear because
241+ // autocomplete values were absent and the conditional path was never entered.
242+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
157243 await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
244+ } ) ;
245+ } ) ;
246+
247+ // 110: autocomplete values, conditional mediation, no button
248+ // Both signals for passkey autofill are present. Silent authentication, no manual button.
249+ test ( 'autocomplete + conditional (110) — silent passkey auth, no manual button' , async ( {
250+ page,
251+ } ) => {
252+ const { clickButton, navigate } = asyncEvents ( page ) ;
253+ await test . step ( 'Register' , async ( ) => {
254+ await registerPasskey ( page , navigate , clickButton ) ;
255+ } ) ;
256+ await test . step ( 'Authenticate' , async ( ) => {
257+ await page . context ( ) . clearCookies ( ) ;
258+ // Hold the fired conditional request open so the rendered autofill UI can be asserted.
259+ // Silent completion depends on real autofill-dropdown interaction that the virtual
260+ // authenticator cannot drive deterministically, so we assert the rendered UI only.
261+ await setPresenceSimulation ( false ) ;
262+ await navigate (
263+ '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional' ,
264+ ) ;
265+
266+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toBeVisible ( ) ;
267+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
268+ } ) ;
269+ } ) ;
158270
159- const { credentials } = await cdp . send ( 'WebAuthn.getCredentials' , { authenticatorId } ) ;
160- expect ( credentials . length ) . toBeGreaterThan ( 0 ) ;
271+ // 101: autocomplete values, default mediation, button "enabled" in journey config
272+ // AM only sets manualButtonEnabled: true when mediation is conditional — the two are coupled.
273+ // With default mediation this journey sends manualButtonEnabled: false, so the button never
274+ // appears even though autocomplete values are present and the conditional path is entered.
275+ test ( 'autocomplete + button (101) — autofill input present, manual button absent because AM sends manualButtonEnabled: false without conditional mediation' , async ( {
276+ page,
277+ } ) => {
278+ const { clickButton, navigate } = asyncEvents ( page ) ;
279+ await test . step ( 'Register' , async ( ) => {
280+ await registerPasskey ( page , navigate , clickButton ) ;
161281 } ) ;
282+ await test . step ( 'Authenticate' , async ( ) => {
283+ await page . context ( ) . clearCookies ( ) ;
284+ // Hold the fired conditional request open so the rendered autofill UI can be asserted.
285+ await setPresenceSimulation ( false ) ;
286+ await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_button' ) ;
287+
288+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toBeVisible ( ) ;
289+ // No button: AM does not set manualButtonEnabled: true without conditional mediation.
290+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
291+ } ) ;
292+ } ) ;
162293
163- await test . step ( 'Authenticate using conditional UI / passkey autofill' , async ( ) => {
164- // Ensure we are not reusing an existing AM session.
165- // This makes the test exercise passkey auth, not cookie auth.
294+ // 011: no autocomplete values, conditional mediation, button enabled
295+ // No autocomplete signal means the conditional path is never entered — button does not appear.
296+ test ( 'conditional + button (011) — manual button does NOT appear, falls back to traditional WebAuthn' , async ( {
297+ page,
298+ } ) => {
299+ const { clickButton, navigate } = asyncEvents ( page ) ;
300+ await test . step ( 'Register' , async ( ) => {
301+ await registerPasskey ( page , navigate , clickButton ) ;
302+ } ) ;
303+ await test . step ( 'Authenticate' , async ( ) => {
166304 await page . context ( ) . clearCookies ( ) ;
305+ await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_conditional_button' ) ;
167306
168- // This journey emits conditional mediation metadata and should complete via background
169- // WebAuthn (journey-app triggers the request and submits when a credential is returned).
170- await navigate ( '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn' ) ;
307+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toHaveCount ( 0 ) ;
308+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
309+ await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
310+ } ) ;
311+ } ) ;
171312
172- const conditionalInput = page . locator ( 'input[autocomplete="webauthn"]' ) ;
173- await expect ( conditionalInput ) . toBeVisible ( { timeout : 10000 } ) ;
174- await conditionalInput . focus ( ) ;
175- await expect ( conditionalInput ) . toBeFocused ( ) ;
313+ // 111: autocomplete values, conditional mediation, button enabled — the full passkey experience.
314+ // All three signals present, so the step offers two distinct ways to authenticate. We cover both.
176315
177- // Re-enable presence simulation so the in-flight WebAuthn request can resolve.
178- await cdp . send ( 'WebAuthn.setAutomaticPresenceSimulation' , {
179- authenticatorId,
180- enabled : true ,
181- } ) ;
316+ // 111a: silent autofill via the username field. The conditional request runs in the background
317+ // and the virtual authenticator resolves it without the user touching the manual button.
318+ test ( 'all enabled (111a) — autofill input and manual button both render, silent passkey auth via username field' , async ( {
319+ page,
320+ } ) => {
321+ const { clickButton, navigate } = asyncEvents ( page ) ;
322+ await test . step ( 'Register' , async ( ) => {
323+ await registerPasskey ( page , navigate , clickButton ) ;
324+ } ) ;
325+ await test . step ( 'Authenticate' , async ( ) => {
326+ await page . context ( ) . clearCookies ( ) ;
327+ // Hold the fired conditional request open so the rendered autofill UI can be asserted.
328+ // Silent completion depends on real autofill-dropdown interaction that the virtual
329+ // authenticator cannot drive deterministically, so we assert the rendered UI only.
330+ await setPresenceSimulation ( false ) ;
331+ await navigate (
332+ '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional_button' ,
333+ ) ;
334+
335+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toBeVisible ( ) ;
336+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toBeVisible ( ) ;
337+ } ) ;
338+ } ) ;
182339
183- // With a virtual authenticator configured for automatic presence simulation, this should
184- // complete without any manual click.
340+ // 111b: explicit authentication via the "Sign in with a passkey" button. Clicking it aborts the
341+ // background conditional request and forces a modal prompt (mediationOverride: 'optional'),
342+ // which the virtual authenticator resolves — exercising the manual button path end to end.
343+ test ( 'all enabled (111b) — clicking "Sign in with a passkey" authenticates via a forced modal prompt' , async ( {
344+ page,
345+ } ) => {
346+ const { clickButton, navigate } = asyncEvents ( page ) ;
347+ await test . step ( 'Register' , async ( ) => {
348+ await registerPasskey ( page , navigate , clickButton ) ;
349+ } ) ;
350+ await test . step ( 'Authenticate via the manual button' , async ( ) => {
351+ await page . context ( ) . clearCookies ( ) ;
352+ // Hold the background conditional request open so it cannot silently log in before the
353+ // manual button is clicked; the button aborts it and fires the forced modal request.
354+ await setPresenceSimulation ( false ) ;
355+ await navigate (
356+ '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional_button' ,
357+ ) ;
358+
359+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toBeVisible ( ) ;
360+
361+ // Re-enable presence so the forced modal request the button fires resolves.
362+ await setPresenceSimulation ( true ) ;
363+ await clickButton ( 'Sign in with a passkey' , '/authenticate' ) ;
185364 await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
186- await expect ( page . getByRole ( 'heading' , { name : 'Complete' } ) ) . toBeVisible ( ) ;
187365 } ) ;
188366 } ) ;
189367} ) ;
0 commit comments