Playwright architecture, selector reliability, and advanced interaction patterns.

Iframes & Embedded Content

A locator that works perfectly in the browser console times out in your test, the trace shows the element clearly rendered on screen, and the call log ends with waiting for locator('#card-number') — the element is real, but it lives inside an <iframe>, and every locator you write against page searches only the top-level document. Checkout widgets, consent banners, embedded maps, help chat, rich-text editors, ad slots and 3-D Secure challenges all arrive as separate browsing contexts, and each one needs an explicit hop before any selector inside it can resolve. This guide covers how Playwright models those hops, how to pick a frame handle that survives a redeploy, and how to keep frame-heavy suites stable in CI as part of Reliable Selector Strategies for Playwright.

Root cause: an iframe is a different document, not a different subtree

The mistake behind almost every iframe failure is treating <iframe> like an ordinary container element. It is not. An <iframe> element is a host in the parent document, and what it displays is a completely separate Document with its own DOM tree, its own JavaScript realm, its own stylesheet cascade, its own URL, and — critically for selector work — its own accessibility tree. The parent document contains exactly one node for the embed: the <iframe> element itself. Everything you can see inside it belongs to a different tree that the parent's querySelectorAll never walks.

This is why page.getByRole('textbox', { name: 'Card number' }) fails while the same query typed into the frame's own console succeeds. Role and label queries read the computed accessibility tree, and the browser builds one tree per document. A control inside a frame is exposed to assistive technology through the frame's tree, not the top page's, so a top-level role query genuinely cannot see it. The same applies to text queries, CSS chaining, and XPath: none of them cross a document boundary, and no combinator exists that would let them. This is the sharp difference from Shadow DOM Traversal, where the shadow tree belongs to the same document and Playwright's engine pierces open roots automatically.

What makes Playwright good at frames is that it does not drive the browser by injecting scripts into the top page. It attaches to every frame in the page individually over the browser's remote debugging protocol, and it holds a separate execution context per frame. Because the driver sits outside the page, the same-origin policy — which stops page JavaScript from reading a cross-origin frame — does not constrain it at all. A locator inside a frame served from a different origin behaves exactly like a locator in the main document: it auto-waits, it retries, it enforces actionability checks. The boundary you have to declare in your code is a bookkeeping boundary, not a security one.

Frame tree of a page with same-origin and cross-origin embeds The top-level page holds two iframe hosts, one of which contains a further nested frame, while the Playwright driver attaches to every frame directly rather than through the top document. Top-level page (main frame) same-origin iframe own document own a11y tree cross-origin iframe separate process own a11y tree nested frame inside the cross-origin frame for example a 3-D Secure challenge reached by chaining contentFrame() twice one hop per boundary Playwright driver attaches to every frame directly
Each embed is its own document with its own accessibility tree; the driver binds to all of them, so cross-origin frames are no harder to reach than same-origin ones.

Prerequisites

Everything below assumes Playwright 1.43 or newer, because locator.contentFrame() and frameLocator.owner() were introduced in that release and they are the ergonomic path for modern suites. Older versions still work with page.frameLocator(), which remains supported and is used in several examples here.

You should already be comfortable with locators as lazy descriptions rather than resolved element references, since that property is what makes frame handling reliable — a locator built through a frame re-resolves the frame on every retry. If auto-waiting and web-first assertions are still fuzzy, read Handling Dynamic Content first, because frame content is dynamic content with an extra navigation in front of it. Familiarity with page.route() from Network Interception Basics helps for the stubbing patterns near the end, and the isolation model in Browser Contexts & Isolation explains why third-party embeds behave differently between a fresh context and a reused profile.

Finally, have a real page with a real embed in front of you. Frame bugs are specific: the difference between an iframe that is present but empty, present but still on about:blank, and present but detached and re-created produces three different error messages and three different fixes.

Entering a frame with contentFrame()

The modern entry point is locator.contentFrame(). You build an ordinary locator for the <iframe> element in the parent document, call contentFrame() on it, and get back a FrameLocator that scopes every subsequent query to the embedded document. Nothing has been resolved yet — like all locators, the chain is a recipe that is evaluated at action time.

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

test('fills a field inside a same-origin embed', async ({ page }) => {
  await page.goto('/checkout');

  // Step 1: an ordinary locator for the <iframe> ELEMENT in the parent document.
  const embedHost = page.locator('iframe[title="Billing address"]');

  // Step 2: hop into the embedded document. Nothing is resolved yet — this is
  // still a lazy description that will be evaluated on each retry.
  const billing = embedHost.contentFrame();

  // Step 3: inside the frame, every normal query works, including role queries,
  // because we are now reading THAT document's accessibility tree.
  await billing.getByLabel('Street address').fill('12 Rowan Way');
  await billing.getByRole('button', { name: 'Save address' }).click();

  // Assertions inside the frame auto-retry exactly like top-level ones.
  await expect(billing.getByRole('status')).toHaveText('Address saved');

  // And the parent document is still reachable from `page` — the hop is scoped
  // to the chain, not a global mode switch you have to undo afterwards.
  await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
});

Two properties of this chain matter more than the syntax. First, there is no "switch back" call. Older automation tools kept a mutable "current frame" pointer that you had to reset, which was a common source of leaked state between steps; Playwright scopes the hop to the locator chain, so page.getByRole(...) on the next line still targets the top document. Second, the frame lookup happens on every retry. If the widget tears down its iframe and mounts a new one while your assertion is polling, the chain simply resolves the new element on the next attempt instead of throwing on a stale reference.

page.frameLocator('iframe[title="Billing address"]') produces the same object and is still perfectly valid. Prefer contentFrame() in new code for one practical reason: the host locator exists as a value you can assert on independently — await expect(embedHost).toBeVisible() tells you whether the element rendered, which separates "the embed never mounted" from "the embed mounted but its content never loaded". Going the other direction, frameLocator.owner() hands you the <iframe> element locator back, which is what you need for scrollIntoViewIfNeeded() or a bounding-box check.

Choosing a frame handle that survives a redeploy

Selecting the iframe element is where most frame tests rot. Vendors generate id attributes at runtime, reorder embeds when a feature flag flips, and add a hidden preload frame that quietly makes your index-based selector point at the wrong thing. The selector must resolve to exactly one iframe, or you get Error: strict mode violation: locator('iframe') resolved to 3 elements — the same strictness rule described in Resolving Strict Mode Violations, applied to the host element.

Rank your options by how much of the attribute the vendor has committed to. A title attribute is best: it is user-facing, it is what screen readers announce, and vendors change it rarely because changing it is an accessibility regression. A name attribute is next, since it is part of the frame targeting contract and often referenced by the vendor's own scripts. A stable id prefix with a random suffix — Stripe's __privateStripeFrame4821 is the classic — calls for an attribute-prefix match. Only when nothing on the element is stable should you fall back to matching the frame by the URL it loaded.

Choosing a stable handle for an iframe element A decision tree that routes from the attributes available on the iframe element to an attribute match, a prefix match, or a URL match, all converging on a single resolved frame. How is the iframe identified? Stable title or name is present match that attribute Generated id with a stable prefix only use a prefix match Nothing stable at all on the element match the frame URL Resolve to exactly one iframe then hop in and chain locators as usual
Work down from the most-committed attribute to the least; anything that resolves two iframes fails strict mode before a single action runs.
import { test, expect } from '@playwright/test';

test('resolves frames by title, by id prefix, and by URL', async ({ page }) => {
  await page.goto('/checkout');

  // Best: a user-facing title the vendor is unlikely to churn.
  const consent = page.locator('iframe[title="Cookie preferences"]').contentFrame();
  await consent.getByRole('button', { name: 'Accept all' }).click();

  // Good: an id whose PREFIX is stable even though the suffix is generated
  // per page load — ^= anchors on the part the vendor documents.
  const card = page.locator('iframe[id^="__privateStripeFrame"]').contentFrame();
  await card.getByPlaceholder('1234 1234 1234 1234').fill('4242424242424242');

  // Fallback: nothing on the element is stable, so identify the frame by the
  // document it loaded. page.frame() returns a Frame (or null) synchronously.
  const mapFrame = page.frame({ url: /tiles\.example\.com\/embed/ });
  expect(mapFrame).not.toBeNull();
  await expect(mapFrame!.getByRole('button', { name: 'Zoom in' })).toBeVisible();
});

Note the type difference on the last one. page.frame() returns a Frame object, which is a resolved handle to a frame that exists right now — it is null if the frame has not attached yet, and it goes stale the moment that frame detaches. FrameLocator is a lazy description that re-resolves. Use Frame when you need frame-level APIs such as frame.url(), frame.waitForURL(), or frame.waitForLoadState(); use FrameLocator for everything to do with selecting and acting on elements. Mixing them up is the reason some suites are littered with waitForTimeout calls before every page.frame() lookup.

One more selector rule worth internalising: never write page.locator('iframe').nth(2). Index-based frame selection is a promise that no other embed will ever mount above yours, and analytics vendors break that promise constantly by injecting hidden tracking frames. If you truly have no attribute, filter the host by something structural from CSS & XPath Best Practices — a labelled ancestor section, for example — rather than a positional index across the whole document.

Nested frames and the one-hop-per-boundary rule

Payment and identity flows routinely stack frames. A checkout page embeds the vendor's container frame; that frame embeds a field frame per input; a 3-D Secure step opens a challenge frame inside the container. Each boundary is its own document, so each boundary costs exactly one hop. There is no depth-piercing selector and no wildcard that crosses two levels at once.

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

test('reaches a control two frame boundaries deep', async ({ page }) => {
  await page.goto('/checkout');

  // Hop 1: the vendor's outer container frame in our document.
  const container = page.locator('iframe[name="pay-container"]').contentFrame();

  // Hop 2: inside that document there is ANOTHER iframe element. We locate it
  // relative to the container frame, then hop again. Same pattern, one level down.
  const challenge = container.locator('iframe[title="3-D Secure challenge"]').contentFrame();

  // Only now are we in the document that actually renders the OTP field.
  await challenge.getByLabel('One-time passcode').fill('123456');
  await challenge.getByRole('button', { name: 'Submit' }).click();

  // The outcome is reported back in OUR document, so assert against `page`.
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});

Debugging nested chains is easier if you assert at each level while building them. Add a temporary await expect(container.locator('iframe')).toBeVisible() between hops: if that passes and the next hop times out, the inner frame element exists but its document has not loaded, which points at a network or consent problem rather than a selector problem. The step-by-step treatment of deeper stacks, including how to keep the chain readable inside a page object, is in Automating Elements Inside Nested Iframes.

What cross-origin actually blocks

The phrase "cross-origin iframe" causes more unnecessary work than any other term in this area, because engineers assume the same-origin policy blocks them when it only blocks page scripts. Playwright locators, actions, assertions, screenshots and text extraction all work normally inside a cross-origin frame. In Chromium, site isolation usually renders such a frame in a separate renderer process — an out-of-process iframe — and the driver attaches to it there without any flag.

What genuinely changes is anything that requires a single shared JavaScript realm. page.evaluate() runs in the main frame's realm and cannot read a cross-origin child; you must run frame.evaluate() against that specific frame instead, and you cannot pass a DOM node from one frame's realm into another's. Storage and cookies are partitioned, so a third-party frame may not see the session your login step established. Coordinate-level input through page.mouse is expressed in top-page viewport coordinates, so drag gestures that begin in the parent and end inside a frame need real coordinates from a bounding box rather than element-relative offsets.

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

test('reads state from a cross-origin frame without touching page.evaluate', async ({ page }) => {
  await page.goto('/dashboard');

  const widgetHost = page.locator('iframe[title="Analytics widget"]');
  const widget = widgetHost.contentFrame();

  // Locators work across origins: the driver speaks to that frame directly.
  await expect(widget.getByRole('heading', { name: 'Sessions' })).toBeVisible();

  // page.evaluate() would run in the TOP document's realm and see nothing here.
  // Resolve the Frame for that document and evaluate inside ITS realm instead.
  const frame = page.frame({ url: /widget\.vendor\.example/ });
  expect(frame).not.toBeNull();
  const reportedOrigin = await frame!.evaluate(() => window.location.origin);
  expect(reportedOrigin).not.toBe(new URL(page.url()).origin);

  // owner() gives the <iframe> element back, which is what you need for
  // geometry — lazy embeds do not load until they enter the viewport.
  await widget.owner().scrollIntoViewIfNeeded();
});

Resist the temptation to launch Chromium with --disable-web-security to "fix" cross-origin frames. It changes the security model your application runs under, so the thing you tested is no longer the thing you ship, and it masks real integration bugs such as a missing allow attribute on the embed. The cases where a restriction is genuine — sandbox attributes, X-Frame-Options refusals, storage partitioning breaking a vendor's session — are worked through in Handling Cross-Origin Iframe Restrictions.

Frame lifecycle, waiting, and the detach race

Frames are not static. A frame attaches with src unset or pointing at about:blank, then navigates to its real document, then may navigate again after a token exchange, and finally detaches when the component unmounts. Playwright surfaces this as three page-level events — frameattached, framenavigated and framedetached — and every intermittent iframe failure is a mismatch between where your action landed and where the frame was in that sequence.

Frame lifecycle timeline and the safe action window A timeline running from frameattached through framenavigated and content ready to framedetached, marking the window in which a locator inside the frame can safely act. Frame lifecycle and when a locator inside it can act frameattached about:blank framenavigated real document loads content ready locators resolve framedetached handle is dead acting here throws a detach error safe action window stale handle A FrameLocator re-enters the frame on every retry, so it recovers when the embed remounts; a Frame captured before a remount keeps pointing at the document that no longer exists.
Anchor your first interaction on a web-first assertion inside the frame so the action lands in the ready window rather than on a blank or detached document.

The reliable pattern is to make your first frame interaction an assertion on something that only exists once the frame's real document has rendered. That single line replaces waitForTimeout, waitForLoadState('networkidle') and every other proxy for readiness, for the reasons laid out in Waiting for Network Idle vs Element State.

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

test('waits for the frame document rather than a fixed delay', async ({ page }) => {
  // Record navigations so a failure tells you WHICH document the frame ended on.
  const navigations: string[] = [];
  page.on('framenavigated', (frame) => navigations.push(frame.url()));

  await page.goto('/support');

  const chatHost = page.locator('iframe[title="Support chat"]');

  // The embed is loading="lazy": its document never starts fetching until the
  // element enters the viewport, so scroll the HOST element first.
  await chatHost.scrollIntoViewIfNeeded();

  const chat = chatHost.contentFrame();

  // First interaction is an assertion on real content. This spans attach,
  // about:blank, navigation and render in one auto-retrying wait.
  await expect(chat.getByRole('heading', { name: 'How can we help?' })).toBeVisible({
    timeout: 20_000, // third-party embeds are slower than your own app
  });

  await chat.getByRole('textbox', { name: 'Message' }).fill('Order 4821 is late');
  await chat.getByRole('button', { name: 'Send' }).click();
  await expect(chat.getByText('Order 4821 is late')).toBeVisible();

  // If this ever fails, the recorded list shows whether the frame reached the
  // vendor's document at all or stalled on about:blank.
  expect(navigations.some((url) => url.includes('/widget/'))).toBe(true);
});

Actions that stay at page level even inside a frame

Some browser interactions are owned by the page, not by the frame that triggered them, and reaching for a frame-scoped equivalent is a common dead end. A JavaScript dialog opened by frame code fires page.on('dialog'). A download started by a link inside a frame fires page.on('download'). A file input inside a frame still opens the browser's file chooser, which surfaces as page.on('filechooser'). Keyboard input through page.keyboard goes to whatever element currently has focus, wherever it lives.

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

test('handles a download and a file input that live inside a frame', async ({ page }) => {
  await page.goto('/documents');

  const viewer = page.locator('iframe[name="doc-viewer"]').contentFrame();

  // The download EVENT is page-scoped even though the click happens in a frame.
  const downloadPromise = page.waitForEvent('download');
  await viewer.getByRole('link', { name: 'Download PDF' }).click();
  const download = await downloadPromise;
  expect(download.suggestedFilename()).toMatch(/\.pdf$/);

  // setInputFiles is locator-scoped, so it works directly on the frame's input
  // and never opens a real OS dialog.
  await viewer.locator('input[type="file"]').setInputFiles('fixtures/signed.pdf');
  await expect(viewer.getByText('signed.pdf')).toBeVisible();
});

setInputFiles() is the exception worth remembering: because it is a locator method it works identically inside a frame and, as covered in File Uploads & Downloads, it bypasses the native dialog entirely. If the vendor hides the input behind a styled button, target the input directly rather than clicking the button and racing the chooser event.

Stubbing and blocking third-party frames

Third-party embeds are the single largest source of network-dependent flake, because a vendor's outage becomes your red build. Route handlers registered on the browser context apply to requests from every frame in that context, including out-of-process ones, which makes context.route() the safer registration point than page.route() when an embed may be isolated in its own renderer.

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

test('serves a deterministic embed and blocks unrelated third parties', async ({ context, page }) => {
  // Context-level routing reaches every frame, including cross-origin ones.
  await context.route('**/widget.vendor.example/embed*', async (route) => {
    // Fulfil the frame's own document so the embed renders from a fixture.
    await route.fulfill({
      status: 200,
      contentType: 'text/html',
      body: '<!doctype html><h1>Sessions</h1><button>Zoom in</button>',
    });
  });

  // Kill analytics and ad frames outright so they cannot slow the run or flake.
  await context.route(/(googletagmanager|doubleclick|hotjar)\./, (route) => route.abort());

  await page.goto('/dashboard');

  const widget = page.locator('iframe[title="Analytics widget"]').contentFrame();
  await expect(widget.getByRole('heading', { name: 'Sessions' })).toBeVisible();
});

Inside a handler, route.request().frame() tells you which frame issued the request, which lets one handler behave differently for the main document and for an embed. When the vendor's flow is too complex to hand-write, record it once and replay it — the workflow in Recording and Replaying HAR Files captures frame traffic along with everything else and gives you a byte-accurate replay without a live dependency.

Frames versus shadow roots at a glance

Because both look like "content the parent cannot select into", it is worth fixing the differences in one place. The table below is the mental model to keep: shadow encapsulation is a same-document convention that Playwright's engine transparently pierces, while a frame is a genuine document boundary that requires an explicit hop.

What crosses a shadow boundary versus an iframe boundary A comparison table of four targeting mechanisms showing which of them cross an open shadow root and which cross an iframe document boundary. Which targeting mechanisms cross which boundary Mechanism Shadow root Iframe chained page.locator() calls crosses open roots does not cross getByRole() from the top page reaches controls does not reach document.querySelector() blocked by the root blocked by origin the driver frame binding not applicable crosses any origin
Only the driver's own frame binding crosses a document boundary, which is why an explicit hop is required for embeds but never for open shadow roots.

Hybrid widgets combine both: a web component whose shadow root contains an iframe, or an embed whose document is built from custom elements. Order the hops to match the nesting — chain into the host, then call contentFrame(), or hop into the frame and then chain — and each step behaves exactly as it does in isolation.

Failure modes and how to read them

Timeout 30000ms exceeded with a call log ending in waiting for locator('iframe[...]').contentFrame().getByRole(...). The chain never resolved. Split it: assert toBeVisible() on the host locator first. If the host is invisible, the embed element never mounted — check whether it is loading="lazy" and off-screen, or gated behind a consent banner you have not dismissed. If the host is visible but the inner query still times out, the frame element exists while its document did not load; check the browser console for an X-Frame-Options or frame-ancestors refusal, which the vendor's server sends and which leaves you with a permanently blank frame.

strict mode violation: locator('iframe[title="Payments"]') resolved to 2 elements. Some vendors keep a hidden preload frame alongside the visible one. Do not reach for .first() — the hidden frame often sorts first in document order. Narrow by an additional attribute, or scope the host locator to the section that contains the visible embed.

Frame was detached. You captured a Frame object, or held a chain across a remount. The widget tore down its iframe — typically after a token refresh or a step change — and your handle points at a document that no longer exists. Rebuild the chain from page and let it re-resolve, and stop storing Frame objects in page-object fields.

Execution context was destroyed, most likely because of a navigation. A frame.evaluate() was in flight while the frame navigated. Wrap the read in expect.poll() so it retries after the navigation settles, or wait on frame.waitForURL() for the post-navigation URL before evaluating.

Actions land on the wrong element or silently do nothing. A transparent overlay in the parent document is covering the embed. Playwright's actionability check runs hit-testing in the frame's own document, so a parent-level overlay is not always detected; take a screenshot at failure and look at what is on top. The frame-aware view in the Trace Viewer & Debugging tooling shows the DOM snapshot per frame, which resolves this in seconds.

CI/CD considerations

Frame-heavy suites fail differently in CI than they do locally, and the difference is almost always network and privacy configuration rather than the code. Third-party embeds add uncontrolled latency, so set a longer expect.timeout for the specs that touch them rather than raising it globally and slowing every failure in the suite. Keep the raised timeout scoped with a project or a test.describe override, in line with the timeout budgeting in CI/CD Integration.

Decide deliberately, per spec, whether the embed is under test or merely present. In a checkout test the payment frame is the subject and must load for real against the vendor's sandbox. In a navigation test it is noise: abort its requests at the context level so the run neither waits for it nor fails when the vendor deploys. Encoding this as two fixtures — one that stubs all third parties and one that permits a named allowlist — makes the choice explicit instead of accidental.

Storage partitioning is the CI-specific trap. A fresh browser context in CI has no prior relationship with the third-party origin, so an embed that "just works" on your machine — where you have visited the vendor before — may hit a consent wall or a partitioned-cookie path in the pipeline. Seed whatever the embed needs through storageState, or stub the consent response, rather than adding a retry and hoping.

Finally, capture traces on first retry for these specs specifically. A frame failure is hard to reconstruct from a log line because the interesting state is inside a document you cannot see; the trace preserves each frame's DOM snapshot and its network activity, which is usually enough to tell a vendor outage from a selector regression without reproducing anything. Pair that with the quarantine discipline in Flaky Test Management so a vendor's bad afternoon does not block your merge queue.

Deep dives beneath this guide

Three focused walkthroughs extend the patterns above with complete, runnable procedures.

Automating Elements Inside Nested Iframes works through frames stacked several levels deep, including how to keep multi-hop chains readable and how to diagnose which level of the stack actually failed.

Handling Cross-Origin Iframe Restrictions separates the restrictions that genuinely bind an automation driver — sandbox attributes, framing refusals, partitioned storage — from the ones that only apply to page scripts.

Testing Third-Party Payment Iframes covers hosted card fields with generated frame names, sandbox test cards, and the 3-D Secure challenge frame that appears mid-flow.

Frequently Asked Questions

Why does getByRole find nothing inside my iframe?

Role queries read a computed accessibility tree, and the browser builds one tree per document. An <iframe> renders a separate document, so its controls appear in that frame's tree rather than the top page's — a top-level getByRole() is searching a tree the control was never added to. Hop into the frame first with page.locator('iframe[...]').contentFrame() and run the identical role query against the returned FrameLocator; it will resolve immediately because you are now reading the right tree.

What is the difference between FrameLocator and Frame?

FrameLocator is a lazy description, like every other locator: it re-finds the iframe element and re-enters its document on each retry, so it recovers automatically when a widget remounts its embed. Frame is a resolved handle to a frame that exists at the moment you asked for it, returned by page.frame() or page.frames(), and it becomes permanently invalid once that frame detaches. Use FrameLocator for selecting and acting on elements, and Frame only when you need frame-level APIs such as waitForURL(), waitForLoadState() or a scoped evaluate().

Do I need special flags to automate a cross-origin iframe?

No. Playwright attaches to each frame through the browser's debugging protocol rather than by injecting script into the top page, so the same-origin policy — which constrains page JavaScript — does not constrain locators, actions or assertions. Chromium's site isolation puts such frames in a separate renderer process and that is handled transparently. Launching with --disable-web-security changes the security model your app runs under and hides genuine integration bugs, so treat it as a signal that something else is wrong rather than a fix.

How do I target an iframe whose id changes on every page load?

Match the part the vendor commits to. If the id has a stable prefix such as __privateStripeFrame, use an attribute-prefix selector like iframe[id^="__privateStripeFrame"]. If a title or name attribute exists, prefer it, because those are user-facing and vendors change them rarely. When nothing on the element is stable, identify the frame by the document it loaded using page.frame({ url: /vendor\.example/ }). Avoid positional selection entirely — an injected analytics frame will silently shift the index.

Can Playwright take a screenshot of just the iframe content?

Yes. locator.screenshot() works on any locator inside a frame, so you can capture a single control or a container within the embedded document. To capture the embed as it appears in the parent layout, call screenshot() on the <iframe> element locator itself, which you can retrieve from an existing chain with frameLocator.owner(). Full-page screenshots include frame content as rendered, so no extra step is needed for visual checks that span both documents.

Back to overview