Day 13: Playwright Selects, Frames, Keyboard, Hover, Drag-and-Drop, and Alerts
This is Day 13 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright Test now. Framework later.
Days 1–7 were the language. Day 8 was the object model. Day 9 typed that object. Day 10 installed Playwright. Day 11 found a field. Day 12 saved a session and read a table. Today the page stops being one document.
I am Pramod Dutta. I teach SDETs in India for a living. The week I open frames, someone always pastes page.locator('#RESULT_TextField-1') from a screenshot of the form they can *see*. Playwright times out. The field is on the screen. The locator is on the wrong document. That is not a wait problem. That is a frame-boundary problem.
This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts later in the stack. This series started at console.log. Day 13 is the first day the spec has to *choose a document* before it chooses a locator.
All labs come from my public fundamentals repo: LearningPlaywrightFundamentals on branch main. I fetched four folders from raw GitHub: tests/08_Web_Select_Frames_Iframe, tests/09_Frame_Iframe, tests/10_Keyboard_Hover_Drag_Drop, and tests/11_JS_Alerts. I quote those files. I will not invent a file that is not there.
Classroom spellings stay. The custom-dropdown labs are 236_Advacne_Select_Frames2.spec.ts and 237_Advacne_Select_Pro.spec.ts — Advacne, as GitHub serves them. The keyboard test title is Keybaord. The iframe variable is vechileFrame. Lab 243 lives in 11_JS_Alerts, not between 242 and 244. I do not rename files to make this post prettier.
If you want the video plus project path after you finish these 21 posts, the course is here: Playwright Automation Mastery. The series hub for every day lives here: JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.
*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Contents
What you will be able to do after Day 13
By the end of this post you can:
- Tell a native
<select>from a custom dropdown and from a React Select widget, and pick the API that matches the markup. - Use
locator.selectOptiononly when the control is a real<select>— and admit that lab235currently leaves that call commented. - Open a custom trigger, pick exact visible text, and close a multi-select with
Escape. - Drive a React Select single, multi, creatable, grouped, and async control using the
data-testidvalues in labs237and238. - Call
page.frameLocator('#frame-one')*before* you fill a field that lives in that iframe. - Inspect named
<frame>tags withpage.locator('//frame').all()and scope the side pane with[name="side"]. - Chain
frameLocator('#pact1').frameLocator('#pact2').frameLocator('#pact3')for nested iframes. - Send
page.keyboard.press('A'),ArrowLeft, andShift+Othe way242_keyboard.spec.tsdoes. - Hover SpiceJet Add-ons, then hover the same pattern on the Testing Academy hover-menu widget.
- Drag
#column-aonto#column-bwithdragTo, then drag a Kanban card with a manualmouse.move/down/uppath whendragTois not enough. - Right-click a context-menu target and pick Copy.
- Register
page.once('dialog', ...)*before* the click that opens a JS alert, confirm, or prompt.
That is the skill. Not the selector. The skill is picking the document, then the interaction, then the assertion — in that order.
The labs we are actually using
Clone the fundamentals repo and stay on main:
git clone https://github.com/PramodDutta/LearningPlaywrightFundamentals.git
cd LearningPlaywrightFundamentals
git checkout main
Four folders. I fetched every file GitHub lists in them. Those files, as GitHub serves them:
tests/08_Web_Select_Frames_Iframe — selects and custom dropdowns. The folder *name* says Frames. The module README is honest: there is no active frameLocator() in this folder yet.
README.md— module index, native vs custom vs React Select, run commands234_Web.spec.ts— table-row checkboxes on the Testing Academy webtable page (not a frame)235_Select_FramesWeb.spec.ts— HerokuApp dropdown page;selectOptionlines are commented236_Advacne_Select_Frames2.spec.ts— custom language and experience dropdowns (Advacnein the filename)237_Advacne_Select_Pro.spec.ts— React Select single, multi, creatable, async238_Advance_Select_Pro_v2.spec.ts— stronger React Select: search, remove a chip, creatable Enter, grouped option, async waitutil.ts—selectValue(page, dropDownLabel, value)helper
tests/09_Frame_Iframe — this is where frames actually start.
README.md—frameLocator, named frames, nested chain239_Iframe.spec.ts— vehicle registration inside#frame-one240_Multiple_frame.spec.ts—[name="main"],[name="side"],//frameinventory241_Iframe_within_Iframe.spec.ts— SelectorsHub#pact1/#pact2/#pact3
tests/10_Keyboard_Hover_Drag_Drop
README.md— keyboard, hover,dragTo, manual mouse path, right-click242_keyboard.spec.ts—keycode.info,A/ArrowLeft/Shift+O, three PNGs244_Spicejet_Hover.spec.ts— SpiceJet Add-ons, then the TTA hover-menu widget245_Drag_Drop.spec.ts— HerokuApp#column-adragTo#column-b246_Drag_Drop_advance_Kanban.spec.ts— TTA Kanban,#card-write-specinto[data-status="review"]247_RightClick.spec.ts— context menu, list options, click Copy
tests/11_JS_Alerts
README.md— accept alert / confirm / prompt243_JS_Alerts.spec.ts— the only spec in this folder; numbered 243, between 242 and 244 in the classroom sequence
I am not opening tests/12_Handle_SVG today. That is Day 14. I am not inventing a 243 inside the keyboard folder. I am not inventing a frameLocator example inside folder 08. The 08 README says it is not there yet.
Run each module from its README:
npx playwright test tests/08_Web_Select_Frames_Iframe
npx playwright test tests/09_Frame_Iframe
npx playwright test tests/10_Keyboard_Hover_Drag_Drop
npx playwright test tests/11_JS_Alerts/243_JS_Alerts.spec.ts
One lesson file, the React Select v2 spec the 08 README points at:
npx playwright test tests/08_Web_Select_Frames_Iframe/238_Advance_Select_Pro_v2.spec.ts
One iframe file:
npx playwright test tests/09_Frame_Iframe/239_Iframe.spec.ts
Headed, so you can watch the cursor:
npx playwright test tests/09_Frame_Iframe --headed
Public demo sites need network. SpiceJet, HerokuApp, SelectorsHub, keycode.info, and app.thetestingacademy.com are live URLs in these specs. If a third-party page is down, the spec fails. That is a classroom fact, not a Playwright bug.
Several files still call await page.waitForTimeout(5000). I will point at those lines. I will not pretend they are the production habit. Auto-wait plus expect is the habit. The timeout is a classroom freeze-frame so the batch can see the UI.
Why Day 13 is the next framework decision
Day 11 asked what page is allowed to touch. Day 12 asked whether you must log in again. Day 13 asks which document and which input channel.
Four things break a naive page.locator().click() suite:
- A custom dropdown is not a
<select>.selectOptiondoes nothing useful on adivthat *looks* like a dropdown. Folder 08 is that lesson. - An iframe is another document. Locators never cross the frame boundary. Folder 09 is that lesson. The diagram at the top of this post is the rule I want in every PR.
- Hover, drag, keyboard, and right-click are not clicks. A submenu that exists only on hover will timeout if you click the child without hovering the parent. A Kanban library that listens to a path of
mousemoveevents will ignore a teleportingdragTo. Folder 10 is that lesson. - A JS dialog pauses the page. If you click first and register the handler second, the dialog is already gone or the test is stuck. Folder 11 is that lesson.
In a framework these four become four helpers, not four copy-pasted specs. Today we still read the classroom files. Day 20 and Day 21 will ask you to put frameLocator and dialog behind a page object. Do not skip the messy files. The mess is the curriculum.
Module 08 — the folder name says frames. The files say selects.
I start here because the classroom numbering starts here. I also start with a warning the module README already wrote:
The current specs run against top-level pages only; there is no active
frameLocator()or iframe example in this folder yet.
If you cloned the repo looking for frames, go to tests/09_Frame_Iframe. Folder 08 is tables, native select comments, custom dropdowns, and React Select.
Lab 234 — 234_Web.spec.ts is a table, not a select
The filename is 234_Web.spec.ts. The test title is still the classroom leftover Basic Web Test - Verify Page Title. The body is a web-table checkbox, on https://app.thetestingacademy.com/playwright/webtable.
await page.locator(
"//td[text()='Aarav.Sharma']/preceding-sibling::td/input[@type='checkbox']"
).click();
await page
.locator("tr:has(td:text('Rohan.Mehta'))")
.locator("td")
.first()
.click();
Two row strategies in one file. The first is XPath sibling traversal: find the cell whose text is Aarav.Sharma, walk to the checkbox in a preceding td. The second is the CSS I prefer: tr:has(td:text('Rohan.Mehta')), then the first td of that row.
Day 12 already taught you hasText on a row. I am not inventing a new table API. I am telling you why this file sits in folder 08: the batch was still on “find the row, then act.” Then we move to dropdowns. The waitForTimeout(5000) at the bottom is the classroom pause.
Also: Day 12 already told you that tests/07_WebTables/234_WebTABLE_Employe_Management.spec.ts is a *different* file that shares the number 234 and is empty (0 bytes). Two 234s. Classroom numbering collision. I do not merge them. I do not invent employee-management rows in this post.
Lab 235 — 235_Select_FramesWeb.spec.ts is the native <select> you do not run yet
This is the file people expect when they hear “Playwright select.”
await page.goto('https://the-internet.herokuapp.com/dropdown');
// await page.locator("#dropdown").click();
// await page.selectOption("#dropdown", "Option 1");
// await page.locator("#dropdown").selectOption("Option 1");
await page.waitForTimeout(5000);
Three lines of the real lesson are commented out. I will not uncomment them in this post and pretend the spec already selects Option 1. The README says this file “keeps native select examples as commented reference.”
When you *do* uncomment, you have two equivalent APIs for a real <select id="dropdown">:
page.selectOption("#dropdown", "Option 1")— page-level, value or label depending on the overloadpage.locator("#dropdown").selectOption("Option 1")— locator-level, same idea
Do not click the <select> and then getByText('Option 1') unless the control is a fake dropdown. Native select is selectOption. Fake dropdown is click-trigger-then-click-option. Mixing them is how you get a 30-second timeout on a control that was ready in 200 ms.
Homework from this file: uncomment one of the two selectOption lines. Assert the selected value. Delete the waitForTimeout. That is the production version of 235. I am not checking in that rewrite. The repo file stays as GitHub serves it.
Lab 236 — 236_Advacne_Select_Frames2.spec.ts is a custom dropdown
Filename: Advacne. I keep it.
URL: https://app.thetestingacademy.com/playwright/tables/dropdowns.
await page.locator('//div[@data-testid="dropdown-language"]').click();
await page.getByText("JavaScript").click();
await page.locator("#experience-shell").click();
await page.getByText("Mid-level (4-6 years)", { exact: true }).click();
This is not a <select>. It is a trigger. You click the trigger. You click the visible option. The language trigger is an XPath on data-testid="dropdown-language". I would write page.getByTestId('dropdown-language') in a new spec. I will not rewrite the classroom file in this post.
exact: true on the experience option is not decoration. Without it, getByText("Mid-level") can match a longer string or a hint. Strict mode then throws, or worse, a looser match clicks the wrong seniority. When two options share a prefix, exact is the contract.
Lab 237 — 237_Advacne_Select_Pro.spec.ts is React Select, first pass
Same product family, different page: https://app.thetestingacademy.com/playwright/tables/select-boxes.
Four widgets, four ids:
#rs-single— click,getByText("Cypress")#rs-multi— click,Pytestexact,JUnitexact, thenpage.keyboard.press("Escape")#rs-creatable— click,api-testing,security, Escape again#rs-async— click, fillgetByTestId('rs-async-input')withpun, expect the menu to containPune, click the option
Two habits I want you to steal.
Close the menu on purpose. A multi-select React Select stays open. The next getByText can hit a leftover option. Escape is the classroom close. In a page object I would close by clicking the control again or pressing Escape inside the helper, not in every test.
Wait for the async menu, not for a sleep. The async box types pun and asserts rs-async-menu contains Pune *before* it clicks. That is the Day 11 auto-wait idea applied to a typeahead. Do not waitForTimeout(2000) and hope Pune arrived.
Lab 238 — 238_Advance_Select_Pro_v2.spec.ts is the one I run in reviews
Same URL. Stronger locators. Assertions. A remove. A grouped option.
await page.getByTestId('rs-single').click();
await page.getByTestId('rs-single-input').fill('play');
await page.getByRole('option', { name: 'Playwright' }).click();
await expect(page.locator('#rs-single .tta-rs__single-value')).toHaveText('Playwright');
This is the upgrade from 237. Search the input. Pick by role. Assert the selected value. If the click landed and the chip did not appear, the test fails. 237 never asserted Cypress stayed selected.
The multi-select loop is the pattern I want in a helper:
const multi = page.getByTestId('rs-multi');
for (const name of ['Playwright', 'Pytest', 'TestNG']) {
await multi.click();
await page.getByRole('option', { name }).click();
}
await multi.locator('.tta-rs__multi-value:has-text("Pytest") .tta-rs__multi-value__remove').click();
Reopen for each option. Then remove Pytest by the chip’s remove control. Selecting is not enough. A real suite also deselects.
Creatable:
await page.getByTestId('rs-creatable-input').fill('chaos-engineering');
await page.getByTestId('rs-creatable-input').press('Enter');
await expect(page.locator('#rs-creatable .tta-rs__multi-value', { hasText: 'chaos-engineering' })).toBeVisible();
You did not click an existing option. You typed a tag the list did not have and pressed Enter. That is a different contract than 237’s api-testing / security clicks.
Grouped:
await page.getByTestId('rs-grouped').click();
await page
.locator('.tta-rs__group[data-group="Edge"]')
.getByRole('option', { name: 'Vercel Edge' })
.click();
Scope the group first. Then the option. getByRole('option', { name: 'Vercel Edge' }) on the whole page can still work if the name is unique. Scoping to data-group="Edge" is the review comment I write when two groups can share a label next quarter.
Async is the same pun → Pune wait as 237. Good. Keep it.
238 is the first file in folder 08 that looks like a production spec: test ids, role options, expects, no waitForTimeout. When I say “write dropdowns like 238, not like 236,” this is the file I mean.
util.ts — a helper that is not imported yet
import { Page, test, expect } from '@playwright/test';
async function selectValue(page: Page, dropDownLabel: string, value: string): Promise {
await page.locator(`//button[contains(@class,'select-trigger')]//span[text()='${dropDownLabel}']`).click();
await page.getByText(value, { exact: true }).click();
}
Three honest notes.
- Nothing in this folder imports
selectValue. I checked the specs. The helper is a pattern, not a wired utility. I will not pretend 236 calls it. - The return type is
Promisewith no type argument. Classroom TypeScript. A finished helper isPromise<void>. - The locator is XPath with a template string. If
dropDownLabelever came from user input you would not concatenate it. For a classroom label it works. In a framework I would usegetByRole('button', { name: dropDownLabel })if the trigger exposes that name.
The *idea* is right: one function for “open this labelled trigger, pick this exact text.” Day 20 page objects will look like this, with a locator map instead of an XPath template.
Native select vs custom vs React Select — write this on the wall
| What you see | What it is | Playwright API in these labs |
|---|---|---|
Real <select> | Native | locator.selectOption — lab 235, currently commented |
A div / button that opens a list | Custom dropdown | Click trigger, getByText(..., { exact: true }) — labs 236, util.ts |
| React Select-style widget | Searchable / multi / async | getByTestId, fill, getByRole('option'), Escape, expect — labs 237, 238 |
Interview answer, short: I do not call selectOption on a div. I inspect the markup first.
That sentence fails more SDET interviews than “what is an iframe.” Because everyone has a story about a dropdown. Few people open DevTools and check for a <select>.
Module 09 — this is the iframe day
The diagram at the top of this post is this module. Main frame is document A. The iframe is document B. page.locator() searches A. page.frameLocator(selector).locator() searches B. There is no switchTo(). Selenium muscle memory will fight you. Let it lose.
Lab 239 — 239_Iframe.spec.ts, one iframe, a whole form
URL: https://app.thetestingacademy.com/playwright/frames/.
let vechileFrame: FrameLocator = await page.frameLocator('#frame-one');
await vechileFrame.locator('#RESULT_TextField-1').fill('Hyundai i10');
await vechileFrame.locator('#RESULT_TextField-2').fill('Pramod Dutta');
await vechileFrame.locator('#RESULT_TextField-3').fill('2012');
await vechileFrame.locator('#RESULT_RadioButton-1').selectOption('Hatchback');
await vechileFrame.locator('#RESULT_TextField-4').fill('2015');
await vechileFrame.locator('#RESULT_TextArea-1').fill('Amazing car with amazing family car in a budget');
await vechileFrame.getByText('Submit registration', { exact: true }).click();
let output = await vechileFrame.locator("#vehicle-output").innerText();
console.log(output);
The variable is vechileFrame. Classroom spelling. I keep it when I quote the file. In a page object I would name it vehicleFrame.
frameLocator('#frame-one') does not need await to construct — FrameLocator is lazy, like Locator. The spec still awaits it. Harmless. The important part is every fill, every selectOption, the submit click, and the output read all go through vechileFrame, not page.
If you write this by mistake:
await page.locator('#RESULT_TextField-1').fill('Hyundai i10');
Playwright waits for an element that does not exist in the main document. Timeout. The screenshot will show the field. That screenshot is a trap. Your eyes crossed the iframe. Your locator did not.
#RESULT_RadioButton-1 is a native select *inside* the frame. Here selectOption('Hatchback') is correct, because the control is a real <select>. Folder 08’s lesson applies *inside* a frame the same way it applies on the main page.
The submit button is getByText('Submit registration', { exact: true }) on the frame locator. Role would be better if the button exposes it. The file uses text. I quote the file.
console.log(output) is a classroom peek. A finished spec would expect(vechileFrame.locator('#vehicle-output')).toContainText(...). I will not invent that assertion. The file logs.
Lab 240 — 240_Multiple_frame.spec.ts, named frames and an inventory
URL: https://app.thetestingacademy.com/playwright/frames/multi-frames.
let mainFrame: FrameLocator = await page.frameLocator('[name="main"]');
const headerText = await mainFrame.locator('h2').innerText();
console.log(headerText);
page.getByRole()
const allFrames: Locator[] = await page.locator('//frame').all();
console.log('total number of frames: ' + allFrames.length);
for (const frame of allFrames) {
console.log(await frame.getAttribute('name'), ': ', await frame.getAttribute('src'));
}
let sideFrame: FrameLocator = await page.frameLocator('[name="side"]');
await sideFrame.getByTestId('side-link-registration').click();
This page uses legacy <frame> tags, not only <iframe>. The inventory XPath is //frame. The README says so. I am not changing it to iframe.
Named frames: [name="main"], [name="side"]. Name is a valid selector. Prefer it over “the second frame” when the markup gives you a name.
There is a leftover line: page.getByRole(). No arguments. It is not awaited. It does nothing useful. I leave it in the quote because it is in the file. If you are following along, delete that line in your working copy. I am not inventing a role I cannot see in the spec.
The useful action is the last one: open the side frame, click side-link-registration. Two documents, two frameLocators, one page. You do not “switch.” You hold both handles.
page.frames() also exists on the Page API and returns Frame objects, including the main frame. This spec does not call it. It locates the <frame> *elements* in the parent document. Different list. I mention page.frames() so you know the API. I do not pretend 240 uses it.
Lab 241 — 241_Iframe_within_Iframe.spec.ts, the nested chain
URL: https://selectorshub.com/iframe-scenario/.
let frame1: FrameLocator = page.frameLocator('#pact1').first();
let frame2: FrameLocator = frame1.frameLocator('#pact2');
let frame3: FrameLocator = frame2.frameLocator('#pact3');
await frame1.locator('#inp_val').fill('Aishwarya Rai');
await frame2.locator('#jex').fill('Wife');
await frame3.locator('#glaf').fill('Playwright');
This is the diagram’s bottom callout. You do not jump to #pact3 from page. You walk.
#pact1is on the main page..first()is in the file — use it if the selector can match more than one.#pact2is *inside* pact1.#pact3is *inside* pact2.
Then each field is filled on the frame that owns it. Aishwarya Rai in pact1. Wife in pact2. Playwright in pact3. The header read is frame1.locator('h3').
Nested iframes show up in payment widgets, chat plugins, and older admin portals. If your locator times out and the screenshot shows the field, walk the iframe tree in DevTools before you add a wait. Nine times out of ten the field is two frames down.
I do not invent a fourth frame. I do not invent a cross-origin lecture beyond this: Playwright can still talk to a frame through frameLocator even when the origin differs, because the protocol can reach it. If a frame is closed or not yet attached, the locator auto-waits for the *inner* action, the same way Day 11 auto-waited a button.
Main frame vs iframe — the rule I write on the whiteboard
- Open DevTools. If the node sits under
#documentinside aniframeorframe, you are not onpage. - First locator:
page.frameLocator(selector)or a chain of them. - Second locator: the field, on that
FrameLocator. - Never write an XPath that starts on the main page and “reaches into” the iframe. XPath does not pierce documents.
- Prefer
frameLocatoroverpage.frame({ name })/page.frame({ url })for actions.frame()returns aFramethat can benull.frameLocatoris lazy and auto-waiting, like a locator.
Selenium people ask “when do I switch back?” You do not. You keep the handle. page is still the main document. vechileFrame is still the iframe. Both exist at the same time.
Module 10 — keyboard, SpiceJet hover, Kanban drag, right-click
Clicks and fills covered Days 11–12. Today the pointer and the keyboard have to behave like a human.
