Module 3: Actions & User Interaction
You’ve mastered locators. Now it’s time to actually interact with the page. This module covers every user action you’ll write in a real Playwright test — from a simple click to handling a browser dialog or uploading a file inside an iframe.
🎬 Video coming soon
Click actions
Section titled “Click actions”.click() is the most common action in Playwright. Before clicking, Playwright automatically waits for the element to be visible, enabled, stable, and not obscured. You don’t need a manual waitFor() before most clicks.
// Basic click — the bread and butter of every testawait page.getByRole('button', { name: 'Log In' }).click();
// Click a linkawait page.getByRole('link', { name: 'Register' }).click();
// Click a product cardawait page.locator('.product-card').filter({ hasText: 'Wireless Mouse' }).click();Double-click
Section titled “Double-click”Used for inline editing UIs and other elements that distinguish single from double click.
// Double-click an editable table cellawait page.locator('.editable-cell').dblclick();Right-click
Section titled “Right-click”Right-click opens the browser context menu or triggers contextmenu event listeners in the app.
// Right-click to trigger a context menuawait page.locator('.product-card').click({ button: 'right' });Force click
Section titled “Force click”Bypasses Playwright’s actionability checks. Use sparingly — only when you know the element is present but Playwright’s checks are blocking a legitimate interaction.
// Force click — bypasses visibility / actionability checksawait page.locator('#hidden-trigger').click({ force: true });Playwright’s auto-wait means: in 95% of real tests, a plain .click() is all you need. Save the options for edge cases.
Remember: a plain .click() auto-waits (visible, enabled, stable); reach for { force: true } / { button: 'right' } only in edge cases.
fill() vs pressSequentially()
Section titled “fill() vs pressSequentially()”This is both a common interview question and a daily design decision.
fill()
Section titled “fill()”- Clears the field first (no need to triple-click first)
- Dispatches a single
inputevent — instantaneous - Preferred for 95% of form fields
// fill() — clears then sets the value in one operationawait page.locator('#email').fill('customer@test.io');await page.locator('#password').fill('customer123');
// Works on any element that accepts text inputawait page.getByLabel('Shipping Name').fill('Jane Doe');await page.getByLabel('Shipping Address').fill('123 Main Street');await page.getByLabel('Shipping City').fill('Portland');await page.getByLabel('Shipping Zip Code').fill('97201');pressSequentially()
Section titled “pressSequentially()”- Types character by character, firing
keydown,keypress,keyupfor each character - Does not clear the field first — appends to existing text
- Slower — use only when
fill()doesn’t trigger the right behavior
// pressSequentially() — simulates real keystrokes, character by characterawait page.locator('#email').pressSequentially('customer@test.io', { delay: 30 });
// Common use case: search fields with autocomplete / debounced handlersawait page.locator('#search').pressSequentially('Wire', { delay: 50 });// Each keystroke fires events that the autocomplete component listens forWhen pressSequentially() is necessary: React-Select, typeahead components, rich text editors, or any input that reacts to each individual keystroke rather than the final value.
Gotcha: If a field already has pre-filled text, pressSequentially() appends to it. fill() always clears first.
// Field starts with "old value" — pressSequentially() appends:await page.locator('#name').pressSequentially(' new suffix'); // result: "old value new suffix"
// fill() ignores whatever was there before:await page.locator('#name').fill('fresh value'); // result: "fresh value"Remember: fill() for ~95% of fields (clears + one event); pressSequentially() only when each keystroke must fire.
Keyboard press()
Section titled “Keyboard press()”.press() sends a single key press to a focused element. page.keyboard.press() sends the key globally, regardless of which element has focus.
// Press Enter to submit a form (locator-scoped)await page.locator('#password').press('Enter');
// Press Tab to move to the next focusable fieldawait page.locator('#shipping_name').press('Tab');
// Escape to close a modal or dropdownawait page.keyboard.press('Escape');
// Backspace to delete charactersawait page.keyboard.press('Backspace');
// Arrow keys for navigationawait page.keyboard.press('ArrowDown');await page.keyboard.press('ArrowUp');Key combinations
Section titled “Key combinations”Use + to combine modifier keys with other keys:
// Select all text in the focused elementawait page.keyboard.press('Control+A');
// Copy selected textawait page.keyboard.press('Control+C');
// Pasteawait page.keyboard.press('Control+V');
// Delete selected textawait page.keyboard.press('Control+A');await page.keyboard.press('Delete');Key name casing matters: The correct names are 'Control', 'Shift', 'Alt', 'Meta' — not 'ctrl' or 'shift'.
Shift+Click and hold-down patterns
Section titled “Shift+Click and hold-down patterns”// Hold Shift, click multiple items for multi-selectionawait page.keyboard.down('Shift');await page.locator('.list-item').nth(3).click();await page.keyboard.up('Shift');Accessibility tip: Tab navigation is a powerful tool for testing keyboard accessibility. Navigate through a form without touching the mouse to verify the tab order is logical.
Remember: .press('Enter') is locator-scoped, page.keyboard.press(...) is global; key names are capitalized (Control, not ctrl).
Checkboxes & radio buttons
Section titled “Checkboxes & radio buttons”Use .check() and .uncheck() rather than .click() — they read like documentation and validate intent.
// Check a checkboxawait page.getByLabel('Remember me').check();
// Uncheck a checkboxawait page.getByLabel('Subscribe to newsletter').uncheck();
// Set checkbox to a specific state regardless of current stateawait page.getByLabel('Terms and conditions').setChecked(true);await page.getByLabel('Marketing emails').setChecked(false);Difference: .check() throws if the checkbox is already checked (self-validating). .click() toggles regardless. .setChecked(true/false) sets a specific state without throwing.
Radio buttons
Section titled “Radio buttons”Radio groups share the same name attribute. Use .check() on the option you want to select.
// Select a radio buttonawait page.getByLabel('Express Shipping').check();await page.getByLabel('Standard Shipping').check();
// Assert which radio is currently selectedawait expect(page.getByLabel('Express Shipping')).toBeChecked();Assertions for checkboxes and radios
Section titled “Assertions for checkboxes and radios”// Assert a checkbox is checkedawait expect(page.getByLabel('Remember me')).toBeChecked();
// Assert a checkbox is NOT checkedawait expect(page.getByLabel('Newsletter')).not.toBeChecked();Remember: prefer .check()/.uncheck() (self-validating) over .click(); .setChecked(bool) forces a specific state.
Native <select> dropdowns
Section titled “Native <select> dropdowns”selectOption() is specifically for native HTML <select> elements. It dispatches the proper change events that the browser and frameworks expect.
// Select by value (the option's `value` attribute)await page.locator('#category').selectOption('electronics');
// Select by visible label textawait page.locator('#category').selectOption({ label: 'Electronics' });
// Select by index (0-based)await page.locator('#sort').selectOption({ index: 2 });
// Multiple selection — pass an arrayawait page.locator('#tags').selectOption(['javascript', 'testing', 'playwright']);TestMarket example — filter products:
await page.goto('/products');
// Filter by categoryawait page.locator('#category').selectOption('electronics');
// Sort by price descendingawait page.locator('#sort').selectOption('price_desc');
// Apply the filtersawait page.getByRole('button', { name: 'Apply Filters' }).click();Assert the current selection:
await expect(page.locator('#category')).toHaveValue('electronics');await expect(page.locator('#sort')).toHaveValue('price_desc');Common mistake: selectOption('Electronics') with a capital E when the value attribute is 'electronics' (lowercase). Values are case-sensitive.
Remember: selectOption() is only for real <select> elements; match the value (case-sensitive), not the visible label.
Custom dropdowns
Section titled “Custom dropdowns”Not all dropdowns are native <select> elements. Modern UI frameworks (React-Select, Headless UI, Material UI) build their own. You cannot use selectOption() on these.
How to tell the difference: Open DevTools → Elements tab. If you see <select> with <option> children → use selectOption(). If you see <div>, <button>, <ul> with <li> → custom dropdown.
The pattern for custom dropdowns is always:
// Step 1: Click to open the dropdownawait page.locator('.dropdown-trigger').click();
// Step 2: Wait for the menu to appear (it may animate in)await page.locator('.dropdown-menu').waitFor({ state: 'visible' });
// Step 3: Click the option you wantawait page.locator('.dropdown-option:has-text("Option Name")').click();
// Step 4 (optional): verify the menu is now closedawait expect(page.locator('.dropdown-menu')).toBeHidden();Debugging custom dropdowns:
- Menu animating in? Wait for it properly with
await page.locator('.dropdown-menu').waitFor({ state: 'visible' })— never a fixedwaitForTimeout, which is slow and flaky. - Option outside the viewport? Scroll before clicking.
- Menu closing before you can click? Usually a timing issue — wait for the option to be visible first;
{ force: true }is a last resort that bypasses Playwright’s safety checks.
// Wait for the option, then click — robust against animationconst option = page.locator('.dropdown-option:has-text("Option Name")');await option.waitFor({ state: 'visible' });await option.click();Remember: click → waitFor({ state: 'visible' }) → click the option — never a fixed waitForTimeout.
Hover & tooltips
Section titled “Hover & tooltips”.hover() moves the mouse over an element, triggering mouseenter and mousemove events. Tooltips, dropdown menus, and info popups revealed on hover all become testable.
// Hover over a product card to reveal the "Quick View" buttonawait page.locator('.product-card').filter({ hasText: 'Wireless Mouse' }).hover();
// After hovering, the tooltip/revealed element is visibleawait expect(page.locator('#hover-tooltip')).toBeVisible();await expect(page.locator('#hover-tooltip')).toContainText('Tooltip content');
// Move away — tooltip hidesawait page.locator('h1').hover();await expect(page.locator('#hover-tooltip')).toBeHidden();Positioned hover — hover at specific coordinates within an element (useful for charts or image maps):
// Hover at exact pixel offset within the elementawait page.locator('.chart-area').hover({ position: { x: 100, y: 50 } });After hovering, wait for the revealed element:
await page.locator('#info-icon').hover();
// Tooltip has a CSS transition — wait for it to be visibleawait page.locator('.tooltip').waitFor({ state: 'visible' });await expect(page.locator('.tooltip')).toContainText('Additional info');Remember: .hover(), then waitFor/assert the revealed element is visible before acting on it.
Date pickers
Section titled “Date pickers”Native <input type="date">
Section titled “Native <input type="date">”Fill with an ISO format string (YYYY-MM-DD). This is the most reliable approach.
// Native date input — use ISO format, always worksawait page.locator('#start-date').fill('2025-06-15');await page.locator('#end-date').fill('2025-07-31');Custom date picker widgets
Section titled “Custom date picker widgets”For React DatePicker, flatpickr, Pikaday, or similar — you must interact with the widget’s UI: click the trigger, navigate months, click a day cell.
// Step 1: Open the calendarawait page.locator('[data-testid="date-trigger"]').click();
// Step 2: Navigate to the correct month// The target: June 2025. Current month may be different.await page.locator('button[aria-label="Next month"]').click();
// Step 3: Click the day cellawait page.locator('.calendar-day:has-text("15")').click();
// Step 4: Verify selectionawait expect(page.locator('#start-date')).toHaveValue('2025-06-15');For navigating multiple months programmatically:
// Navigate forward until we reach the target month/year heading.// Cap the loop so a wrong target can never spin forever, and// null-guard textContent() (it can return null).const target = 'June 2025';for (let i = 0; i < 24; i++) { const heading = await page.locator('.calendar-header').textContent(); if (heading?.includes(target)) break; await page.locator('button[aria-label="Next month"]').click();}await page.locator('.calendar-day:has-text("15")').click();Remember: native <input type="date"> → fill('YYYY-MM-DD'); custom widgets → click through the calendar UI.
Drag & drop
Section titled “Drag & drop”Playwright offers three approaches. Start with dragTo() — fall back to the manual sequence if the framework doesn’t respond correctly.
Method 1: dragTo() (simplest)
Section titled “Method 1: dragTo() (simplest)”// Drag an element to a targetawait page.locator('#drag-item').dragTo(page.locator('#drop-zone'));Method 2: Manual mouse sequence (most reliable)
Section titled “Method 2: Manual mouse sequence (most reliable)”Some drag-and-drop libraries (react-beautiful-dnd, SortableJS, Angular CDK) use custom event handling that dragTo() doesn’t fire correctly. The manual sequence fires mousedown, mousemove, mouseup — which these libraries listen for.
const source = page.locator('.drag-handle').nth(0);const target = page.locator('.drop-target');
const sourceBox = await source.boundingBox();const targetBox = await target.boundingBox();
await page.mouse.move( sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);await page.mouse.down();await page.mouse.move( targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 } // smooth movement — some libraries need this);await page.mouse.up();Method 3: dragTo() with coordinates
Section titled “Method 3: dragTo() with coordinates”// Drag to a specific position within the targetawait page.locator('#drag-item').dragTo(page.locator('#drop-zone'), { targetPosition: { x: 10, y: 10 },});When dragTo() fails: Check the browser console for errors. If the DnD library uses synthetic events, switch to Method 2.
Remember: try dragTo() first; fall back to the manual mouse.down/move/up sequence for custom drag-and-drop libraries.
File upload
Section titled “File upload”setInputFiles() replaces OS file dialogs entirely. No AutoIT, no Robot class, no OS-level automation.
// Upload a single file by pathawait page.locator('input[type="file"]').setInputFiles('/path/to/file.pdf');
// Upload multiple filesawait page.locator('input[type="file"]').setInputFiles([ '/path/to/file1.txt', '/path/to/file2.txt',]);
// Upload from a Buffer — no physical file neededawait page.locator('input[type="file"]').setInputFiles({ name: 'data.csv', mimeType: 'text/csv', buffer: Buffer.from('id,name,price\n1,Widget,9.99\n2,Gadget,19.99'),});
// Clear the file selectionawait page.locator('input[type="file"]').setInputFiles([]);Portable paths — use path.join so tests work on any OS:
import path from 'node:path';
await page.locator('input[type="file"]').setInputFiles( path.join(import.meta.dirname, '..', 'fixtures', 'test-upload.txt'));Hidden file input: If the <input type="file"> is styled with display: none or opacity: 0, use { force: true }:
await page.locator('input[type="file"]').setInputFiles('file.pdf', { force: true });Verify the file was selected:
const files = await page.locator('input[type="file"]').evaluate(el => Array.from(el.files).map(f => f.name));expect(files).toContain('test-upload.txt');Remember: setInputFiles() replaces the OS dialog — pass a path, an array, or { name, mimeType, buffer }.
Dialogs & popups
Section titled “Dialogs & popups”page.on('dialog') is an event listener. It must be registered before the action that triggers the dialog. If you register it after clicking the delete button, the dialog has already fired and was dismissed automatically.
// ✅ CORRECT — register before the triggering actionpage.on('dialog', async (dialog) => { await dialog.accept(); // accept / confirm});await page.locator('button:has-text("Delete")').click();
// ❌ WRONG — dialog fires before this handler is registeredawait page.locator('button:has-text("Delete")').click();page.on('dialog', async (dialog) => { await dialog.accept(); // too late});Dialog types and handling
Section titled “Dialog types and handling”// Accept a confirm dialogpage.on('dialog', async (dialog) => { expect(dialog.message()).toContain('Are you sure'); await dialog.accept();});
// Dismiss (cancel) a confirm dialogpage.on('dialog', async (dialog) => { await dialog.dismiss();});
// Respond to a prompt dialog with input textpage.on('dialog', async (dialog) => { await dialog.accept('typed input here');});
// Handle different dialog types selectivelypage.on('dialog', async (dialog) => { if (dialog.type() === 'confirm') { await dialog.accept(); } else if (dialog.type() === 'prompt') { await dialog.accept('test input'); } else { await dialog.dismiss(); // alert }});Best practice: Register the dialog handler in beforeEach or inside your page object constructor — not inside individual test functions. This ensures the handler is always ready before any dialog-triggering action.
// In a page object constructorclass AdminProductsPage { constructor(page) { this.page = page; // Auto-accept all delete confirmations page.on('dialog', async (dialog) => { await dialog.accept(); }); }}Remember: register page.on('dialog', …) before the click that triggers the dialog.
Iframes
Section titled “Iframes”page.frameLocator() returns a FrameLocator — all Playwright locator methods are available on it. Use it exactly as you would use page for locators.
// Get a FrameLocator for the iframeconst frame = page.frameLocator('#payment-iframe');
// Use all normal locator methods inside the iframeawait frame.getByLabel('Card number').fill('4111 1111 1111 1111');await frame.getByLabel('Expiry date').fill('12/28');await frame.getByLabel('CVV').fill('123');await frame.getByRole('button', { name: 'Pay Now' }).click();Finding the iframe selector:
// iframe with an idconst frame = page.frameLocator('#my-iframe');
// iframe without an id — use src or other attributeconst frame = page.frameLocator('iframe[src="/payment"]');const frame = page.frameLocator('iframe[title="Payment form"]');Nested iframes:
// Chain frameLocators for iframes inside iframesconst innerFrame = page.frameLocator('#outer-frame').frameLocator('#inner-frame');await innerFrame.locator('#card-number').fill('4111...');What frameLocator does NOT have: goto(), evaluate() — it is not a Page object. For those operations, use page.frame():
// page.frame() by name or URL — for advanced scenariosconst frame = page.frame({ url: /payment/ });await frame.evaluate(() => document.title);Cross-origin iframes: Playwright handles these transparently. frameLocator() works regardless of the iframe’s origin.
Remember: use frameLocator() for elements inside an iframe; it has all the locator methods but not goto()/evaluate() (use page.frame() for those).
Action cheat-sheet
Section titled “Action cheat-sheet”| I want to… | Use |
|---|---|
| Click / double / right / force | .click() · .dblclick() · .click({ button: 'right' }) · .click({ force: true }) |
| Clear + set a field | .fill(value) |
| Type key by key (autocomplete) | .pressSequentially(value) |
| Press a key / combo | .press('Enter') · page.keyboard.press('Control+A') |
| Check / uncheck / set a box | .check() · .uncheck() · .setChecked(bool) |
Native <select> | .selectOption(value / label / index) |
| Custom dropdown | click → waitFor({ state: 'visible' }) → click option |
| Hover | .hover() |
| Drag & drop | .dragTo(target) (fallback: manual mouse.*) |
| Upload a file | .setInputFiles(path / array / { name, buffer }) |
| Browser dialog | page.on('dialog', …) before the trigger |
| Inside an iframe | page.frameLocator(sel) |
Exercises
Section titled “Exercises”These exercises use TestMarket Lab running locally at http://localhost:3000.
Exercise 1: Login form — fill() vs pressSequentially()
Section titled “Exercise 1: Login form — fill() vs pressSequentially()”Create tests/starter/tests/actions/login-actions.spec.js:
import { test, expect } from '@playwright/test';
test.describe('Login form actions', () => {
test('fill() — fast, single event', async ({ page }) => { await page.goto('/auth/login'); await page.locator('#email').fill('customer@test.io'); await page.locator('#password').fill('customer123'); await page.getByRole('button', { name: 'Log In' }).click(); await expect(page.locator('.alert.alert-success')).toBeVisible(); });
test('pressSequentially() — character by character', async ({ page }) => { await page.goto('/auth/login'); await page.locator('#email').pressSequentially('customer@test.io', { delay: 30 }); await page.locator('#password').pressSequentially('customer123', { delay: 30 }); await page.getByRole('button', { name: 'Log In' }).click(); await expect(page.locator('.alert.alert-success')).toBeVisible(); });
test('press Enter to submit', async ({ page }) => { await page.goto('/auth/login'); await page.locator('#email').fill('customer@test.io'); await page.locator('#password').fill('customer123'); await page.locator('#password').press('Enter'); await expect(page.locator('.alert.alert-success')).toBeVisible(); });});Tasks:
- Run with
npx playwright test --headedand observe typing speed:fill()is instantaneous,pressSequentially()types character by character - Comment out
{ delay: 30 }and run again — what changes? - Try
pressSequentially()without clearing first on a pre-filled field to see the append behavior
Exercise 2: Checkout form — fill & submit
Section titled “Exercise 2: Checkout form — fill & submit”Create tests/starter/tests/actions/checkout-actions.spec.js. A beforeEach logs in, adds a product to the cart, then opens the checkout page:
import { test, expect } from '@playwright/test';
test.describe('Checkout actions', () => { test.beforeEach(async ({ page }) => { await page.goto('/auth/login'); await page.locator('#email').fill('customer@test.io'); await page.locator('#password').fill('customer123'); await page.locator('#login-btn').click();
await page.goto('/products'); await page.locator('[data-testid="add-to-cart"]').first().click(); await page.goto('/checkout'); });
test('fill all shipping fields and place order', async ({ page }) => { await page.locator('#shipping_name').fill('Jane Doe'); await page.locator('#shipping_address').fill('123 Main Street'); await page.locator('#shipping_city').fill('Portland'); await page.locator('#shipping_zip').fill('97201'); await page.locator('#place-order').click(); await expect(page).toHaveURL(/\/orders\/\d+/); await expect(page.locator('h1')).toContainText('Order'); });
test('Tab through the fields, typing each value', async ({ page }) => { await page.locator('#shipping_name').pressSequentially('John Smith', { delay: 20 }); await page.locator('#shipping_name').press('Tab'); await page.locator('#shipping_address').pressSequentially('456 Oak Ave', { delay: 20 }); await page.locator('#shipping_address').press('Tab'); await page.locator('#shipping_city').pressSequentially('Seattle', { delay: 20 }); await page.locator('#shipping_city').press('Tab'); await page.locator('#shipping_zip').pressSequentially('98101', { delay: 20 }); await page.locator('#place-order').click(); await expect(page).toHaveURL(/\/orders\/\d+/); });
test('submit empty form — HTML5 validation blocks', async ({ page }) => { await page.locator('#place-order').click(); await expect(page).toHaveURL('/checkout'); // required fields block submission });});Exercise 3: Product filter — selectOption()
Section titled “Exercise 3: Product filter — selectOption()”Create tests/starter/tests/actions/product-filter-actions.spec.js:
test('filter by category using selectOption', async ({ page }) => { await page.goto('/products'); await page.locator('#category').selectOption('electronics'); await page.getByRole('button', { name: 'Apply Filters' }).click(); expect(await page.locator('.product-card').count()).toBeGreaterThan(0);});
test('select by visible label', async ({ page }) => { await page.goto('/products'); await page.locator('#category').selectOption({ label: 'Electronics' }); await page.getByRole('button', { name: 'Apply Filters' }).click(); expect(await page.locator('.product-card').count()).toBeGreaterThan(0);});
test('combine search + filter + sort', async ({ page }) => { await page.goto('/products'); await page.locator('#search').fill('Wireless'); await page.locator('#category').selectOption('electronics'); await page.locator('#sort').selectOption('price_asc'); await page.getByRole('button', { name: 'Apply Filters' }).click(); await expect(page.locator('.product-card, p:has-text("No products found")')) .toBeVisible();});Tasks:
- Open DevTools → Elements, find
<select id="category">, expand its<option>nodes — note thevalueattributes - Try
selectOption('nonexistent')— what error does Playwright throw? - Try
selectOption('Electronics')with capital E — does it match?
Exercise 4: Keyboard shortcuts — select, copy, delete
Section titled “Exercise 4: Keyboard shortcuts — select, copy, delete”Create tests/starter/tests/actions/keyboard-actions.spec.js:
test('select all and delete with keyboard', async ({ page }) => { await page.goto('/auth/register'); await page.locator('#name').fill('Some text to clear'); await page.locator('#name').click(); await page.keyboard.press('Control+A'); await page.keyboard.press('Delete'); expect(await page.locator('#name').inputValue()).toBe('');});
test('select all, copy, paste with keyboard', async ({ page }) => { await page.goto('/auth/login'); await page.locator('#email').fill('customer@test.io'); await page.locator('#email').click(); await page.keyboard.press('Control+A'); await page.keyboard.press('Control+C'); await page.locator('#email').press('Tab'); await page.keyboard.press('Control+V'); expect(await page.locator('#password').inputValue()).toBe('customer@test.io');});
test('backspace deletes characters', async ({ page }) => { await page.goto('/auth/register'); await page.locator('#name').fill('Product Launch Event'); for (let i = 0; i < 6; i++) { await page.keyboard.press('Backspace'); } expect(await page.locator('#name').inputValue()).toBe('Product Launch');});Exercise 5: File upload — setInputFiles()
Section titled “Exercise 5: File upload — setInputFiles()”Create tests/starter/tests/actions/file-upload-actions.spec.js:
import { test, expect } from '@playwright/test';import path from 'node:path';import fs from 'node:fs';
test('upload a file via path', async ({ page }) => { await page.goto('/auth/register');
const tmpDir = path.join(import.meta.dirname, '..', '..', 'tmp'); if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); const testFile = path.join(tmpDir, 'test-upload.txt'); fs.writeFileSync(testFile, 'Hello from Playwright!');
// Inject a file input (the TestMarket register page has none) await page.evaluate(() => { const input = document.createElement('input'); input.type = 'file'; input.id = 'test-file-upload'; document.body.appendChild(input); });
await page.locator('#test-file-upload').setInputFiles(testFile);
const files = await page.locator('#test-file-upload').evaluate(el => Array.from(el.files).map(f => f.name) ); expect(files).toContain('test-upload.txt');
fs.unlinkSync(testFile);});
test('upload from Buffer — no file on disk', async ({ page }) => { await page.goto('/auth/register');
await page.evaluate(() => { const input = document.createElement('input'); input.type = 'file'; input.id = 'test-buffer-upload'; document.body.appendChild(input); });
await page.locator('#test-buffer-upload').setInputFiles({ name: 'data.csv', mimeType: 'text/csv', buffer: Buffer.from('id,name,price\n1,Widget,9.99\n2,Gadget,19.99'), });
const fileName = await page.locator('#test-buffer-upload').evaluate(el => el.files[0].name ); expect(fileName).toBe('data.csv');});Tasks:
- Observe that
setInputFiles()never opens an OS file dialog - Try uploading a non-existent file path — what error does Playwright throw?
- Try the multi-file variant: pass an array of two paths
Exercise 6: Browser dialogs — page.on('dialog')
Section titled “Exercise 6: Browser dialogs — page.on('dialog')”Create tests/starter/tests/actions/dialog-actions.spec.js. The page is injected with a button that fires native confirm() / prompt() dialogs, so the exercise runs against any page:
import { test, expect } from '@playwright/test';
test.describe('Dialog handling', () => {
test('accept a confirm dialog', async ({ page }) => { await page.goto('/products');
// Inject a "Delete" button that records whether the user confirmed await page.evaluate(() => { const btn = document.createElement('button'); btn.id = 'confirm-btn'; btn.textContent = 'Delete'; btn.addEventListener('click', () => { const ok = confirm('Are you sure you want to delete this item?'); btn.dataset.result = ok ? 'accepted' : 'dismissed'; }); document.body.appendChild(btn); });
// Register the handler BEFORE the click that triggers the dialog page.on('dialog', async (dialog) => { expect(dialog.type()).toBe('confirm'); expect(dialog.message()).toContain('Are you sure'); await dialog.accept(); });
await page.locator('#confirm-btn').click(); await expect(page.locator('#confirm-btn')).toHaveAttribute('data-result', 'accepted'); });
test('dismiss a confirm dialog', async ({ page }) => { await page.goto('/products'); await page.evaluate(() => { const btn = document.createElement('button'); btn.id = 'confirm-btn'; btn.addEventListener('click', () => { const ok = confirm('Delete this item?'); btn.dataset.result = ok ? 'accepted' : 'dismissed'; }); document.body.appendChild(btn); });
page.on('dialog', (dialog) => dialog.dismiss());
await page.locator('#confirm-btn').click(); await expect(page.locator('#confirm-btn')).toHaveAttribute('data-result', 'dismissed'); });
test('respond to a prompt dialog with text', async ({ page }) => { await page.goto('/products'); await page.evaluate(() => { const out = document.createElement('p'); out.id = 'prompt-output'; document.body.appendChild(out); const btn = document.createElement('button'); btn.id = 'prompt-btn'; btn.addEventListener('click', () => { out.textContent = prompt('Enter a coupon code:') || ''; }); document.body.appendChild(btn); });
page.on('dialog', (dialog) => dialog.accept('SAVE10'));
await page.locator('#prompt-btn').click(); await expect(page.locator('#prompt-output')).toHaveText('SAVE10'); });});Tasks:
- Move the
page.on('dialog', …)line to after the click — the dialog auto-dismisses and the assertion fails. This is the #1 dialog mistake. - Add
console.log(dialog.type())inside the handler to seeconfirmvspromptvsalert.
Exercise 7: Iframes — frameLocator()
Section titled “Exercise 7: Iframes — frameLocator()”Create tests/starter/tests/actions/iframe-actions.spec.js. A beforeEach injects an iframe (a stand-in for a real payment iframe) so you can practice frameLocator() against any page:
import { test, expect } from '@playwright/test';
test.describe('Iframe interactions', () => { test.beforeEach(async ({ page }) => { await page.goto('/products'); await page.evaluate(() => { const iframe = document.createElement('iframe'); iframe.id = 'payment-iframe'; iframe.srcdoc = '<label>Card number <input id="card" /></label>' + '<label>CVV <input id="cvv" /></label>' + '<button id="pay">Pay Now</button>'; document.body.appendChild(iframe); }); });
test('fill fields inside the iframe', async ({ page }) => { const frame = page.frameLocator('#payment-iframe'); await frame.locator('#card').fill('4111 1111 1111 1111'); await frame.locator('#cvv').fill('123'); await expect(frame.locator('#card')).toHaveValue('4111 1111 1111 1111'); await frame.getByRole('button', { name: 'Pay Now' }).click(); });
test('page-level selectors cannot see inside the frame', async ({ page }) => { // #card lives inside the iframe — a page locator finds nothing await expect(page.locator('#card')).toHaveCount(0); // …but the same selector via frameLocator finds it await expect(page.frameLocator('#payment-iframe').locator('#card')).toHaveCount(1); });});Tasks:
- Query
#cardfrompagedirectly (noframeLocator) and watch it time out — iframe content is invisible to page-level locators. - Try calling
.goto()on the result offrameLocator()— note that aFrameLocatorhas no navigation methods.
Self-check questions
Section titled “Self-check questions”- What is the fundamental difference between
fill()andpressSequentially()? - When would you use
selectOption()vs the click → wait → click pattern? - How does
setInputFiles()differ from using an OS file dialog? - Why must
page.on('dialog', handler)be registered before clicking the button that triggers the dialog? - What method do you use to interact with elements inside an iframe?
- What happens if you call
selectOption('nonexistent_value')? - How do you simulate Ctrl+A (select all) in Playwright?
- What is the difference between
.check()and.setChecked(true)?
Key takeaways
Section titled “Key takeaways”Four rules to carry into every test:
fill()for forms,pressSequentially()for special cases —fill()is faster, always clears first, and triggers the right events for 95% of inputs- Native
<select>=selectOption(), custom dropdown = click → wait → click — always check the DOM before deciding setInputFiles()replaces OS file dialogs entirely — it works with file paths, arrays, and Buffer objects- Dialogs need listeners before triggers; iframes use
frameLocator()— register dialog handlers before the action that opens them
Module 4 covers assertions — you’ll learn the full expect() API and how to write assertions that are both precise and resilient to timing.