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:
- Attaches event listeners to every user interaction (click, input, keydown, navigation)
- Identifies the best locator for each target element
- Translates events into Playwright API calls in real time
- 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:
- Remove any accidental actions (misclicks, page scrolls)
- Replace hardcoded test data with variables
- Add a
beforeEachif 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
});
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.