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,114 @@ 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 ) ;
150-
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' ) ;
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 ) ;
157181 await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
158-
159- const { credentials } = await cdp . send ( 'WebAuthn.getCredentials' , { authenticatorId } ) ;
160- expect ( credentials . length ) . toBeGreaterThan ( 0 ) ;
161182 } ) ;
183+ } ) ;
162184
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.
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 ( ) => {
166194 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' ) ;
167198
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' ) ;
171-
172- const conditionalInput = page . locator ( 'input[autocomplete="webauthn"]' ) ;
173- await expect ( conditionalInput ) . toBeVisible ( { timeout : 10000 } ) ;
174- await conditionalInput . focus ( ) ;
175- await expect ( conditionalInput ) . toBeFocused ( ) ;
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+ } ) ;
176203
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- } ) ;
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' ) ;
182217
183- // With a virtual authenticator configured for automatic presence simulation, this should
184- // complete without any manual click.
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 ) ;
185220 await expect ( page . getByRole ( 'button' , { name : 'Logout' } ) ) . toBeVisible ( ) ;
186- await expect ( page . getByRole ( 'heading' , { name : 'Complete' } ) ) . toBeVisible ( ) ;
221+ } ) ;
222+ } ) ;
223+
224+ // 110: autocomplete values, conditional mediation, no button
225+ // Both signals for passkey autofill are present. Silent authentication, no manual button.
226+ test ( 'autocomplete + conditional (110) — silent passkey auth, no manual button' , async ( {
227+ page,
228+ } ) => {
229+ const { clickButton, navigate } = asyncEvents ( page ) ;
230+ await test . step ( 'Register' , async ( ) => {
231+ await registerPasskey ( page , navigate , clickButton ) ;
232+ } ) ;
233+ await test . step ( 'Authenticate' , async ( ) => {
234+ await page . context ( ) . clearCookies ( ) ;
235+ // Hold the fired conditional request open so the rendered autofill UI can be asserted.
236+ // Silent completion depends on real autofill-dropdown interaction that the virtual
237+ // authenticator cannot drive deterministically, so we assert the rendered UI only.
238+ await setPresenceSimulation ( false ) ;
239+ await navigate (
240+ '/?clientId=tenant&journey=TEST_AutofillPasskeyWebAuthn_autocomplete_conditional' ,
241+ ) ;
242+
243+ await expect ( page . locator ( 'input[autocomplete="username webauthn"]' ) ) . toBeVisible ( ) ;
244+ await expect ( page . getByRole ( 'button' , { name : 'Sign in with a passkey' } ) ) . toHaveCount ( 0 ) ;
187245 } ) ;
188246 } ) ;
189247} ) ;
0 commit comments