← Blog

"Playwright Studio Recorder — Handling SPAs, Shadow DOM, Iframes and Dynamic Content"

The Recorder goes beyond simple click-and-type. Learn how it captures single-page app routing, shadow DOM elements, nested iframes, file uploads, drag-and-drop, and complex multi-step flows.

reading now
views
comments

Series Navigation

Getting Started with Playwright Studio

Building Assertions with Playwright Studio


How the Recorder Works Under the Hood

When you click Record, Playwright Studio injects a small script into the active page. This script:

  1. Attaches event listeners to every user interaction (click, input, keydown, navigation)
  2. Identifies the best locator for each target element
  3. Translates events into Playwright API calls in real time
  4. Streams the generated code into the DevTools panel

The key difference from older recorders: Playwright Studio uses Playwright's own locator strategy — the same algorithm Playwright's codegen uses — so generated code works reliably in CI without manual fixing.


Single-Page Applications (React, Vue, Angular)

SPAs don't reload the page on navigation — they update the DOM in place and change the URL via the History API. Basic recorders miss this. Playwright Studio detects URL changes and captures them:

// Recorded from a React SPA navigation:
await page.click('nav a[href="/products"]');
await expect(page).toHaveURL('/products');  // ← captures the URL change

// And subsequent interactions within the new "page":
await page.click('.product-card:first-child');
await expect(page).toHaveURL(/\/products\/\d+/);

What to do if a click doesn't trigger a recorded navigation:

Some SPAs use custom router events. After clicking, manually type the URL into the DevTools console to confirm where the page actually went, then edit the generated code to add an explicit URL assertion.


Shadow DOM Elements

Shadow DOM is used by Web Components, Lit, and some design systems (Material Web, Shoelace). Standard CSS selectors cannot pierce shadow roots. Playwright's locator() can.

Playwright Studio detects shadow DOM automatically and generates pierce-capable locators:

// Standard element (no shadow DOM):
await page.click('#submit-button');

// Shadow DOM element — Playwright Studio generates:
await page.locator('my-button').locator('button').click();
// or using the >> combinator:
await page.locator('my-form >> button[type="submit"]').click();

Tip: If the element is inside a deeply nested shadow root and the generated locator fails, switch to the Assertion Builder to visually inspect what locator Playwright Studio resolved to.


iFrames

Frames are separate browsing contexts. You must explicitly switch to a frame before interacting with its contents.

Playwright Studio detects frame context changes automatically:

// Interacting with an iframe (e.g. a payment form)
const frame = page.frameLocator('#payment-iframe');
await frame.locator('#card-number').fill('4111111111111111');
await frame.locator('#expiry').fill('12/26');
await frame.locator('#cvv').fill('123');
await page.click('#pay-now');  // back on main page

If the iframe has no id or name, Playwright Studio falls back to frameLocator('iframe:first-of-type') — you may want to refine this to a more specific selector.


File Uploads

// Playwright Studio records the file chooser interaction:
const fileChooserPromise = page.waitForEvent('filechooser');
await page.click('input[type="file"]');
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('/path/to/file.pdf');

Note: The recorded path will be your local machine's path. Change it to a path that works in CI — typically relative to the project root:

await fileChooser.setFiles('./fixtures/test-document.pdf');

Drag and Drop

// Recorded drag from source to target:
await page.dragAndDrop('#draggable-item', '#drop-zone');

// For precise pixel-level drag:
await page.hover('#draggable-item');
await page.mouse.down();
await page.mouse.move(500, 300, { steps: 10 });
await page.mouse.up();

Keyboard Shortcuts

Hold a modifier key while clicking and the recorder captures it:

// Ctrl+Click (multi-select):
await page.click('.list-item', { modifiers: ['Control'] });

// Shift+Click (range select):
await page.click('.list-item:last-child', { modifiers: ['Shift'] });

// Direct keyboard input:
await page.keyboard.press('Control+A');  // select all
await page.keyboard.press('Control+C');  // copy

Locator Priority — How Studio Picks the Best Selector

The recorder scores and ranks available locators for each element:

Locator priority (most stable → least stable):

1. data-testid / data-qa / data-cy   ← most stable, add to your app
2. ARIA role + name                  ← semantic and resilient
3. ARIA label / placeholder
4. Text content (for buttons/links)
5. id attribute
6. name attribute
7. CSS class (non-generated)
8. nth-child / positional CSS        ← least stable, avoid

Best practice: Ask your development team to add data-testid attributes to interactive elements. This makes every generated locator stable and the recorder output clean:

<button data-testid="submit-login">Login</button>
<input data-testid="email-input" type="email">

Generated code then becomes:

await page.getByTestId('email-input').fill('user@example.com');
await page.getByTestId('submit-login').click();

Cleaning Up Generated Code

The recorder is comprehensive — it captures everything including accidental clicks. After recording, review the code and:

  1. Remove any accidental actions (misclicks, page scrolls)
  2. Replace hardcoded test data with variables
  3. Add a beforeEach if the same setup appears in multiple tests
// Before cleanup:
test('checkout', async ({ page }) => {
  await page.goto('https://shop.example.com');
  await page.click('.hamburger-menu');   // ← accidental, remove
  await page.goto('https://shop.example.com');
  await page.fill('#email', 'test@example.com');
  await page.fill('#password', 'password123');
  await page.click('#login');
  // ... rest of test
});

// After cleanup:
const BASE_URL = 'https://shop.example.com';
const TEST_USER = { email: 'test@example.com', password: 'password123' };

test.beforeEach(async ({ page }) => {
  await page.goto(BASE_URL);
  await page.fill('#email', TEST_USER.email);
  await page.fill('#password', TEST_USER.password);
  await page.click('#login');
});

test('checkout flow', async ({ page }) => {
  // test starts already logged in
});

What's Next

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000