Skip to content

Commit 07bfe39

Browse files
authored
Fix flaky E2E tests (#4755)
* Increase timeouts, to reduce flakiness * Add waitFor... helpers to the payment utils * Refactor ACH tests to use waitFor helpers * Fix ACH shortcode tests * Fix ACH block tests
1 parent 8db98b6 commit 07bfe39

File tree

4 files changed

+168
-51
lines changed

4 files changed

+168
-51
lines changed

tests/e2e/config/playwright.config.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ const config = {
1818
testDir: '../tests',
1919

2020
// Maximum time one test can run for
21-
timeout: TIMEOUT ? Number( TIMEOUT ) : 90 * 1000,
21+
// Increased from 90s to 120s to reduce flakiness with Stripe iframe/modal flow.
22+
timeout: TIMEOUT ? Number( TIMEOUT ) : 120 * 1000,
2223

2324
expect: {
2425
// Maximum time expect() should wait for the condition to be met
2526
// For example in `await expect(locator).toHaveText();`
26-
timeout: 20 * 1000,
27+
// Increased from 20s to 30s to reduce flakiness with Stripe iframe/modal interactions.
28+
timeout: 30 * 1000,
2729
},
2830

2931
// Folder for test artifacts such as screenshots, videos, traces, etc
@@ -69,6 +71,9 @@ const config = {
6971
video: 'on-first-retry',
7072

7173
viewport: { width: 1280, height: 720 },
74+
75+
// Maximum time for individual actions (click, fill, etc.)
76+
actionTimeout: 15 * 1000,
7277
},
7378

7479
projects: [

tests/e2e/tests/checkout/blocks/lpms/ach.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ test.describe( 'ACH payment tests @blocks', () => {
4747
} ) => {
4848
await setupACHCheckout( page, 'blocks' );
4949
await fillACHBankDetails( page );
50+
5051
await page.locator( 'text=Place order' ).click();
5152
await page.waitForURL( '**/checkout/order-received/**' );
5253
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
@@ -71,6 +72,7 @@ test.describe( 'ACH payment tests @blocks', () => {
7172
'.wc-block-components-payment-methods__save-card-info'
7273
)
7374
.click();
75+
7476
await page.locator( 'text=Place order' ).click();
7577
await page.waitForURL( '**/checkout/order-received/**' );
7678
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
@@ -90,6 +92,7 @@ test.describe( 'ACH payment tests @blocks', () => {
9092
.locator( 'label' )
9193
.filter( { hasText: 'Checking account ending in' } )
9294
.click();
95+
9396
await page.locator( 'text=Place order' ).click();
9497
await page.waitForURL( '**/checkout/order-received/**' );
9598
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(

tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ const {
88
emptyCart,
99
setupCart,
1010
setupShortcodeCheckout,
11-
fillACHBankDetails,
1211
setupACHCheckout,
12+
fillACHBankDetails,
1313
} = payments;
1414

1515
test.describe( 'ACH payment tests @shortcode', () => {
@@ -48,6 +48,7 @@ test.describe( 'ACH payment tests @shortcode', () => {
4848
} ) => {
4949
await setupACHCheckout( page, 'shortcode' );
5050
await fillACHBankDetails( page );
51+
5152
await page.locator( 'text=Place order' ).click();
5253
await page.waitForURL( '**/checkout/order-received/**' );
5354
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
@@ -67,11 +68,13 @@ test.describe( 'ACH payment tests @shortcode', () => {
6768
);
6869
await setupACHCheckout( page, 'shortcode' );
6970
await fillACHBankDetails( page );
71+
7072
await page
7173
.getByRole( 'checkbox', {
7274
name: 'Save payment information to',
7375
} )
7476
.click();
77+
7578
await clickPlaceOrder( page );
7679
await page.waitForURL( '**/checkout/order-received/**' );
7780
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
@@ -97,6 +100,7 @@ test.describe( 'ACH payment tests @shortcode', () => {
97100
.locator( '.woocommerce-SavedPaymentMethods-token' )
98101
.first()
99102
.click();
103+
100104
await clickPlaceOrder( page );
101105
await page.waitForURL( '**/checkout/order-received/**' );
102106
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(

tests/e2e/utils/payments.js

Lines changed: 153 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)