Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
200 changes: 152 additions & 48 deletions tests/e2e/utils/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,103 @@
}
}

/**
* 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:59:6 › ACH payment tests @shortcode › customer can save and reuse ACH payment method @smoke

3) [default] › checkout/shortcode/lpms/ach.spec.js:59:6 › ACH payment tests @shortcode › customer can save and reuse ACH payment method @smoke › Save payment method during first checkout 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 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:545:2) at /home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js:69:4 at /home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js:63: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 Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── 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 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:545: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 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:545: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
const loadingIndicators = [
'.__PrivateStripeElementLoader',
'.LightboxModalLoadingIndicator',
'[data-testid="loading"]',
];

for ( const indicator of loadingIndicators ) {
const loader = stripeFrame.locator( indicator );
if ( await loader.isVisible().catch( () => false ) ) {
await expect( loader ).toBeHidden( { timeout: 10000 } );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two thoughts:

  • It's not clear to me whether we need to check the selectors in sequence, or whether we can shift to something like Promise.all() that allows us to perform these checks in parallel. We should either add a comment about why we're doing this sequentially, or we should look at handling this in parallel.
  • Not blocking: should this timeout be configurable in some way?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to check the selector in parallel in f04f9bc

}
}

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 +513,24 @@
await emptyCart( page );
await setupCart( page );

let iframeSelector = 'iframe[src*="elements-inner-payment"]';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: rather than overwriting the iframeSelector variable, it may be clearer to use a separate variable name for this initial/inner selector, and then use a separate variable for the full iframe selector.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f04f9bc


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

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 ${ iframeSelector }`;

await setupShortcodeCheckout(
page,
config.get( 'addresses.customer.billing' )
Expand All @@ -450,23 +539,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 555 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:59:6 › ACH payment tests @shortcode › customer can save and reuse ACH payment method @smoke

3) [default] › checkout/shortcode/lpms/ach.spec.js:59:6 › ACH payment tests @shortcode › customer can save and reuse ACH payment method @smoke › Save payment method during first checkout Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TimeoutError: locator.click: Timeout 15000ms exceeded. Call log: - waiting for frameLocator('.wc_payment_method.payment_method_stripe_us_bank_account 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

Check failure on line 555 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 +565,51 @@
.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', timeout: 5000 } )

Check failure on line 577 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: locator.waitFor: Timeout 5000ms exceeded. Call log: - waiting for frameLocator('iframe[name^="__privateStripeFrame"]').first().getByTestId('link-not-now-button') to be visible at ../utils/payments.js:577 575 | frame 576 | .getByTestId( 'link-not-now-button' ) > 577 | .waitFor( { state: 'visible', timeout: 5000 } ) | ^ 578 | .then( async () => { 579 | await frame.getByTestId( 'link-not-now-button' ).click(); 580 | } ), at fillACHBankDetails (/home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/utils/payments.js:577:5) at /home/runner/work/woocommerce-gateway-stripe/woocommerce-gateway-stripe/tests/e2e/tests/checkout/shortcode/lpms/ach.spec.js:50:3
.then( async () => {
await frame.getByTestId( 'link-not-now-button' ).click();
} ),
frame
.getByRole( 'button', { name: 'Success ••••' } )
.waitFor( { state: 'visible', timeout: 5000 } ),
] );

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', timeout: 5000 } )
.then( async () => {
await frame.getByTestId( 'link-not-now-button' ).click();
} ),
frame
.getByTestId( 'done-button' )
.waitFor( { state: 'visible', timeout: 5000 } ),
] );

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