@@ -90,6 +90,105 @@ export async function setupCart(
9090 }
9191}
9292
93+ /**
94+ * Wait for Stripe iframe to be fully loaded and ready for interaction.
95+ * This helper addresses common race conditions with Stripe Elements.
96+ *
97+ * @param {Page } page Playwright page fixture.
98+ * @param {string } iframeSelector The selector for the Stripe iframe.
99+ * @param {number } timeout Maximum time to wait in milliseconds (default: 15000).
100+ * @returns {Promise<Frame> } The loaded Stripe frame.
101+ */
102+ export async function waitForStripeReady (
103+ page ,
104+ iframeSelector ,
105+ timeout = 15000
106+ ) {
107+ // Wait for iframe to be present and visible
108+ await page . waitForSelector ( iframeSelector , {
109+ state : 'visible' ,
110+ timeout,
111+ } ) ;
112+
113+ // Get the frame handle and content frame
114+ const frameHandle = await page . waitForSelector ( iframeSelector , {
115+ timeout,
116+ } ) ;
117+ const stripeFrame = await frameHandle . contentFrame ( ) ;
118+
119+ if ( ! stripeFrame ) {
120+ throw new Error (
121+ `Could not get content frame for: ${ iframeSelector } `
122+ ) ;
123+ }
124+
125+ // Wait for the frame to be fully loaded
126+ await stripeFrame . waitForLoadState ( 'networkidle' , { timeout } ) ;
127+
128+ // Additional wait for any loading indicators to disappear in parallel
129+ const loadingIndicators = [
130+ '.__PrivateStripeElementLoader' ,
131+ '.LightboxModalLoadingIndicator' ,
132+ '[data-testid="loading"]' ,
133+ ] ;
134+
135+ await Promise . all (
136+ loadingIndicators . map ( ( indicator ) =>
137+ stripeFrame
138+ . locator ( indicator )
139+ . waitFor ( { state : 'hidden' , timeout } )
140+ . catch ( ( ) => { } )
141+ )
142+ ) ;
143+
144+ return stripeFrame ;
145+ }
146+
147+ /**
148+ * Retry an async function with exponential backoff.
149+ * Useful for flaky operations like iframe interactions or API calls.
150+ *
151+ * @param {Function } fn The async function to retry.
152+ * @param {Object } options Retry configuration.
153+ * @param {number } options.maxRetries Maximum number of retries (default: 3).
154+ * @param {number } options.initialDelay Initial delay in milliseconds (default: 500).
155+ * @param {number } options.maxDelay Maximum delay in milliseconds (default: 5000).
156+ * @param {Function } options.shouldRetry Optional function to determine if error should trigger retry.
157+ * @returns {Promise<any> } The result of the function call.
158+ */
159+ export async function retryWithBackoff ( fn , options = { } ) {
160+ const {
161+ maxRetries = 3 ,
162+ initialDelay = 500 ,
163+ maxDelay = 5000 ,
164+ shouldRetry = ( ) => true ,
165+ } = options ;
166+
167+ let lastError ;
168+ let delay = initialDelay ;
169+
170+ for ( let attempt = 0 ; attempt <= maxRetries ; attempt ++ ) {
171+ try {
172+ return await fn ( ) ;
173+ } catch ( error ) {
174+ lastError = error ;
175+
176+ // Don't retry if we've exhausted attempts or if shouldRetry returns false
177+ if ( attempt === maxRetries || ! shouldRetry ( error ) ) {
178+ break ;
179+ }
180+
181+ // Wait before retrying
182+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
183+
184+ // Exponential backoff with max delay cap
185+ delay = Math . min ( delay * 2 , maxDelay ) ;
186+ }
187+ }
188+
189+ throw lastError ;
190+ }
191+
93192/**
94193 * Fills in the credit card details on the default (blocks) checkout page.
95194 * @param {Page } page Playwright page fixture.
@@ -416,32 +515,25 @@ export const setupACHCheckout = async ( page, checkoutType = 'blocks' ) => {
416515 await emptyCart ( page ) ;
417516 await setupCart ( page ) ;
418517
518+ const rawIframeSelector = 'iframe[src*="elements-inner-payment"]' ;
519+ let iframeSelector ;
520+
419521 if ( checkoutType === 'blocks' ) {
522+ iframeSelector = `#radio-control-wc-payment-method-options-stripe_us_bank_account__content ${ rawIframeSelector } ` ;
523+
420524 await setupBlocksCheckout (
421525 page ,
422526 config . get ( 'addresses.customer.billing' )
423527 ) ;
528+
424529 // Select ACH in blocks checkout
425530 await page
426531 . locator ( 'label' )
427532 . filter ( { hasText : 'ACH Direct Debit' } )
428- . dispatchEvent ( 'click' ) ;
429-
430- // Wait for the iframe to be ready
431- const frameHandle = await page . waitForSelector (
432- '#radio-control-wc-payment-method-options-stripe_us_bank_account__content iframe[name^="__privateStripeFrame"]'
433- ) ;
434- const stripeFrame = await frameHandle . contentFrame ( ) ;
435- await stripeFrame . waitForLoadState ( 'networkidle' ) ;
436-
437- // Click "Test Institution"
438- await page
439- . frameLocator (
440- '#radio-control-wc-payment-method-options-stripe_us_bank_account__content iframe[src*="elements-inner-payment"]'
441- )
442- . getByText ( 'Test Institution' )
443- . dispatchEvent ( 'click' ) ;
533+ . click ( ) ;
444534 } else {
535+ iframeSelector = `.wc_payment_method.payment_method_stripe_us_bank_account ${ rawIframeSelector } ` ;
536+
445537 await setupShortcodeCheckout (
446538 page ,
447539 config . get ( 'addresses.customer.billing' )
@@ -450,23 +542,21 @@ export const setupACHCheckout = async ( page, checkoutType = 'blocks' ) => {
450542 // Select ACH in shortcode checkout
451543 const achLabel = page . getByText ( 'ACH Direct Debit' ) ;
452544 await achLabel . waitFor ( { state : 'visible' } ) ;
453- await achLabel . dispatchEvent ( 'click' ) ;
545+ await achLabel . click ( ) ;
546+ }
454547
455- // Wait for the iframe to be ready
456- const frameHandle = await page . waitForSelector (
457- '.payment_method_stripe_us_bank_account iframe[name^="__privateStripeFrame"]'
458- ) ;
459- const stripeFrame = await frameHandle . contentFrame ( ) ;
460- await stripeFrame . waitForLoadState ( 'networkidle' ) ;
548+ await waitForStripeReady ( page , iframeSelector ) ;
461549
462- // Click "Test Institution"
463- await page
464- . frameLocator (
465- '.wc_payment_method.payment_method_stripe_us_bank_account iframe[src*="elements-inner-payment"]'
466- )
467- . getByTestId ( 'featured-institution-default' )
468- . dispatchEvent ( 'click' ) ;
469- }
550+ // Click "Test Institution" with retry logic
551+ await retryWithBackoff ( async ( ) => {
552+ const testInstitutionButton = page
553+ . frameLocator ( iframeSelector )
554+ . getByText ( 'Test Institution' )
555+ . first ( ) ;
556+
557+ await expect ( testInstitutionButton ) . toBeVisible ( ) ;
558+ await testInstitutionButton . click ( ) ;
559+ } ) ;
470560} ;
471561
472562/**
@@ -478,34 +568,49 @@ export const fillACHBankDetails = async ( page ) => {
478568 . frameLocator ( 'iframe[name^="__privateStripeFrame"]' )
479569 . first ( ) ;
480570
481- // Agree and Continue
482- await frame . getByTestId ( 'agree-button' ) . click ( ) ;
483-
484- // Click "Success ••••" button
485- await frame . getByRole ( 'button' , { name : 'Success ••••' } ) . click ( ) ;
486-
487- // Click "Connect Account" button.
488- await frame . getByTestId ( 'select-button' ) . click ( ) ;
571+ // Click Agree and Continue button
572+ let button = frame . getByTestId ( 'agree-button' ) ;
573+ await expect ( button ) . toBeVisible ( ) ;
574+ await button . click ( ) ;
489575
490576 // Link registration button may or may not appear.
491577 await Promise . race ( [
492578 frame
493579 . getByTestId ( 'link-not-now-button' )
494- . waitFor ( {
495- state : 'visible' ,
496- timeout : 5000 ,
497- } )
580+ . waitFor ( { state : 'visible' } )
498581 . then ( async ( ) => {
499582 await frame . getByTestId ( 'link-not-now-button' ) . click ( ) ;
500583 } ) ,
584+ frame
585+ . getByRole ( 'button' , { name : 'Success ••••' } )
586+ . waitFor ( { state : 'visible' } ) ,
587+ ] ) ;
501588
502- frame . getByTestId ( 'done-button' ) . waitFor ( {
503- state : 'visible' ,
504- timeout : 5000 ,
505- } ) ,
589+ // Click "Success ••••" account
590+ button = frame . getByRole ( 'button' , { name : 'Success ••••' } ) ;
591+ await expect ( button ) . toBeVisible ( ) ;
592+ await button . click ( ) ;
593+
594+ // Click Connect account button
595+ button = frame . getByTestId ( 'select-button' ) ;
596+ await expect ( button ) . toBeVisible ( ) ;
597+ await button . click ( ) ;
598+
599+ // If link registration did not load when starting the flow, it will appear here.
600+ await Promise . race ( [
601+ frame
602+ . getByTestId ( 'link-not-now-button' )
603+ . waitFor ( { state : 'visible' } )
604+ . then ( async ( ) => {
605+ await frame . getByTestId ( 'link-not-now-button' ) . click ( ) ;
606+ } ) ,
607+ frame . getByTestId ( 'done-button' ) . waitFor ( { state : 'visible' } ) ,
506608 ] ) ;
507609
508- await frame . getByTestId ( 'done-button' ) . click ( ) ;
610+ // Click the done button with retry logic
611+ button = frame . getByTestId ( 'done-button' ) ;
612+ await expect ( button ) . toBeVisible ( ) ;
613+ await button . click ( ) ;
509614} ;
510615
511616/**
0 commit comments