Playwright architecture, selector reliability, and advanced interaction patterns.

Testing Third-Party Payment Iframes

Every card field on a modern checkout page belongs to somebody else. Stripe Elements, Adyen Secured Fields, Braintree Hosted Fields and Checkout.com Frames all render the card number, expiry and CVC inside an iframe served from the provider's own origin, so the raw pan never touches the merchant's DOM and the merchant stays inside a lighter PCI DSS assessment. That design is correct for security and hostile to test code: the fields are cross-origin, their frame names change on every mount, the provider re-creates the frames when the amount or currency changes, and a successful payment can open a second, deeper iframe for a 3D Secure challenge. This page shows how to drive those fields with frameLocator(), how to reach the nested challenge frame, and how to keep a provider's sandbox from turning your checkout suite into a coin flip.

How Playwright reaches a cross-origin payment field The Playwright driver reaches card inputs inside a provider iframe over the browser protocol, while page JavaScript is stopped at the origin boundary. merchant page — shop.example.com payment iframe — js.stripe.com card number input expiry and CVC inputs Playwright driver browser protocol reaches into any frame page JavaScript same-origin only blocked at the boundary
Playwright drives the provider's inputs over the browser protocol, so the same-origin policy that blocks in-page scripts never applies to a locator.

Root cause: the fields are in someone else's document

A payment iframe is a separate document with a separate origin, so nothing in the merchant page can select, read or type into it — that isolation is the entire product. Playwright is not subject to that rule, because it speaks to the browser over the automation protocol rather than from inside the page, and frameLocator() resolves a frame by its host element and then queries the frame's own document directly. The practical problems are therefore not permission problems; they are identity and lifetime problems. Providers name their frames with a random suffix (__privateStripeFrame1621), mount several invisible controller and metrics frames alongside the visible one, and tear down and recreate the field frames whenever the payment intent, amount or locale changes. Any test that grabs iframe by index, or holds a Frame object across a re-render, is coupled to values the provider changes without notice. The durable approach is a prefix or attribute match on the frame host plus an accessible-name query inside it, which is the same principle applied one level deeper than in Handling Cross-Origin Iframe Restrictions and covered generally in the Iframes & Embedded Content guide.

Minimal reproducible example

The test below shows the naive approach that fails and the resolution that holds. Run it against a Stripe test-mode checkout; the same shape applies to Adyen and Braintree with different attribute names.

import { test, expect } from '@playwright/test';

test('fills a Stripe card field', async ({ page }) => {
  await page.goto('/checkout');

  // WRONG: Stripe mounts several hidden controller and metrics iframes next
  // to the visible field frame, so this throws:
  //   Error: strict mode violation: locator('iframe') resolved to 4 elements
  // await page.frameLocator('iframe').locator('input').fill('4242424242424242');

  // RIGHT: match the frame host on the stable name prefix. The numeric
  // suffix (__privateStripeFrame1621) changes on every mount, so ^= is required.
  const card = page.frameLocator('iframe[name^="__privateStripeFrame"]');

  // Inside the frame, query by accessible name rather than Stripe's internal
  // ids (#Field-numberInput), which differ between Card and Payment Element.
  const number = card.getByRole('textbox', { name: 'Card number' });

  // A frameLocator resolves lazily on every action, so this single await
  // covers "frame not attached yet" and "SDK still booting" in one wait.
  await expect(number).toBeVisible();

  // Stripe's input formats on keystrokes; fill() sets the value and dispatches
  // an input event, which the SDK accepts. Providers that only listen for
  // keydown need pressSequentially() instead — see step 3.
  await number.fill('4242424242424242');
  await card.getByRole('textbox', { name: 'Expiration' }).fill('12/34');
  await card.getByRole('textbox', { name: 'CVC' }).fill('123');

  // The Pay button lives in YOUR document, not the frame — do not chain it
  // off `card`, or the locator will search the provider's document and time out.
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByText('Payment received')).toBeVisible();
});

Step-by-step fix

  1. Anchor the frame on a stable attribute, never on index or full name. Use an attribute-prefix match on the host element: iframe[name^="__privateStripeFrame"] for Stripe, iframe[name="braintree-hosted-field-number"] for Braintree Hosted Fields, and iframe[title="Iframe for secured card number"] or iframe[data-fieldtype="encryptedCardNumber"] for Adyen Secured Fields. Adyen and Braintree give each field its own frame, so you will hold three frame locators; Stripe's Payment Element puts all three fields in one. Matching on nth(0) works exactly until the provider adds another hidden frame in a minor SDK release.

  2. Use frameLocator(), not page.frame(). page.frame({ url: /stripe/ }) returns a Frame snapshot captured at the moment you asked, and once the SDK remounts the element that object points at a dead execution context. A FrameLocator stores only the selector and re-resolves the frame on every action and every assertion retry, which is what makes it survive the remounts described below. The same lazy-resolution argument drives the chaining rules in Automating Elements Inside Nested Iframes.

  3. Match the provider's input model when typing. Stripe and Adyen inputs react to the input event, so fill() is correct and fast. Braintree Hosted Fields and several older tokenizers format and validate on keydown, and a bulk fill() leaves their internal state empty even though the field looks populated — the submit button stays disabled and the SDK reports the number as incomplete. For those, use pressSequentially('4242424242424242', { delay: 30 }), which emits a real key sequence per character. Test one provider once, write down which mode it needs, and keep it in the page object.

  4. Wait on provider readiness, not on a timer. The card frame attaches before the SDK has wired its listeners, so an action fired the instant the frame appears can be silently dropped. Assert the field is both visible and editable — await expect(number).toBeEditable() — before typing, and where the SDK exposes a ready signal (Stripe's ready event on the element, Adyen's onReady) have the app set a data-payment-ready attribute in test builds and wait on that. Never insert waitForTimeout(); the reasoning is laid out in Handling Dynamic Content.

  5. Chain a frame locator per boundary for 3D Secure. A card that triggers a challenge (Stripe's 4000 0027 6000 3184) opens the issuer's authentication page inside a frame nested two levels below the page. Chain frameLocator() calls in document order rather than trying to reach the button in one query, then click the issuer's completion control. Some providers redirect to a popup window instead; catch that with page.waitForEvent('popup') and drive the returned Page normally.

  6. Decide per test whether you need the real provider at all. Most checkout tests assert your application's behaviour — validation copy, totals, the confirmation screen — and only a handful assert the integration itself. Stub the provider's script and confirmation endpoint with page.route() for the first group so they run offline and deterministically, and keep a small tagged suite that hits the provider sandbox for the second. The stubbing mechanics are in Mocking API Responses with Playwright.

Order of interactions across a 3D Secure payment A sequence diagram showing the test runner driving the merchant page, the payment iframe and the nested 3D Secure challenge frame in turn. test runner merchant page payment iframe 3DS challenge frame page.goto('/checkout') SDK mounts card iframe frameLocator().fill() click Pay button 3DS challenge opens complete authentication redirect to receipt
The runner alternates between your document and two levels of provider frames; each hop needs its own locator root, never a cached frame handle.

Two details in that sequence trip people up. The Pay button belongs to the merchant document even though it sits directly under the card fields, so it must be located from page, not from the frame locator — chaining it off the provider frame produces a timeout that reads like a rendering bug. And the redirect at the end returns control to your origin rather than resolving inside the frame, which means the confirmation assertion is an ordinary page-level assertion; nothing about the payment being third-party changes how you verify your own success screen.

The 3D Secure chain in step 5 looks like this against Stripe's test-mode challenge. Each frameLocator() call descends exactly one boundary, and the whole chain re-resolves on every retry, so a slow issuer page costs time rather than a failure.

import { test, expect } from '@playwright/test';

test('completes a 3D Secure challenge', async ({ page }) => {
  await page.goto('/checkout');

  const card = page.frameLocator('iframe[name^="__privateStripeFrame"]');
  // 4000 0027 6000 3184 is Stripe's card that always forces a 3DS2 challenge.
  await card.getByRole('textbox', { name: 'Card number' }).fill('4000002760003184');
  await card.getByRole('textbox', { name: 'Expiration' }).fill('12/34');
  await card.getByRole('textbox', { name: 'CVC' }).fill('123');
  await page.getByRole('button', { name: 'Pay now' }).click();

  // The challenge is three documents deep: the Stripe wrapper frame, the
  // challenge shell, then the issuer's own ACS page. Chain one call per level.
  const acs = page
    .frameLocator('iframe[name^="__privateStripeFrame"]')
    .frameLocator('#challengeFrame')
    .frameLocator('iframe[name="acsFrame"]');

  // The issuer page loads over a real network hop; give this leg its own budget
  // instead of raising the whole test timeout.
  const approve = acs.getByRole('button', { name: 'Complete authentication' });
  await expect(approve).toBeVisible({ timeout: 20_000 });
  await approve.click();

  // Control returns to your origin; assert on your own confirmation state.
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});

Troubleshooting variants

strict mode violation: locator('iframe') resolved to 4 elements

The provider mounted controller frames you did not expect. Stripe adds __privateStripeController and __privateStripeMetricsController hosts with zero height, Adyen adds a hidden encryption frame, and both are real iframe elements. Print the frame inventory once with page.frames().map(f => f.name() + ' ' + f.url()) in a scratch run, then write a selector that matches only the field host — a name prefix, a title, or a data-fieldtype. Resist .first(): the hidden frames often attach before the visible one, so .first() picks the wrong document and every subsequent action times out with an empty-looking frame.

locator.fill: Frame was detached in the middle of a form

The SDK remounted the element while you were typing, usually because the amount, currency or country changed and the provider recreated the payment intent. Because a FrameLocator re-resolves per action, the retry usually succeeds — but only if you are not holding a Frame or ElementHandle from before the remount. Remove any page.frame() or elementHandle usage, complete all address and total-affecting fields before touching the card frame, and if the app updates totals on blur, assert the new total is rendered before entering card data. The same detachment shows up as Execution context was destroyed, most likely because of a navigation.

The card fields fill but the Pay button never enables

The provider's SDK did not register your input. This is the fill() versus pressSequentially() distinction from step 3: a tokenizer that formats on keydown sees no keystrokes and keeps its internal validity flag false, so the merchant page never receives the change event that enables submit. Confirm by watching the provider's own inline error text — a field the SDK considers empty usually shows nothing at all rather than an error. Switch that field to pressSequentially() with a small delay, and verify with the browser console tab in the Playwright Trace Viewer, where the SDK's change events are visible.

Choosing between a stubbed provider and the real sandbox A decision tree splitting checkout tests into stubbed provider runs and a small suite that exercises the real provider sandbox. What is this test asserting? app checkout flow and state the payment integration itself stub the provider with page.route() and fulfill provider sandbox keys and test card 4242 4242 4242 keep the sandbox branch small and tagged
Only tests that assert the integration itself should reach the provider's servers; everything else runs against a stub and never sees a rate limit.

Stubbing the provider means intercepting its script and its confirmation endpoint, then rendering a local stand-in for the field. Route handlers registered on the context apply to every page in it, which keeps the setup in one fixture rather than repeated per test. The trade-off is explicit: a stubbed run can never catch a breaking change in the provider's SDK, which is exactly why the sandbox branch of the tree still exists. Size it deliberately — one successful payment, one decline, one 3D Secure challenge is usually enough coverage to notice an upstream change, while everything else about the checkout runs offline in seconds.

import { test, expect } from '@playwright/test';

test('shows the confirmation screen without contacting the provider', async ({ page }) => {
  // Replace the provider SDK with a tiny stub that renders a plain input and
  // resolves tokenisation immediately. No iframe, no network, no rate limit.
  await page.route('https://js.stripe.com/**', route =>
    route.fulfill({
      contentType: 'application/javascript',
      body: 'window.Stripe = () => ({ elements: () => ({ create: () => ({ mount: () => {} }) }) });',
    }),
  );

  // Stub your own server's confirm endpoint so the assertion is about your UI.
  await page.route('**/api/payments/confirm', route =>
    route.fulfill({ status: 200, json: { status: 'succeeded', id: 'pi_test_123' } }),
  );

  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});

Verification

Prove the frame targeting is real, not accidental. First, log the frame tree once — page.frames().forEach(f => console.log(f.name(), f.url())) — and confirm your selector matches exactly one host whose URL is the provider's origin; a match against your own origin means you selected a controller frame. Second, run the card test five times with npx playwright test checkout --repeat-each=5 after clearing state; the random frame-name suffix changes on each mount, so five green runs demonstrate the prefix match is not pinned to one build. Third, open the trace and inspect the action log: each frameLocator step is recorded with the frame it resolved to, and the DOM snapshot lets you confirm the input you typed into is the provider's, not a lookalike in your own markup. Finally, assert on your own confirmation state rather than the provider's UI text, which the provider can restyle at will — the accessible-name discipline from getByRole & Accessibility Selectors applies just as much inside a frame. Tag the sandbox tests and let CI report them separately so a provider outage does not read as a product regression, using the pattern in Quarantining Flaky Tests Without Blocking CI.

Frequently Asked Questions

Does Playwright need a flag to type into a cross-origin payment iframe?

No. The driver communicates with the browser over the automation protocol and addresses each frame's document directly, so the same-origin policy that blocks in-page scripts never applies. You do not need --disable-web-security, a proxy, or any provider-specific bypass — only a selector that identifies the correct frame host.

Can I record and replay the provider's traffic instead of hitting the sandbox?

Yes for read-mostly journeys. Capture the checkout once with Playwright's HAR recording, then replay it so the provider's script and static assets are served from disk; see Recording and Replaying HAR Files. Tokenisation calls carrying one-time nonces will not replay cleanly, so pair HAR replay for assets with a route stub for the confirm endpoint.

Why does the same test pass locally and time out in CI?

Two causes dominate. The provider's sandbox rate-limits shared CI egress addresses, so the SDK script or the tokenisation call returns 429 and the frame never becomes editable — check the network panel in the trace before blaming the locator. The other is contention: a headless CI worker loads the third-party script more slowly, so an action fired at frame-attach time lands before the SDK's listeners exist. Waiting for toBeEditable() fixes the second and reducing sandbox-dependent tests fixes the first.

How do I test Apple Pay or Google Pay buttons?

You cannot drive the payment sheet itself — it is browser or operating-system chrome outside the page, and no locator reaches it. Assert that the wallet button renders under the right conditions, that clicking it calls your app's handler, and stub the resulting payment-token callback. Anything past the sheet belongs in a manual or provider-supplied simulator run.

Back to overview