Web Testing

Web Platform

maestro-runner can test web applications directly in a Chromium browser using the Chrome DevTools Protocol (CDP). No Selenium, no WebDriver, no external dependencies — just a single binary.


Quick Start

# Run a flow against a web app
maestro-runner --platform web flows/login.yaml

# With a visible browser window
maestro-runner --platform web --headed flows/

# Use your installed Chrome instead of bundled Chromium
maestro-runner --platform web --browser chrome flows/

Your flow file uses url (or appId) to set the web app URL:

url: https://myapp.example.com
name: Login Flow
---
- launchApp
- tapOn: "Sign In"
- inputText:
    text: "[email protected]"
    placeholder: "Email"
- inputText:
    text: "password123"
    placeholder: "Password"
- tapOn: "Log In"
- assertVisible: "Welcome back"

On first run, maestro-runner downloads a bundled Chromium (~130MB) to ~/.maestro-runner/browsers/. Subsequent runs reuse the cached binary.


CLI Flags (Web-Specific)

Flag Type Default Env Var Description
--platform web string MAESTRO_PLATFORM Required. Selects the web/browser driver
--headed bool false Show the browser window (default is headless)
--browser string chromium MAESTRO_BROWSER Browser to use: chrome, chromium, or path to binary
--parallel int Run N browser instances in parallel

Browser Selection

Value Behavior
chromium (default) Downloads and uses Rod's bundled Chromium
chrome Uses locally installed Google Chrome (falls back to Chromium if not found)
/path/to/binary Uses a custom browser binary at the given path
# Bundled Chromium (default)
maestro-runner --platform web flows/

# Installed Chrome
maestro-runner --platform web --browser chrome flows/

# Custom binary
maestro-runner --platform web --browser /opt/chrome-canary/chrome flows/

Flow Configuration

The config section (above the --- separator) controls flow-level settings.

URL / App ID

Use url or appId to set the web app URL. This URL is used by launchApp.

url: https://staging.myapp.com
name: Checkout Flow
tags:
  - smoke
env:
  API_KEY: test-key-123
---
- launchApp

Both url and appId work — they're interchangeable for web flows:

appId: https://staging.myapp.com

Or set the URL directly on the launchApp step:

- launchApp: https://staging.myapp.com

Config Fields

Field Type Description
url string Web app URL (used by launchApp)
appId string Alias for url — both work on web
name string Flow name (shown in reports)
tags list Tags for filtering flows
env map Environment variables (accessible as ${VAR_NAME} in steps)
timeout int Flow timeout in ms (entire flow)
commandTimeout int Default timeout for all commands in ms

Lifecycle Hooks

Run setup/teardown steps automatically:

url: https://myapp.example.com
onFlowStart:
  - evalBrowserScript: "localStorage.setItem('debug', 'true')"
onFlowComplete:
  - clearConsoleLogs
---
- launchApp
- tapOn: "Submit"

onFlowStart runs before any flow steps. onFlowComplete runs after all steps (even on failure).


Common Step Properties

Every step supports these optional fields:

Field Type Description
optional bool If true, step failure doesn't fail the flow (timeout: 7s instead of 17s)
timeout int Step-specific timeout in ms (overrides flow's commandTimeout)
label string Human-readable label shown in reports
# Optional step — won't fail the flow if element is missing
- tapOn:
    text: "Dismiss"
    optional: true

# Custom timeout — wait up to 30 seconds
- assertVisible:
    text: "Report generated"
    timeout: 30000

# Label for reports
- tapOn:
    text: "Submit"
    label: "Submit the order form"

Supported Commands

These commands work on web just as they do on Android/iOS. See Flow Commands for the full reference — below are web-specific examples.

# Navigate to URL (uses flow config url, or specify directly)
- launchApp
- launchApp: https://myapp.com

# Navigate with clear state (clears cookies, storage, cache)
- launchApp:
    appId: https://myapp.com
    clearState: true

# Navigate to a URL
- openLink: "https://example.com/page"
- openBrowser: "https://example.com/page"

# Go back in browser history
- back

launchApp navigates to the URL. stopApp and killApp navigate to about:blank. clearState clears cookies, localStorage, sessionStorage, and browser cache.

Tap & Click

# By text
- tapOn: "Login"

# By any selector
- tapOn:
    testId: submit-btn
- tapOn:
    css: "button[type=submit]"
- tapOn:
    placeholder: "Search"
- tapOn:
    role: button
- tapOn:
    href: "/settings"

# Double click
- doubleTapOn: "Item"

# Long press (mouse down 1s, mouse up)
- longPressOn: "Delete"

# Click at coordinates
- tapOnPoint:
    x: 100
    y: 200

Text Input

# Type into focused element
- inputText: "[email protected]"

# Type into element by any selector
- inputText:
    text: "[email protected]"
    placeholder: "Email"

- inputText:
    text: "search query"
    css: "input[type=search]"

# Erase characters (default: 50 if no count specified)
- eraseText
- eraseText: 10

hideKeyboard is a no-op on web (no virtual keyboard).

Scroll & Swipe

# Scroll (dispatches mouse wheel events at viewport center)
- scroll: DOWN
- scroll: UP

# Scroll until element visible (max 10 scrolls)
- scrollUntilVisible:
    element: "Footer"
    direction: DOWN

# Swipe (click-and-drag)
- swipe: LEFT
- swipe: RIGHT

Assertions

# Visible text
- assertVisible: "Welcome"

# Visible element by selector
- assertVisible:
    testId: success-banner
- assertVisible:
    css: ".notification"

# Not visible
- assertNotVisible: "Error"

# Assert condition (script-based)
- assertTrue: "${totalPrice > 0}"

assertNotVisible uses a browser-side requestAnimationFrame polling loop for fast resolution (~16ms detection).

Wait Commands

# Wait for element to appear
- extendedWaitUntil:
    visible:
      text: "Ready"
    timeout: 30000

# Wait for DOM to stabilize (animations to end)
- waitForAnimationToEnd

waitForAnimationToEnd waits for the DOM to stop changing (300ms stability threshold, 10s max).

Other Shared Commands

# Keyboard
- pressKey: ENTER
- pressKey: TAB
- pressKey: ESCAPE

# Alerts & Dialogs
- acceptAlert
- dismissAlert

# Device Emulation
- setLocation:
    latitude: "37.7749"
    longitude: "-122.4194"

# Rotate viewport (swaps width/height)
- setOrientation: LANDSCAPE

# Clipboard
- copyTextFrom: "Price"
- pasteText

# Screenshots
- takeScreenshot: "login-screen.png"

# Variables
- defineVariables:
    env:
      BASE_URL: "https://staging.myapp.com"

# Flow Control
- runFlow: "flows/login.yaml"
- repeat:
    times: "3"
    commands:
      - scroll: DOWN

Parallel Browser Execution

Run tests across multiple browser instances simultaneously:

# Run with 3 parallel browsers
maestro-runner --platform web --parallel 3 flows/

# Run with 5 parallel browsers, headed mode
maestro-runner --platform web --parallel 5 --headed flows/

Each parallel browser is a separate Chromium process with its own profile — full isolation, no shared state. The browser binary is downloaded once (if needed) before launching workers.

Flows are distributed across browsers using the same work-stealing scheduler as mobile parallel execution.


Unsupported Commands

These mobile-only commands are not available on the web platform:

Command Reason
setAirplaneMode / toggleAirplaneMode Use setNetworkConditions: { offline: true } instead
travel No GPS simulation in browser (use setLocation for static location)
addMedia No camera/photo library in browser
startRecording / stopRecording No screen recording
clearKeychain No keychain in browser (use clearState)
setPermissions Use grantPermissions / resetPermissions instead
assertNoDefectsWithAI / assertWithAI / extractTextWithAI Not yet supported on web

Troubleshooting

Browser download on first run

On first run, maestro-runner downloads a bundled Chromium browser (~130MB). This is one-time — subsequent runs use the cached binary at ~/.maestro-runner/browsers/. To use your existing Chrome installation instead:

maestro-runner --platform web --browser chrome flows/

Element not found

The web driver finds elements using a multi-stage cascade (CSS > attribute > role > ID > text). If an element is not found:

  1. Use a precise selectorcss, testId, or name are the most reliable. Text selectors search the AX tree which may not match what you see on screen.
  2. Select by type attribute — use CSS: css: "input[type=email]", css: "button[type=submit]".
  3. Wait for the page — add - waitForAnimationToEnd before the assertion if the page is still loading.
  4. Check visibility — elements with display: none, visibility: hidden, or zero dimensions are not findable.
  5. Check nth — if multiple elements match, use nth: 0, nth: 1, etc. to pick a specific one.
  6. Use headed mode — run with --headed to see what the browser sees.

Selector priority

If you set multiple selector fields on the same step, only one is used (first match wins in this order):

css > testId > name > placeholder > href > alt > title > role > id > text > textContains > textRegex

Headed mode for debugging

Run with --headed to see the browser window and watch your test execute:

maestro-runner --platform web --headed flows/login.yaml

Console errors

Use assertNoJSErrors to catch silent JavaScript errors. To debug, add getConsoleLogs and inspect the output:

- getConsoleLogs: logs
- evalBrowserScript:
    script: |
      // logs are now in a variable, use evalScript to inspect
      return "check logs variable"

Unsupported selector warning

If you use a selector field that's not supported on web (e.g., traits, width, height, tolerance, index), the driver logs a warning and ignores that field. The step still executes using the remaining selector fields.