Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions tests/e2e/config/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ const config = {
testDir: '../tests',

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

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

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

viewport: { width: 1280, height: 720 },

// Maximum time for individual actions (click, fill, etc.)
actionTimeout: 15 * 1000,
},

projects: [
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/tests/checkout/blocks/lpms/ach.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ test.describe( 'ACH payment tests @blocks', () => {
} ) => {
await setupACHCheckout( page, 'blocks' );
await fillACHBankDetails( page );

await page.locator( 'text=Place order' ).click();
await page.waitForURL( '**/checkout/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
Expand All @@ -71,6 +72,7 @@ test.describe( 'ACH payment tests @blocks', () => {
'.wc-block-components-payment-methods__save-card-info'
)
.click();

await page.locator( 'text=Place order' ).click();
await page.waitForURL( '**/checkout/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
Expand All @@ -90,6 +92,7 @@ test.describe( 'ACH payment tests @blocks', () => {
.locator( 'label' )
.filter( { hasText: 'Checking account ending in' } )
.click();

await page.locator( 'text=Place order' ).click();
await page.waitForURL( '**/checkout/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
Expand Down
6 changes: 5 additions & 1 deletion tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ const {
emptyCart,
setupCart,
setupShortcodeCheckout,
fillACHBankDetails,
setupACHCheckout,
fillACHBankDetails,
} = payments;

test.describe( 'ACH payment tests @shortcode', () => {
Expand Down Expand Up @@ -48,6 +48,7 @@ test.describe( 'ACH payment tests @shortcode', () => {
} ) => {
await setupACHCheckout( page, 'shortcode' );
await fillACHBankDetails( page );

await page.locator( 'text=Place order' ).click();
await page.waitForURL( '**/checkout/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
Expand All @@ -67,11 +68,13 @@ test.describe( 'ACH payment tests @shortcode', () => {
);
await setupACHCheckout( page, 'shortcode' );
await fillACHBankDetails( page );

await page
.getByRole( 'checkbox', {
name: 'Save payment information to',
} )
.click();

await clickPlaceOrder( page );
await page.waitForURL( '**/checkout/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
Expand All @@ -97,6 +100,7 @@ test.describe( 'ACH payment tests @shortcode', () => {
.locator( '.woocommerce-SavedPaymentMethods-token' )
.first()
.click();

await clickPlaceOrder( page );
await page.waitForURL( '**/checkout/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
Expand Down
201 changes: 153 additions & 48 deletions tests/e2e/utils/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,105 @@
}
}

/**
* Wait for Stripe iframe to be fully loaded and ready for interaction.
* This helper addresses common race conditions with Stripe Elements.
*
* @param {Page} page Playwright page fixture.
* @param {string} iframeSelector The selector for the Stripe iframe.
* @param {number} timeout Maximum time to wait in milliseconds (default: 15000).
* @returns {Promise<Frame>} The loaded Stripe frame.
*/
export async function waitForStripeReady(
page,
iframeSelector,
timeout = 15000
) {
// Wait for iframe to be present and visible
await page.waitForSelector( iframeSelector, {
state: 'visible',
timeout,
} );

// Get the frame handle and content frame
const frameHandle = await page.waitForSelector( iframeSelector, {
timeout,
} );
const stripeFrame = await frameHandle.contentFrame();

if ( ! stripeFrame ) {
throw new Error(
`Could not get content frame for: ${ iframeSelector }`
);
}

// Wait for the frame to be fully loaded
await stripeFrame.waitForLoadState( 'networkidle', { timeout } );

Check failure on line 126 in tests/e2e/utils/payments.js

View workflow job for this annotation

GitHub Actions / Default WP=latest, WC=latest, PHP=7.4

[default] › checkout/shortcode/lpms/ach.spec.js:46:6 › ACH payment tests @shortcode › customer can pay with ACH using valid bank details @smoke

2) [default] › checkout/shortcode/lpms/ach.spec.js:46:6 › ACH payment tests @shortcode › customer can pay with ACH using valid bank details @smoke Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TimeoutError: frame.waitForLoadState: Timeout 15000ms exceeded. at ../utils/payments.js:126 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 = [ at waitForStripeReady (/home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/utils/payments.js:126:20) at setupACHCheckout (/home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/utils/payments.js:548:2) at /home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js:49:3

Check failure on line 126 in tests/e2e/utils/payments.js

View workflow job for this annotation

GitHub Actions / Default WP=latest, WC=latest, PHP=7.4

[default] › checkout/shortcode/lpms/ach.spec.js:46:6 › ACH payment tests @shortcode › customer can pay with ACH using valid bank details @smoke

2) [default] › checkout/shortcode/lpms/ach.spec.js:46:6 › ACH payment tests @shortcode › customer can pay with ACH using valid bank details @smoke TimeoutError: frame.waitForLoadState: Timeout 15000ms exceeded. at ../utils/payments.js:126 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 = [ at waitForStripeReady (/home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/utils/payments.js:126:20) at setupACHCheckout (/home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/utils/payments.js:548:2) at /home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js:49:3

// Additional wait for any loading indicators to disappear in parallel
const loadingIndicators = [
'.__PrivateStripeElementLoader',
'.LightboxModalLoadingIndicator',
'[data-testid="loading"]',
];

await Promise.all(
loadingIndicators.map( ( indicator ) =>
stripeFrame
.locator( indicator )
.waitFor( { state: 'hidden', timeout } )
.catch( () => {} )
)
);

return stripeFrame;
}

/**
* Retry an async function with exponential backoff.
* Useful for flaky operations like iframe interactions or API calls.
*
* @param {Function} fn The async function to retry.
* @param {Object} options Retry configuration.
* @param {number} options.maxRetries Maximum number of retries (default: 3).
* @param {number} options.initialDelay Initial delay in milliseconds (default: 500).
* @param {number} options.maxDelay Maximum delay in milliseconds (default: 5000).
* @param {Function} options.shouldRetry Optional function to determine if error should trigger retry.
* @returns {Promise<any>} The result of the function call.
*/
export async function retryWithBackoff( fn, options = {} ) {
const {
maxRetries = 3,
initialDelay = 500,
maxDelay = 5000,
shouldRetry = () => true,
} = options;

let lastError;
let delay = initialDelay;

for ( let attempt = 0; attempt <= maxRetries; attempt++ ) {
try {
return await fn();
} catch ( error ) {
lastError = error;

// Don't retry if we've exhausted attempts or if shouldRetry returns false
if ( attempt === maxRetries || ! shouldRetry( error ) ) {
break;
}

// Wait before retrying
await new Promise( ( resolve ) => setTimeout( resolve, delay ) );

// Exponential backoff with max delay cap
delay = Math.min( delay * 2, maxDelay );
}
}

throw lastError;
}

/**
* Fills in the credit card details on the default (blocks) checkout page.
* @param {Page} page Playwright page fixture.
Expand Down Expand Up @@ -416,32 +515,25 @@
await emptyCart( page );
await setupCart( page );

const rawIframeSelector = 'iframe[src*="elements-inner-payment"]';
let iframeSelector;

if ( checkoutType === 'blocks' ) {
iframeSelector = `#radio-control-wc-payment-method-options-stripe_us_bank_account__content ${ rawIframeSelector }`;

await setupBlocksCheckout(
page,
config.get( 'addresses.customer.billing' )
);

// Select ACH in blocks checkout
await page
.locator( 'label' )
.filter( { hasText: 'ACH Direct Debit' } )
.dispatchEvent( 'click' );

// Wait for the iframe to be ready
const frameHandle = await page.waitForSelector(
'#radio-control-wc-payment-method-options-stripe_us_bank_account__content iframe[name^="__privateStripeFrame"]'
);
const stripeFrame = await frameHandle.contentFrame();
await stripeFrame.waitForLoadState( 'networkidle' );

// Click "Test Institution"
await page
.frameLocator(
'#radio-control-wc-payment-method-options-stripe_us_bank_account__content iframe[src*="elements-inner-payment"]'
)
.getByText( 'Test Institution' )
.dispatchEvent( 'click' );
.click();
} else {
iframeSelector = `.wc_payment_method.payment_method_stripe_us_bank_account ${ rawIframeSelector }`;

await setupShortcodeCheckout(
page,
config.get( 'addresses.customer.billing' )
Expand All @@ -450,23 +542,21 @@
// Select ACH in shortcode checkout
const achLabel = page.getByText( 'ACH Direct Debit' );
await achLabel.waitFor( { state: 'visible' } );
await achLabel.dispatchEvent( 'click' );
await achLabel.click();
}

// Wait for the iframe to be ready
const frameHandle = await page.waitForSelector(
'.payment_method_stripe_us_bank_account iframe[name^="__privateStripeFrame"]'
);
const stripeFrame = await frameHandle.contentFrame();
await stripeFrame.waitForLoadState( 'networkidle' );
await waitForStripeReady( page, iframeSelector );

// Click "Test Institution"
await page
.frameLocator(
'.wc_payment_method.payment_method_stripe_us_bank_account iframe[src*="elements-inner-payment"]'
)
.getByTestId( 'featured-institution-default' )
.dispatchEvent( 'click' );
}
// Click "Test Institution" with retry logic
await retryWithBackoff( async () => {
const testInstitutionButton = page
.frameLocator( iframeSelector )
.getByText( 'Test Institution' )
.first();

await expect( testInstitutionButton ).toBeVisible();
await testInstitutionButton.click();

Check failure on line 558 in tests/e2e/utils/payments.js

View workflow job for this annotation

GitHub Actions / Default WP=latest, WC=latest, PHP=7.4

[default] › checkout/blocks/lpms/ach.spec.js:45:6 › ACH payment tests @blocks › customer can pay with ACH using valid bank details @smoke

1) [default] › checkout/blocks/lpms/ach.spec.js:45:6 › ACH payment tests @blocks › customer can pay with ACH using valid bank details @smoke TimeoutError: locator.click: Timeout 15000ms exceeded. Call log: - waiting for frameLocator('#radio-control-wc-payment-method-options-stripe_us_bank_account__content iframe[src*="elements-inner-payment"]').getByText('Test Institution').first() - locator resolved to <p class="u-lh u-text-truncate u-fs-3xs u-text-cent…>Test Institution</p> - attempting click action - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #1 - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #2 - waiting 20ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #3 - waiting 100ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #4 - waiting 100ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #5 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #6 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #7 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #8 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #9 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #10 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #11 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - element is outside of the viewport - retrying click action, attempt #12 - waiting 500ms - waiting for element to be visible, enabled and stable - element is visible, enabled and stable
} );
};

/**
Expand All @@ -478,34 +568,49 @@
.frameLocator( 'iframe[name^="__privateStripeFrame"]' )
.first();

// Agree and Continue
await frame.getByTestId( 'agree-button' ).click();

// Click "Success ••••" button
await frame.getByRole( 'button', { name: 'Success ••••' } ).click();

// Click "Connect Account" button.
await frame.getByTestId( 'select-button' ).click();
// Click Agree and Continue button
let button = frame.getByTestId( 'agree-button' );
await expect( button ).toBeVisible();
await button.click();

// Link registration button may or may not appear.
await Promise.race( [
frame
.getByTestId( 'link-not-now-button' )
.waitFor( {
state: 'visible',
timeout: 5000,
} )
.waitFor( { state: 'visible' } )
.then( async () => {
await frame.getByTestId( 'link-not-now-button' ).click();
} ),
frame
.getByRole( 'button', { name: 'Success ••••' } )
.waitFor( { state: 'visible' } ),
] );

frame.getByTestId( 'done-button' ).waitFor( {
state: 'visible',
timeout: 5000,
} ),
// Click "Success ••••" account
button = frame.getByRole( 'button', { name: 'Success ••••' } );
await expect( button ).toBeVisible();
await button.click();

// Click Connect account button
button = frame.getByTestId( 'select-button' );
await expect( button ).toBeVisible();
await button.click();

// If link registration did not load when starting the flow, it will appear here.
await Promise.race( [
frame
.getByTestId( 'link-not-now-button' )
.waitFor( { state: 'visible' } )
.then( async () => {
await frame.getByTestId( 'link-not-now-button' ).click();
} ),
frame.getByTestId( 'done-button' ).waitFor( { state: 'visible' } ),
] );

await frame.getByTestId( 'done-button' ).click();
// Click the done button with retry logic
button = frame.getByTestId( 'done-button' );
await expect( button ).toBeVisible();
await button.click();
};

/**
Expand Down
Loading