Basic click-and-type covers most tests. This chapter covers the interactions that trip people up: native select dropdowns, drag and drop, iframes, multiple windows, cookies, and when to reach for raw JavaScript.
Dropdowns (Native Select)
For <select> elements, WDIO provides three selection methods:
const sort = $('[data-test="product-sort-container"]')
// By the text the user sees
await sort.selectByVisibleText('Price (low to high)')
// By the option's value attribute
await sort.selectByAttribute('value', 'lohi')
// By zero-based index
await sort.selectByIndex(2)
selectByVisibleText is the most readable. Use selectByAttribute when the visible text might change (e.g., localised strings) but the value is stable.
To assert the selection:
await expect(sort).toHaveValue('lohi')
SauceDemo's own sort control is a native <select> (the example above), not this custom-dropdown pattern — so the snippet below is reference syntax for when you hit a non-native dropdown elsewhere, not something this tutorial's site has to run it against.
For custom dropdown components (not native <select>), treat them like any other element — click the trigger, wait for the menu, click the option:
await $('[data-test="sort-trigger"]').click()
await $('[data-test="sort-option-lohi"]').waitForDisplayed()
await $('[data-test="sort-option-lohi"]').click()
Drag and Drop
SauceDemo has no drag-and-drop feature anywhere in the app — this section is reference syntax, not something you can run against this tutorial's site.
WDIO's built-in dragAndDrop() works for most cases:
const source = await $('[data-test="drag-source"]')
const target = await $('[data-test="drop-target"]')
await source.dragAndDrop(target)
For applications using HTML5 drag events that don't respond to the standard dragAndDrop(), use the Actions API for precise control:
const source = await $('[data-test="drag-source"]')
const target = await $('[data-test="drop-target"]')
const sourceCoords = await source.getLocation()
const targetCoords = await target.getLocation()
await browser.action('pointer', { parameters: { pointerType: 'mouse' } })
.move({ x: sourceCoords.x + 5, y: sourceCoords.y + 5 })
.down({ button: 0 })
.pause(200)
.move({ x: targetCoords.x + 5, y: targetCoords.y + 5, duration: 500 })
.up({ button: 0 })
.perform()
The pause() and duration on the move() are important for drag implementations that require the pointer to settle before recognising the drag.
Keyboard Input
// Type into a focused element using special keys
await $('#search-input').click()
await browser.keys(['Control', 'a']) // select all
await browser.keys('Delete') // delete selection
// Tab through form fields
await $('[data-test="firstName"]').setValue('Hammad')
await browser.keys('Tab') // moves focus to next field
// Submit a form with Enter
await $('[data-test="lastName"]').setValue('Faisal')
await browser.keys('Enter')
// Press Escape to close a modal
await browser.keys('Escape')
// Arrow keys for navigating lists
await $('[data-test="dropdown"]').click()
await browser.keys('ArrowDown')
await browser.keys('ArrowDown')
await browser.keys('Enter')
For precise key sequences using the Actions API:
await browser.action('key')
.down('Tab')
.up('Tab')
.perform()
Hover / Tooltip
SauceDemo has no hover-triggered tooltip anywhere in the app — this section is reference syntax, not something you can run against this tutorial's site.
Hovering over an element triggers :hover CSS and mouseover events:
// Move the mouse pointer over the element
await $('[data-test="product-name"]').moveTo()
// Assert the tooltip appeared
await expect($('[data-test="tooltip"]')).toBeDisplayed()
// Hover with offset — useful if the element is tiny
await $('[data-test="product-name"]').moveTo({ xOffset: 10, yOffset: 5 })
File Upload
SauceDemo has no file upload input anywhere in the app — this section is reference syntax, verified against WebdriverIO's own API, not something you can run against this tutorial's site.
For <input type="file"> elements, set the file path directly — no need to interact with the OS file picker:
const fileInput = $('input[type="file"]')
await fileInput.setValue('/absolute/path/to/test-file.pdf')
// Verify the upload was accepted
await expect($('[data-test="file-name"]')).toHaveText('test-file.pdf')
The path must be absolute and accessible from the machine running the test. In CI, construct paths from process.cwd():
import path from 'path'
const filePath = path.join(process.cwd(), 'test', 'fixtures', 'test-file.pdf')
await $('input[type="file"]').setValue(filePath)
If the file input is hidden (common pattern where a styled button triggers the hidden input), use browser.execute() to make it visible first:
await browser.execute((el) => {
(el as HTMLElement).style.display = 'block'
}, await $('input[type="file"]'))
await $('input[type="file"]').setValue(filePath)
File Downloads
WebdriverIO has no dedicated "download" event to await — verify a download the same way you'd verify any other file-system side effect, by checking the downloads directory on disk after triggering it. First, point Chrome's download behavior at a known folder in your capabilities:
capabilities: [
{
browserName: 'chrome',
'goog:chromeOptions': {
prefs: {
'download.default_directory': path.join(process.cwd(), 'test-downloads'),
'download.prompt_for_download': false,
},
},
},
],
SauceDemo has a real download to verify this against — a "Generate PDF" button on the order confirmation page, after completing checkout. Its filename is timestamped (swag-labs-order-<timestamp>.pdf), so this polls for any PDF landing rather than a fixed name — which is realistic, since a lot of real downloads (invoices, exports, generated reports) are timestamped the same way:
import fs from 'fs'
import path from 'path'
const downloadDir = path.join(process.cwd(), 'test-downloads')
// ... complete login, add to cart, and checkout ...
await expect(await checkoutPage.getConfirmationMessage()).toBe('Thank you for your order!')
await $('[data-test="generate-pdf-order"]').click()
await browser.waitUntil(
() => fs.existsSync(downloadDir) && fs.readdirSync(downloadDir).some((f) => f.endsWith('.pdf')),
{ timeout: 10000, timeoutMsg: 'expected a PDF to be downloaded within 10s' },
)
const downloaded = fs.readdirSync(downloadDir).find((f) => f.endsWith('.pdf'))
const size = fs.statSync(path.join(downloadDir, downloaded!)).size
expect(size).toBeGreaterThan(100) // sanity check it's not an empty/broken file
For a text-based format with a fixed filename, read the file directly afterward to assert on its actual contents, the same as you would for any other file your test needs to inspect.
Download the Working Test
tabs-and-downloads.e2e.ts
The download test above, plus a real new-tab test (below) — both verified passing against the live site
iframes
SauceDemo has no iframe anywhere in the app — this section is reference syntax, not something you can run against this tutorial's site.
WDIO operates in the top-level frame by default. To interact with content inside an iframe, switch to it first:
// Switch to iframe by element reference
const frame = await $('iframe[title="Payment Form"]')
await browser.switchToFrame(frame)
// Now you can interact with elements inside the iframe
await $('[data-test="card-number"]').setValue('4111111111111111')
await $('[data-test="card-expiry"]').setValue('12/26')
// Switch back to the main page
await browser.switchToParentFrame()
// Now back to normal
await $('[data-test="submit-payment"]').click()
Common mistake: forgetting to switch back. If your next selector unexpectedly fails to find an element, check whether the context is still inside an iframe.
To switch by iframe index (fragile — avoid if possible):
await browser.switchToFrame(0) // first iframe on the page
Multiple Windows and Tabs
// Open a new tab (or window)
await browser.newWindow('https://www.saucedemo.com', {
windowName: 'SauceDemo',
windowFeatures: 'width=1280,height=800'
})
// Get all window handles
const handles = await browser.getWindowHandles()
console.log(handles) // ['CDwindow-1A2B3C', 'CDwindow-4D5E6F']
// Switch to a specific window by handle
await browser.switchToWindow(handles[1])
// Do work in that window
await expect(browser).toHaveUrl(expect.stringContaining('saucedemo.com'))
// Switch back to the original window
await browser.switchToWindow(handles[0])
For links that open a new tab (target="_blank"), capture the handle before clicking. SauceDemo's footer social icons are real target="_blank" links — here's the X (Twitter) one, verified against the live site:
const originalHandle = await browser.getWindowHandle()
await $('[data-test="social-x"]').click()
// Wait for the new tab to open
await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 2, {
timeout: 5000,
timeoutMsg: 'expected a second tab to open',
})
// switchWindow matches by URL or title substring — no handle bookkeeping needed
await browser.switchWindow('x.com')
await expect(browser).toHaveUrl(expect.stringContaining('x.com/saucelabs'))
// Close the tab and return to the original one
await browser.closeWindow()
await browser.switchToWindow(originalHandle)
// the original tab is untouched
await expect(browser).toHaveUrl(expect.stringContaining('inventory.html'))
browser.switchWindow() is the more convenient variant used above — it matches a window by URL or title substring directly, instead of the manual handle-diffing shown in the first example. Reach for switchWindow() when you know something identifiable about the destination tab; fall back to manual handle tracking when you don't.
Cookies and Local Storage
// Read all cookies
const cookies = await browser.getCookies()
console.log(cookies)
// Read a specific cookie
const [sessionCookie] = await browser.getCookies(['session_id'])
console.log(sessionCookie.value)
// Set a cookie
await browser.setCookies([{
name: 'session_id',
value: 'abc123',
domain: 'www.saucedemo.com',
}])
// Delete a specific cookie
await browser.deleteCookies(['session_id'])
// Delete all cookies
await browser.deleteCookies()
Local storage and session storage require browser.execute():
// Set a localStorage item
await browser.execute(() => {
localStorage.setItem('cart', JSON.stringify(['sauce-labs-backpack']))
})
// Read it back
const cart = await browser.execute(() => localStorage.getItem('cart'))
console.log(cart) // '["sauce-labs-backpack"]'
// Clear all localStorage
await browser.execute(() => localStorage.clear())
Scrolling
// Scroll to absolute position
await browser.execute(() => window.scrollTo(0, 500))
// Scroll to bottom of page
await browser.execute(() => window.scrollTo(0, document.body.scrollHeight))
// Scroll a specific element into view
await $('[data-test="footer-link"]').scrollIntoView()
// Scroll into view with options (center in viewport)
await $('[data-test="footer-link"]').scrollIntoView({ block: 'center' })
Taking Screenshots
// Full viewport screenshot
await browser.saveScreenshot('./screenshots/current-state.png')
// Element-level screenshot (crops to just that element)
const header = $('[data-test="header"]')
await header.saveScreenshot('./screenshots/header.png')
In hooks, build the path dynamically from the test title:
afterEach(async function() {
if (this.currentTest?.state === 'failed') {
const name = this.currentTest.title.replace(/\s+/g, '-').toLowerCase()
await browser.saveScreenshot(`./screenshots/fail-${name}.png`)
}
})
Shadow DOM (v9+)
If a page you're testing uses web components, elements can live inside a shadow root. In older WDIO versions you needed a dedicated method to reach inside one:
// pre-v9 pattern — still works, but no longer required
const shadowHost = await $('my-custom-element')
const shadowRoot = await shadowHost.shadow$('button')
await shadowRoot.click()
Since v9, standard selectors — including accessibility-based ones — automatically pierce open and closed shadow roots, so this works without any shadow-specific syntax:
await $('button=Submit').click()
Your selector doesn't need to know or care whether the element happens to sit inside a web component's shadow tree.
A note on auto-wait
Since v9, WDIO also waits for an element to be interactable — present, visible, and enabled — before running a command like .click(), without you calling waitForDisplayed()/waitForEnabled() first. You'll still see waitForDisplayed() used deliberately throughout this tutorial — for example waiting on a loading spinner to disappear, or an error message to appear after a failed submit — because those are genuine "wait for a state change" checks, not "wait before I'm allowed to interact" checks. The auto-wait only covers the latter.
Reading element internals: getElement()
One more v9 change worth knowing if you're reading older WDIO code or migrating an inherited suite: reading a property directly off an element (like .selector) used to return the value synchronously. In v9 it returns a Promise instead, and you need getElement() to resolve it first:
// v9 — resolve the element before reading its properties
const elem = await $('[data-test="add-to-cart-sauce-labs-backpack"]').getElement()
console.log(elem.selector) // '[data-test="add-to-cart-sauce-labs-backpack"]'
You won't hit this often — calling methods like .click() or .getText() works exactly the same as before — but it's the kind of thing that only surfaces as a confusing Promise { <pending> } value in a debug log, not a clear error, so it's worth recognizing on sight.
Executing JavaScript
browser.execute() runs synchronously in the browser context. Use it when WDIO has no native command for what you need:
// Read a property not exposed by WDIO
const scrollY = await browser.execute(() => window.scrollY)
// Trigger an event
await browser.execute((el) => {
el.dispatchEvent(new Event('input', { bubbles: true }))
}, await $('[data-test="quantity"]'))
// Click something that's obstructed by another element
await browser.execute((el) => (el as HTMLElement).click(), await $('[data-test="hidden-btn"]'))
// Read a deeply nested value
const value = await browser.execute(() => {
return (document.querySelector('input[name="qty"]') as HTMLInputElement)?.value
})
For async operations in the browser (like fetch), use browser.executeAsync():
const data = await browser.executeAsync((done) => {
fetch('/api/products')
.then(r => r.json())
.then(done)
})
Mobile Emulation with Chrome DevTools Protocol
You can emulate mobile viewports without a real device:
// In wdio.conf.ts capabilities:
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
mobileEmulation: {
deviceName: 'iPhone 12'
// Or use custom metrics:
// deviceMetrics: { width: 390, height: 844, pixelRatio: 3 },
// userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) ...'
}
}
}]
Or switch emulation mid-test using the CDP:
await browser.cdp('Emulation', 'setDeviceMetricsOverride', {
width: 390,
height: 844,
deviceScaleFactor: 3,
mobile: true
})
Multi-Touch Actions (Mobile / Appium)
This is genuinely a WebdriverIO-specific topic — neither Playwright nor Cypress automate native multi-touch gestures, since neither drives real mobile devices through Appium. If you're testing a native or hybrid mobile app, WDIO's action API lets you script multi-finger gestures directly:
// A two-finger pinch-to-zoom gesture
await browser.performActions([
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: 100, y: 300 },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: 500, x: 50, y: 300 },
{ type: 'pointerUp', button: 0 },
],
},
{
type: 'pointer',
id: 'finger2',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: 200, y: 300 },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: 500, x: 250, y: 300 },
{ type: 'pointerUp', button: 0 },
],
},
])
await browser.releaseActions()
This is the W3C WebDriver Actions API, not something WDIO-specific — each pointer entry is an independent finger, and the coordinates describe where it starts and ends. For common gestures like swipe or pinch, most real-world suites wrap this in a small helper rather than writing the raw action sequence inline every time. This is genuinely an Appium/mobile concern rather than something you'll reach for testing a desktop browser — if you're only testing web in Chrome or Firefox, you're unlikely to need this at all.
Complete Example: Sorting, New Tabs, and localStorage
import path from 'path'
describe('Advanced Interactions', () => {
before(async () => {
await browser.url('/')
await $('#user-name').setValue('standard_user')
await $('#password').setValue('secret_sauce')
await $('#login-button').click()
await expect(browser).toHaveUrl(expect.stringContaining('/inventory'))
})
it('should interact with product sort and verify ordering', async () => {
// before() already logged in and landed on /inventory.html — every
// test in this file continues in that same session rather than
// re-navigating (which 404s: this site has no real static route for
// /inventory.html, only a client-side one reached by logging in).
// Sort by price ascending
await $('[data-test="product-sort-container"]').selectByAttribute('value', 'lohi')
// Scroll to the last product (might be off-screen)
const lastProduct = (await $$('.inventory_item')).at(-1)!
await lastProduct.scrollIntoView()
await expect(lastProduct).toBeDisplayed()
// Hover over the last product name to check tooltip behaviour
await lastProduct.$('.inventory_item_name').moveTo()
// Screenshot the sorted state for visual record
await browser.saveScreenshot('./screenshots/sorted-lohi.png')
// Verify prices are in ascending order
const priceEls = await $$('.inventory_item_price')
const prices: number[] = []
for (const el of priceEls) {
prices.push(parseFloat((await el.getText()).replace('$', '')))
}
for (let i = 1; i < prices.length; i++) {
expect(prices[i]).toBeGreaterThanOrEqual(prices[i - 1])
}
})
it('should open and interact with a new tab from a link', async () => {
const handlesBefore = await browser.getWindowHandles()
// LinkedIn link in footer opens in a new tab
await $('[data-test="social-linkedin"]').click()
await browser.waitUntil(async () => {
return (await browser.getWindowHandles()).length > handlesBefore.length
}, { timeout: 5000 })
const newHandle = (await browser.getWindowHandles()).find(
h => !handlesBefore.includes(h)
)!
await browser.switchToWindow(newHandle)
await expect(browser).toHaveUrl(expect.stringContaining('linkedin'))
await browser.closeWindow()
await browser.switchToWindow(handlesBefore[0])
// Back on saucedemo
await expect(browser).toHaveUrl(expect.stringContaining('saucedemo'))
})
it('should use JavaScript execution to read localStorage after adding to cart', async () => {
await $('[data-test="add-to-cart-sauce-labs-backpack"]').click()
// SauceDemo stores cart in localStorage — read it directly
const rawCart = await browser.execute(() => localStorage.getItem('cart-contents'))
expect(rawCart).not.toBeNull()
})
})
Next chapter: flaky tests — how to diagnose intermittent failures and build a suite that gives you the same result every time.