Playwright and Selenium calls as steps
If you already write Playwright or Selenium tests, this page shows, call by
call, the step that does the same thing on Driftstack, and says plainly where
there is none yet. For the concepts (sessions, profiles, concurrency) and a
first full port, read
Migrating from Puppeteer / Playwright first.
You do not connect Playwright or Selenium to a Driftstack session: there is no
CDP or WebDriver endpoint. Your code sends a list of steps to
POST /v1/agent-sessions/{id}/steps,
and they run in order, exactly as written, with no AI planning:
{
"steps": [
{ "kind": "navigate", "url": "https://shop.example.com/login" },
{ "kind": "interact", "action": "type", "selector": "#email", "value": "[email protected]" },
{ "kind": "interact", "action": "tap", "selector": "[data-testid=\"sign-in\"]" },
{ "kind": "wait", "condition": "selector_visible", "selector": "#account", "timeoutMs": 10000 },
{ "kind": "extract", "selector": "#account h1" }
]
}
Four things differ from a Playwright or Selenium script:
- One list runs up to 8 steps. A longer script is several lists, sent one
after another.
- A step that cannot run stops the list. Its row in
results says why,
and the steps after it are not sent. A wait that runs out of time is the
one exception: its row fails and the next step runs.
- Every element is named by a CSS selector. There are no locator objects
and no text, label or XPath locators yet (see
Locating elements).
- Steps act the way a person on the phone does. A tap is a touch, and
typing uses the on-screen keyboard, so there is no hover, no right click and
no keyboard shortcut.
In the tables, “not supported yet” means no step does it today. Where there
is a way round it, the table says so.
Navigation
| Playwright |
Selenium |
Driftstack |
page.goto(url) |
driver.get(url) |
{ "kind": "navigate", "url": "https://example.com/" } — a full http:// or https:// address. |
page.goBack() |
driver.back() |
{ "kind": "back" } |
page.goForward() |
driver.forward() |
Not a step. Between lists, call POST /v1/agent-sessions/{id}/history with { "direction": "forward" }. |
page.reload() |
driver.refresh() |
Not supported yet. Send a navigate step to the same address instead. |
page.url(), page.title() |
driver.current_url, driver.title |
GET /v1/agent-sessions/{id}/page-state returns url and title. Where this deployment has it switched on, each successful step row also carries them in page. |
page.waitForLoadState() after a click or goto |
Selenium’s page-load wait |
Built in: before the first tap, tap_at, type or key press on a new page, the session waits for the page to settle. Add an idle wait (see Waiting) where you need one yourself. |
Locating elements
Every step that acts on an element takes a CSS selector. The session looks
it up when the step runs and acts on the first match. A selector that is not
CSS is refused before it is sent: the row fails with invalid_request.
| Playwright |
Selenium |
Driftstack |
page.locator('#pay') |
find_element(By.CSS_SELECTOR, '#pay') |
"selector": "#pay" |
page.getByTestId('checkout') |
find_element(By.CSS_SELECTOR, '[data-testid="checkout"]') |
"selector": "[data-testid=\"checkout\"]" — a test id is a plain attribute match. Playwright’s data-testid=checkout locator form is refused. |
page.getByPlaceholder('Email') |
find_element(By.CSS_SELECTOR, '[placeholder="Email"]') |
"selector": "[placeholder=\"Email\"]" matches only a hint that is exactly Email, letter case and spaces included. Playwright’s getByPlaceholder matches part of the hint by default; for that, write [placeholder*="mail"], which still counts letter case. |
page.getByRole('button', { name: 'Pay' }) |
find_element(By.CSS_SELECTOR, 'button[aria-label="Pay"]') |
Partly: match what the page writes as attributes, as in button[aria-label="Pay"] or [role="button"][aria-label="Pay"]. A name the browser works out from label text, alt text or the element’s own text is not matched. |
page.getByText('Sign in'), text=Sign in, :has-text() |
By.LINK_TEXT, By.PARTIAL_LINK_TEXT |
Not supported yet. Text locators, :has-text(), :contains() and text= are refused. Match an attribute or the page’s structure instead (a[aria-label="Sign in"], nav > a:first-child). |
page.getByLabel('Email') |
— |
Not supported yet. Name the field by its own id or name (#email, input[name="email"]). |
page.locator('xpath=//button') |
find_element(By.XPATH, '//button') |
Not supported: XPath is refused on a tap, type, wait and extract. |
locator('form').locator('button'), >> chaining |
Finding inside an element |
One descendant selector: "selector": "form button". Playwright’s >> chaining is refused. |
locator.nth(2) |
find_elements(...)[2] |
On an extract: "index": 2 counts every match in page order, from 0. A tap or type does not take index yet; name the position inside its parent instead (#results > li:nth-of-type(3) a), which counts from 1 among siblings of the same tag. |
locator.first() |
find_element(...) |
The default: every step acts on the first match. |
| A locator inside a shadow root |
element.shadow_root |
On a tap, type or wait, a >>> path steps into an open shadow root: "selector": "checkout-form >>> button[type=\"submit\"]". A closed shadow root cannot be reached. |
page.frameLocator('#card') |
driver.switch_to.frame(...) |
A frame on the step itself; see Frames. |
Tapping
| Playwright |
Selenium |
Driftstack |
locator.click(), locator.tap() |
element.click() |
{ "kind": "interact", "action": "tap", "selector": "#pay" } — a touch on the element. The summary names what was tapped (tapped #pay: the button “Pay”). |
page.mouse.click(x, y) |
ActionChains(...).move_by_offset(x, y).click() |
{ "kind": "tap_at", "x": 120, "y": 480 } — viewport pixels from the top-left corner. |
locator.check(), locator.uncheck() |
element.click() on a checkbox |
A tap toggles it. To set a state, read it first with { "kind": "extract", "selector": "#terms", "property": "checked" } and tap only when it differs. |
locator.dblclick() |
ActionChains(...).double_click() |
Not supported yet. |
Long press (click({ delay })) |
ActionChains(...).click_and_hold() |
Not supported yet. |
locator.hover() |
ActionChains(...).move_to_element() |
None: a touch screen has no hover. A tap is a touch. |
click({ button: 'right' }) |
ActionChains(...).context_click() |
None: a phone has no right click. |
locator.dragTo(target), mouse drags |
ActionChains(...).drag_and_drop() |
Not supported yet. An interact step with swipe is not run in a list. |
| Pinch or zoom gestures |
— |
Not supported yet. |
Typing and keys
| Playwright |
Selenium |
Driftstack |
locator.pressSequentially(text) |
element.send_keys(text) |
{ "kind": "interact", "action": "type", "selector": "#q", "value": "blue shoes" } — the text is added to what the field holds. |
locator.clear() |
element.clear() |
A type step with an empty value: { "kind": "interact", "action": "type", "selector": "#q", "value": "" } |
locator.fill(text) |
element.clear() then element.send_keys(text) |
Two type steps: "" to clear, then the text. A step that types a saved credential or a session secret clears the field first on its own. |
locator.press('Enter') |
send_keys(Keys.ENTER) |
{ "kind": "interact", "action": "press", "value": "Enter" } — on the focused field. The keys an iPhone keyboard has: Enter (or Return), Backspace, Space and one printable character. |
press('Tab'), press('Escape'), arrows |
Keys.TAB, Keys.ESCAPE, arrow keys |
None: an iPhone’s on-screen keyboard has no such key. The row fails and the list stops. |
press('Control+A') and other shortcuts |
Keys.CONTROL chords |
None, for the same reason. |
locator.selectOption('CA') |
Select(el).select_by_value('CA') |
{ "kind": "interact", "action": "type", "selector": "#country", "value": "CA" } — on a session whose browser has the iPhone’s list picker, and only for a list on the page itself. The option’s value is matched exactly first, then its whole text, ignoring letter case. |
selectOption({ label: 'Canada' }) |
select_by_visible_text('Canada') |
The same type step with the option’s whole text as value. |
selectOption({ index: 2 }) |
select_by_index(2) |
Not supported yet. Read the options first, then choose by value or text. |
selectOption([...]) on a multiple list |
Several select_by_* calls |
Not supported yet: a list that takes several choices is refused. |
locator.focus(), locator.blur() |
Focus by clicking |
A tap focuses a field. There is no blur step; tap somewhere else. |
| Typing a password from an environment variable |
The same |
Register it as a session secret or save it as a saved credential and type its placeholder ({{credential:sec_…}}). Add "sensitive": true to type a card number or a code straight through, without a person’s typing mistakes. |
Waiting
| Playwright |
Selenium |
Driftstack |
page.waitForSelector(sel), expect(locator).toBeVisible() |
WebDriverWait(...).until(visibility_of_element_located(...)) |
{ "kind": "wait", "condition": "selector_visible", "selector": "#dashboard", "timeoutMs": 10000 } — until the element is shown, not only present. timeoutMs is rounded up to whole seconds; without it the wait lasts up to 30 seconds. |
page.waitForLoadState('load'), 'networkidle' |
Page-load waits |
{ "kind": "wait", "condition": "idle" } — until the page has loaded, and never more than 3 seconds after the load. Nothing matches Playwright’s networkidle: on a self-hosted deployment, POST /v1/sessions/:id/navigate accepts wait_until: 'networkidle', and it currently waits for the load event, the same as load (on hosted, direct sessions answer 503 driver-not-integrated). Wait for the element you need instead. |
page.waitForURL('**/order/confirmed') |
WebDriverWait(...).until(url_contains('/order/confirmed')) |
A preview, only on accounts it is switched on for: { "kind": "wait", "condition": "url_matches", "url": "/order/confirmed" }. The address must contain the text exactly as written; it is never a pattern. See Preview steps. |
waitForSelector(sel, { state: 'hidden' }), 'detached' |
invisibility_of_element_located(...) |
Not supported yet. |
waitForSelector(sel, { state: 'attached' }) |
presence_of_element_located(...) |
Not supported yet: selector_visible waits for the element to be shown. |
expect(locator).toHaveText(...) |
text_to_be_present_in_element(...) |
Not supported yet. Wait for the element, then read its text with extract. |
page.waitForResponse(...), waitForRequest(...) |
— |
Not supported yet. The network log can be read after the list. |
page.waitForTimeout(2000) |
time.sleep(2) |
{ "kind": "behavioral_pause", "duration_ms": 2000 } — the session pauses between two steps of the same list. |
| Implicit waits |
driver.implicitly_wait(...) |
None. Each wait is a step of its own. |
Reading text, attributes and counts
Every read is an extract step. Its answer comes back in the step’s row of
results and is never stored by Driftstack.
| Playwright |
Selenium |
Driftstack |
locator.innerText() |
element.text |
{ "kind": "extract", "selector": "#kpi" } — the element’s rendered text, in the row’s summary. |
locator.textContent() |
element.get_property('textContent') |
{ "kind": "extract", "selector": "#kpi", "property": "textContent" } — every character, hidden text included, in the row’s value. |
locator.getAttribute('href') |
element.get_attribute('href') |
{ "kind": "extract", "selector": "a.terms", "attribute": "href" } |
locator.inputValue() |
element.get_property('value') |
{ "kind": "extract", "selector": "#email", "property": "value" } — what the field holds now, in value. |
locator.isChecked() |
element.is_selected() |
"property": "checked" — a boolean in value. |
locator.isDisabled(), isEnabled() |
element.is_enabled() |
"property": "disabled" — a boolean in value. |
locator.isVisible() |
element.is_displayed() |
Not a field yet. A short selector_visible wait answers it: its row succeeds or fails. |
locator.allInnerTexts() |
[e.text for e in find_elements(...)] |
{ "kind": "extract", "selector": "li.result h3", "all": true } — each match’s text in values, at most 200. |
locator.count() |
len(find_elements(...)) |
count on a successful extract with all, index, attribute or property: how many elements the selector matched (1,000 means 1,000 or more). A selector that matches nothing fails with element_not_found. |
page.content() |
driver.page_source |
{ "kind": "extract", "selector": "html", "property": "outerHTML" } for the markup (on a session whose browser can read a live property; cut at 100,000 characters), or { "kind": "extract", "body": true } for the page’s text. |
page.evaluate(...) |
driver.execute_script(...) |
None: running your own script in the page is left out on purpose, because the page can see it. Use the reads above. |
context.cookies() |
driver.get_cookies() |
Not a step: GET /v1/agent-sessions/{id}/cookies, between lists. |
localStorage or sessionStorage reads |
execute_script('return localStorage…') |
Not supported yet. |
Screenshots and PDFs
| Playwright |
Selenium |
Driftstack |
page.screenshot() |
driver.save_screenshot(...) |
{ "kind": "capture", "capture": "screenshot" } — the screen as the phone shows it. Fetch the image by the row’s captureId with GET /v1/agent-sessions/{id}/captures/{captureId} soon after the list ends. |
page.screenshot({ fullPage: true }) |
— |
Not supported yet: a screenshot is the screen, not the whole page. |
locator.screenshot() |
element.screenshot(...) |
Not supported yet. |
page.pdf() |
driver.print_page(...) |
Not supported yet. capture lists pdf, but no session can make one: the row fails and the list stops. |
Files and downloads
| Playwright |
Selenium |
Driftstack |
locator.setInputFiles(path) |
send_keys(path) on a file input |
Not a step. While the page is open, upload the file with POST /v1/agent-sessions/{id}/files, then tap the page’s upload button. The page gets the newest unused upload for its address. |
page.waitForEvent('download') |
A download folder setting |
Not supported yet: the session does not save a page’s downloads, so the downloads list stays empty. |
Frames
Playwright’s frameLocator and Selenium’s switch_to.frame become a frame
on each step that acts inside the frame. A step without frame acts on the
page itself, as after switch_to.default_content().
| Playwright |
Selenium |
Driftstack |
page.frameLocator('iframe').first() |
switch_to.frame(0) |
"frame": [0] — the browser’s numbering, listed by GET /v1/agent-sessions/{id}/frames. |
page.frameLocator('iframe[name="card"]') |
switch_to.frame('card') |
"frame": [{ "name": "card" }] — the frame whose iframe name is exactly that text. |
page.frameLocator('iframe[src*="pay"]') |
switch_to.frame(find_element(...)) |
"frame": [{ "src": "pay.example.com" }] — the frame whose address contains that text. |
| A frame inside a frame |
Two switch_to.frame calls |
"frame": [0, 1], outermost first. |
| A frame inside a shadow root |
— |
"frame": ["checkout-form >>> iframe"], on a session whose browser can enter one. |
A type, an extract with body: true and a capture of dom_snapshot
can name a frame. A tap and a wait for selector_visible can on a
session whose browser can act inside a frame, and an extract by selector on
one that can read an element there. A frame found by name or src needs a
session whose browser can find a frame that way. A scroll or a key press
never names one.
The full rules are in
Steps inside an embedded frame.
{
"steps": [
{
"kind": "wait",
"condition": "selector_visible",
"selector": "input[name=\"cardnumber\"]",
"frame": [{ "name": "card" }],
"timeoutMs": 10000
},
{
"kind": "interact",
"action": "type",
"selector": "input[name=\"cardnumber\"]",
"value": "{{credential:sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64}}",
"frame": [{ "name": "card" }]
}
]
}
Tabs and popups
Tabs are calls of their own, between lists, not steps. While a list of steps
runs, a tab call answers 409; on an account without tabs, 404.
| Playwright |
Selenium |
Driftstack |
context.newPage() then goto(url) |
switch_to.new_window('tab') then get(url) |
POST /v1/agent-sessions/{id}/tabs with { "action": "open", "url": "https://mail.example.com/" }. At most 8 tabs. |
page.bringToFront() |
switch_to.window(handle) |
{ "action": "switch", "tab_id": "t48213" } |
page.close() |
driver.close() |
{ "action": "close", "tab_id": "t48213" } |
context.pages() |
driver.window_handles |
{ "action": "list" } |
page.waitForEvent('popup') |
A new handle after a click |
Not supported yet: a tab the page opens itself (target="_blank", window.open) is not in the tab list. |
Every list of steps, message and history step acts on the tab in front.
Network
| Playwright |
Selenium |
Driftstack |
page.on('request'), page.on('response') |
Browser performance logs |
Not a step: GET /v1/agent-sessions/{id}/network lists the requests the pages made (address, method, status). |
page.route(...), request interception |
— |
Not supported. |
page.waitForResponse(...) |
— |
Not supported yet. |
Dialogs
| Playwright |
Selenium |
Driftstack |
page.on('dialog', (d) => d.accept()) |
driver.switch_to.alert.accept() |
{ "kind": "dialog", "action": "accept" } — taps OK on the iPhone’s sheet, on a session whose browser can answer dialogs. |
dialog.dismiss() |
alert.dismiss() |
{ "kind": "dialog", "action": "dismiss" } |
dialog.accept('Jane') on a prompt |
alert.send_keys('Jane'), accept() |
{ "kind": "dialog", "action": "accept", "text": "Jane" } |
httpCredentials in the browser context |
Credentials in the address |
A dialog step answers the browser’s sign-in sheet; see Dialogs. |
| Playwright |
Selenium |
Driftstack |
page.mouse.wheel(0, 600) |
ActionChains(...).scroll_by_amount(0, 600) |
{ "kind": "scroll", "direction": "down", "amount_px": 600 } — up or down; without amount_px, 600 pixels. |
locator.scrollIntoViewIfNeeded() |
ActionChains(...).scroll_to_element(el) |
{ "kind": "interact", "action": "scroll", "selector": "#reviews" } on a session whose browser can scroll to an element; on any other it is a plain scroll. A tap scrolls toward its own target first. |
| Scrolling sideways, or to the top or bottom |
scroll_by_amount(600, 0) |
A preview, only on accounts it is switched on for: "direction": "left" or "right", and "amount": "one_screen", "to_top" or "to_bottom". |
| Scrolling inside a scrollable panel |
— |
Not supported yet: a scroll moves the page itself. |
Assertions, retries and longer scripts
- Assertions (
expect(...) in Playwright, assert in a Selenium test)
are not steps. Read the value with extract and check it in your own code.
- Optional steps (a
try around a click on a cookie banner) are not
supported yet: a tap that fails stops the list. Send the optional step in a
list of its own and carry on whatever its row says.
- Retries. A failed row carries
diagnosis.retryable, which says whether
sending the same step again can help. Retry in your own code.
- Longer scripts. One list runs up to 8 steps. Send the next list when the
previous one has answered.
Where to go next