Code Mage LogoCode Mage
TutorialsWebdriverIOAdvanced Interactions & Browser Control

๐Ÿค– WebdriverIO ยท Chapter 6 of 8

Advanced Interactions & Browser Control

Handle dropdowns, file uploads, iframes, multi-tab, and direct JavaScript execution in WDIO

All chapters (8)

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')

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

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

Hovering over an element triggers :hover CSS and mouseover events:

// Move the mouse pointer over the element
await $('[data-test="item-name"]').moveTo()

// Assert the tooltip appeared
await expect($('[data-test="tooltip"]')).toBeDisplayed()

// Hover with offset โ€” useful if the element is tiny
await $('[data-test="item-name"]').moveTo({ xOffset: 10, yOffset: 5 })

File Upload

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)

iframes

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-XXXX', 'CDwindow-YYYY']

// 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:

const handlesBefore = await browser.getWindowHandles()
await $('[data-test="terms-link"]').click()

// Wait for the new tab to open
await browser.waitUntil(async () => {
  const handles = await browser.getWindowHandles()
  return handles.length > handlesBefore.length
})

const handlesAfter = await browser.getWindowHandles()
const newTab = handlesAfter.find(h => !handlesBefore.includes(h))!
await browser.switchToWindow(newTab)

await expect(browser).toHaveUrl(expect.stringContaining('terms'))

// Close the tab and return
await browser.closeWindow()
await browser.switchToWindow(handlesBefore[0])

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`)
  }
})

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
})

Complete Example: File Upload with iframe Interaction

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 () => {
    await browser.url('/inventory.html')

    // 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 = await Promise.all(
      priceEls.map(async el => 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 () => {
    await browser.url('/inventory.html')

    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 browser.url('/inventory.html')

    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.