| |

Playwright iFrames: Testing Embedded Content in TypeScript

Playwright iframes featured image: frameLocator() in TypeScript, Day 50

I still see QA engineers lose entire afternoons to a single error: waiting for getByPlaceholder('Card number') to be visible on a payment page that loads fine in the browser. The culprit is almost always an iframe. Playwright has crossed 94,000 stars on GitHub and serves over 200 million monthly npm downloads, yet frame handling remains one of the most confusing parts for testers who meet it for the first time. Playwright treats iframes as separate documents, and your locator will never pierce that boundary unless you explicitly tell it to. In this Day 50 tutorial I will show you exactly how to handle Playwright iframes in TypeScript, from the basic frameLocator() call to nested frames, cross-origin frames, and the waiting patterns that stop your embedded-content tests from flaking.

Table of Contents

Contents

Why iFrames Break Your Test Automation

An iframe is a full HTML document embedded inside another HTML document. When you open a checkout page, the browser actually loads two (or more) separate documents: the parent page, and each <iframe> the parent references. Your Stripe card field, your chat widget, your embedded video player, and your analytics form are each their own mini-page.

This matters for automation because of how the DOM and the JavaScript context are isolated. A locator like page.getByPlaceholder('Card number') searches only the top-level document. If that input lives inside an iframe, the locator returns nothing, Playwright keeps retrying until the timeout expires, and your test fails with an “element is not visible” or “element not found” error even though the element clearly rendered on screen.

What Actually Happens Inside the DOM

Open DevTools on any payment page and expand the <iframe> node. You will see a brand-new #document inside it. That nested document has its own <html>, its own <body>, and its own full element tree. It also runs in a separate browsing context, which is why a document.querySelector call from the parent page returns null for anything inside the frame.

This is the single most common reason a “working” selector suddenly fails in CI. The selector was never wrong. It was pointing at a document boundary the test did not cross. The worst part is that the failure looks like a normal timeout, so engineers waste time re-checking the selector, re-installing browsers, and blaming the environment before anyone looks at the iframe.

The Frame Tree, Explained

Every page in Playwright exposes a frame tree. The top-level document is the main frame, and every iframe (and every iframe inside an iframe) is a child frame. Playwright gives you two ways to reach these frames:

  • page.frames() returns the full list of Frame objects, including the main frame.
  • page.frameLocator() returns a FrameLocator that scopes every subsequent locator to a specific frame.

You will almost always use frameLocator(). It composes cleanly with the locators you already know, and it keeps your test readable. I reserve page.frames() for the rare case where I need a frame’s URL or name at runtime.

How Playwright Sees Frames

Before I show code, a quick mental model. In Selenium you switch into a frame with driver.switchTo().frame() and then remember to switch back. In Playwright there is no switching state to manage. A FrameLocator is a scoped locator: you create it once, and every call chained onto it stays inside that frame automatically.

That is a huge deal for readability and for avoiding a classic Selenium bug where a test forgets to switchTo().defaultContent() and every later step runs inside the wrong context. I have debugged that exact bug in three different client suites, and it is exactly the kind of hidden state that makes tests fail for no visible reason.

frameLocator() vs page.locator()

The difference is scope, not syntax:

  • page.locator('...') searches the main document only.
  • page.frameLocator('#frame').locator('...') searches only inside that frame.
  • page.frameLocator('#frame').getByRole('button') uses role, name, and accessibility semantics inside the frame.

Both return a locator, both support auto-waiting, both support strict-mode assertions. The frame locator simply pins the search to a different document. If you understand locators from Day 2 of this series, you already understand 90 percent of frame handling.

iframes vs Shadow DOM: Do Not Mix Them Up

I get this question every week, so let me clear it up. Shadow DOM is a way to encapsulate styles and markup inside the same document. Playwright pierces open shadow DOM automatically with normal locators, so you can target an element inside a shadow root with a plain page.getByRole(...) in most cases. An iframe is a completely separate document, and no locator crosses it without frameLocator(). If a normal locator cannot find an element, check whether the boundary is a shadow root (usually fine) or an iframe (needs frameLocator()) before changing anything else.

Locating Elements Inside an iframe

Here is the canonical example: a checkout page with a payment iframe. The card fields exist only inside #card-iframe, so I scope a frame locator first and then target the fields.

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

test('fill card details inside a payment iframe', async ({ page }) => {
  await page.goto('https://example.com/checkout');

  // Scope everything below to the card iframe
  const card = page.frameLocator('#card-iframe');

  await card.getByPlaceholder('Card number').fill('4242424242424242');
  await card.getByPlaceholder('MM / YY').fill('12 / 28');
  await card.getByPlaceholder('CVC').fill('123');

  await card.getByRole('button', { name: 'Pay' }).click();
  await expect(card.getByText('Payment confirmed')).toBeVisible();
});

Three things to notice. First, frameLocator('#card-iframe') accepts any selector, so you can match by ID, class, or attribute. Second, the chained locators use the same getByPlaceholder and getByRole helpers you already use on a normal page. Third, auto-waiting still applies: each fill() and click() waits for the element to be actionable inside the frame before it proceeds.

Matching a Frame by Attribute or URL

Many modern embeds do not give you a stable ID. A chat widget might render as <iframe src="https://widget.example.com/chat"> with no ID and no name. You can match it with an attribute selector.

const chat = page.frameLocator('iframe[src*="chat-widget"]');
await chat.getByRole('textbox', { name: 'Type a message' }).fill('Hello, I need help');
await chat.getByRole('button', { name: 'Send' }).click();

If you have several matching iframes, Playwright throws a strict-mode violation and tells you how many it found. Add a more specific selector or scope from a parent element to disambiguate. A data-testid on the iframe is the cleanest long-term fix, so loop your developers in early.

Reading Text and Making Assertions Inside Frames

Assertions work the same way as actions. Keep them on the same frame locator so you verify the right document.

const result = page.frameLocator('#results-frame');

await expect(result.getByRole('heading', { name: 'Order summary' })).toBeVisible();
const total = await result.getByTestId('total-amount').textContent();
expect(total?.trim()).toBe('₹1,299');
await expect(result.getByRole('button', { name: 'Download invoice' })).toBeEnabled();

Notice the textContent() call is scoped to the frame. If you accidentally call page.getByTestId('total-amount').textContent(), the parent-page locator returns nothing and the assertion fails on an empty string, not on the actual UI.

Nested Frames: iframes Inside iframes

Payment gateways love nesting: an outer iframe for the checkout module, an inner iframe for the 3-D Secure card-verification step. You handle this by chaining frameLocator() calls.

const outer = page.frameLocator('#checkout-module');
const inner = outer.frameLocator('#secure3d-frame');

await inner.getByPlaceholder('Enter the OTP').fill('123456');
await inner.getByRole('button', { name: 'Confirm' }).click();
await expect(inner.getByText('Authentication successful')).toBeVisible();

The chain reads left to right exactly like the DOM: the checkout module frame, then the 3-D Secure frame inside it. There is no practical limit to nesting depth, but past three levels the readability suffers, and I usually wrap the chain in a small helper function or a page object.

Using the Frame Object Directly

Sometimes you need metadata about a frame before you interact with it. page.frames() returns real Frame objects with a url() and a name(). This is useful for logging which frame a test is about to touch, or for picking the right frame when there are many.

const frame = page.frames().find((f) => f.url().includes('secure'));
if (!frame) {
  throw new Error('Secure frame did not load');
}
console.log('Secure frame URL:', frame.url());
await frame.getByPlaceholder('Enter the OTP').fill('123456');

Note that frame.locator() and frame.getByPlaceholder() work the same way as on the page object. The Frame object is the lower-level primitive; frameLocator() is a convenience wrapper around it.

Real-World iframe Scenarios QA Teams Face

Iframes are not a niche edge case. They are the default delivery mechanism for an entire class of web functionality. Here are the scenarios I see in production suites every month.

Payment Gateways and Checkout

This is the most common one, and in India it is unavoidable. Razorpay, PayU, CCAvenue, and Cashfree all render their card and UPI forms inside iframes so the merchant never touches raw card data (a PCI-DSS requirement). When an Indian e-commerce site says “payment failed during automation”, nine times out of ten the test never scoped its locator to the gateway iframe. The fix is the payment-frame pattern above, plus one extra habit: log the frame’s src in your test output so failures are debuggable at a glance.

Chat Widgets and Support Embeds

Intercom, Zendesk, Freshchat, and similar tools mount a floating launcher and a full conversation panel inside a cross-origin iframe. Automating “open chat, type a message, verify the bot replies” requires scoping every action to the widget frame, and often waiting for the launcher to mount before the frame exists at all.

Consent Banners, Video Players, and reCAPTCHA

GDPR and cookie-consent widgets load in their own frame. YouTube and Vimeo embeds are iframes. Google reCAPTCHA v2 renders its challenge inside an iframe. For reCAPTCHA specifically, do not try to solve it in automation: use the test keys that Google provides for automated environments, or mock the token response. Solving the real challenge is against the point of the test and will not be stable.

Cross-Origin iframes and Permission Boundaries

Most iframes load content from a different origin than the parent page: Stripe, YouTube, Intercom, Google Maps. This is exactly why frames are isolated. Your test can still interact with cross-origin iframes because Playwright drives the browser at the automation protocol level, below the JavaScript same-origin policy.

There are two real boundaries to know about:

  • You cannot run page.evaluate() into a cross-origin frame. If you need to read or mutate a value inside a third-party frame, you cannot reach it from the parent’s JS context. Interact through locators instead.
  • Permissions are per-origin. A Google Maps embed that requests geolocation needs context.grantPermissions(['geolocation'], { origin: 'https://maps.example.com' }) for that specific origin, not the parent page’s origin.

For content you control, the cleanest fix is to keep iframe and parent on the same origin in test environments, or to mock the third-party frame entirely with page.route() when you only need to verify your own page’s behavior around it. Mocking is also faster and removes network flakiness from your CI runs.

Waiting for Frame Content to Load

Frames load on their own timeline. The parent page can fire load long before a lazy-loaded iframe finishes rendering its content. If you start filling fields immediately, you hit a race. The good news is that locators auto-wait, so in most cases you do not need explicit waits.

// Auto-wait handles the slow frame: Playwright retries until the field exists
const consent = page.frameLocator('#gdpr-frame');
await consent.getByRole('checkbox', { name: 'I agree' }).check();

When you do need an explicit signal, wait for a visible element inside the frame rather than a fixed sleep().

const player = page.frameLocator('#video-frame');
await expect(player.getByRole('button', { name: 'Play' })).toBeVisible();
await player.getByRole('button', { name: 'Play' }).click();

For frames that appear only after a user action, combine an expect on the frame element with the scoped locator. The wait targets the outcome you care about, so the test never sleeps longer than necessary and never proceeds too early. If a frame can be injected at runtime, also assert on the iframe element itself first, so Playwright waits for the frame to exist before it tries to scope locators into it.

Common Pitfalls (and How I Fix Them)

Here are the mistakes I see teams make with Playwright iframes, in rough order of frequency.

  1. Using page.locator() for content inside an iframe. The locator never crosses the boundary. Fix: always scope with frameLocator() first.
  2. Forgetting frames can be nested. A single frameLocator() does not search child frames. Fix: chain one frameLocator() per nesting level.
  3. Relying on a missing ID. Many widgets ship without IDs or with dynamically generated IDs that change every deploy. Fix: match by src attribute or a stable attribute, and avoid hashed IDs.
  4. Strict-mode violations. Two iframes match the same selector. Fix: read the error message, which names both matches, and add a more specific selector.
  5. Asserting on the wrong frame. You scoped the click correctly but then assert page.getByText(...) against the parent page. Fix: keep every step, including assertions, on the same frame locator.
  6. Fixed sleeps instead of auto-wait. page.waitForTimeout(3000) is flaky and slow. Fix: use expect(locator).toBeVisible() or toBeAttached() inside the frame.
  7. Ignoring iframe timing on lazy embeds. Chat widgets and video players mount after scroll or after a network call. Fix: wait for a frame element before interacting.

If you are coming from Selenium, the biggest habit to unlearn is the frame-switch dance. There is nothing to switch back from in Playwright. Each FrameLocator is self-contained, and the next line of code automatically operates on whatever frame you scoped it to.

Key Takeaways

  • Playwright iframes are separate documents, and locators never cross the boundary automatically. Scope with page.frameLocator() first.
  • frameLocator() accepts any selector and composes with getByRole, getByPlaceholder, and getByText exactly like a normal page locator.
  • Nested iframes are handled by chaining frameLocator() calls, one per level.
  • Auto-waiting works inside frames, so prefer expect(locator).toBeVisible() over fixed sleeps for lazy-loaded embeds.
  • Keep the frame-scoped locator in a variable and reuse it for both actions and assertions to avoid asserting on the wrong document.

FAQ

Why does my locator work in the browser but fail in Playwright?

Because the element is inside an iframe. Your DevTools inspection is showing the merged view, but Playwright searches only the main document unless you use frameLocator(). Scope the locator to the frame and it will resolve.

How do I handle an iframe with no ID or name?

Match it by a stable attribute such as src or title, for example page.frameLocator('iframe[src*="widget"]'). If nothing is stable, ask the developers to add a data-testid to the iframe, which is the same fix you would apply to any fragile selector.

Can Playwright interact with cross-origin iframes?

Yes, for normal actions like fill, click, and assert. What you cannot do is run page.evaluate() into a cross-origin frame from the parent context. For reading values inside a third-party frame, use locators; for geolocation and similar permissions, grant them per origin.

What is the difference between frameLocator() and page.frames()?

frameLocator() returns a scoped locator you chain other locators onto. page.frames() returns the list of raw Frame objects with url() and name() methods. Use frameLocator() for nearly everything; reach for page.frames() when you need runtime frame metadata.

Is an iframe the same as a shadow DOM?

No. Shadow DOM is encapsulation inside the same document, and Playwright pierces open shadow roots automatically. An iframe is a separate document that always needs frameLocator(). When a locator fails, check which boundary you are crossing before rewriting anything.

If this is your first time through the series, start at Day 1: Installation and First Test, and brush up on Day 4: Page Interactions for the click, fill, and select primitives you will use inside frames. The full frame reference lives in the Playwright frames documentation and the FrameLocator API reference.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.