@nhdms/waestro
v0.1.1
Published
Maestro-style YAML web testing platform wrapping Playwright, optimized for AI agents
Maintainers
Readme
waestro
Maestro-style YAML testing for the web — powered by Playwright.
Why Waestro?
AI agents writing Playwright tests burn tokens fast. Waestro cuts that by 5x+.
Raw Playwright (~25 lines, ~650 tokens):
import { test, expect } from '@playwright/test';
test('login', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
await page.getByRole('textbox', { name: 'Password' }).fill('secret123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Welcome back!')).toBeVisible();
await expect(page).toHaveURL('**/dashboard');
});Waestro YAML (~8 lines, ~120 tokens):
url: https://app.example.com
env:
EMAIL: [email protected]
PASSWORD: secret123
---
- navigate: /login
- tapOn: "Email"
- inputText: ${EMAIL}
- tapOn: "Password"
- inputText: ${PASSWORD}
- tapOn: "Sign In"
- assertVisible: "Welcome back!"
- assertUrl: "**/dashboard"Same test. 5x fewer tokens. Ideal for AI agents generating and maintaining test suites.
Quick Start
# Install
npm install -g waestro
npx playwright install chromium
# Create a flow template
waestro init login
# Run it
waestro run login.yaml
# Run all flows in a directory
waestro run flows/YAML Flow Format
A flow file has two sections separated by ---:
# Header (all fields optional)
url: https://app.example.com # Base URL — navigate: /path resolves against this
env:
EMAIL: [email protected] # Variables available as ${EMAIL}
PASSWORD: secret123
timeout: 5000 # Command timeout in ms (default: 5000)
headless: true # Run headless (default: true)
onError: fail # fail | continue (default: fail)
---
# Command list — one command per list item
- navigate: /login
- tapOn: "Sign In"
- assertVisible: "Welcome"Header fields:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| url | string | — | Base URL for relative navigate paths |
| env | object | {} | Variable definitions |
| timeout | number | 5000 | Per-command timeout (ms) |
| headless | boolean | true | Run browser headless |
| onError | string | fail | fail stops on first error; continue runs all commands |
Command Reference
Navigation
| Command | Args | Example |
|---------|------|---------|
| navigate | URL string | - navigate: /dashboard |
| goBack | — | - goBack |
| goForward | — | - goForward |
| refresh | — | - refresh |
Interaction
| Command | Args | Example |
|---------|------|---------|
| tapOn | selector | - tapOn: "Submit" |
| doubleTapOn | selector | - doubleTapOn: "item-row" |
| longPressOn | selector | - longPressOn: "Delete" |
| hoverOn | selector | - hoverOn: "Tooltip trigger" |
Input
| Command | Args | Example |
|---------|------|---------|
| inputText | string | - inputText: ${EMAIL} |
| typeText | string (key-by-key) | - typeText: hello |
| clearText | — | - clearText |
| pressKey | key name | - pressKey: Enter |
inputText fills the focused element instantly. typeText fires key events for each character (useful for autocomplete inputs).
- tapOn: "Search"
- typeText: "react"
- pressKey: ArrowDown
- pressKey: EnterAssertions
| Command | Args | Example |
|---------|------|---------|
| assertVisible | selector | - assertVisible: "Welcome back!" |
| assertNotVisible | selector | - assertNotVisible: "Loading..." |
| assertText | {target, text} | see below |
| assertUrl | URL pattern (glob) | - assertUrl: "**/dashboard" |
| assertTitle | string | - assertTitle: "My App" |
- assertText:
target: "#status-badge"
text: "Active"
# text can also be a regex: /^Active|Pending$/Forms
| Command | Args | Example |
|---------|------|---------|
| selectOption | {target, value} | see below |
| check | selector | - check: "Remember me" |
| uncheck | selector | - uncheck: "Subscribe" |
| uploadFile | {target, path} | see below |
- selectOption:
target: "#country"
value: US
- uploadFile:
target: "#avatar-input"
path: /tmp/avatar.pngTabs & Frames
| Command | Args | Example |
|---------|------|---------|
| newTab | — | - newTab |
| switchTab | index (0-based) | - switchTab: 1 |
| closeTab | — | - closeTab |
| switchFrame | CSS selector | - switchFrame: "#payment-iframe" |
Network
| Command | Args | Example |
|---------|------|---------|
| waitForRequest | URL pattern | - waitForRequest: "**/api/login" |
| waitForResponse | URL pattern | - waitForResponse: "**/api/users" |
| mockApi | {url, method?, status?, body?, headers?} | see below |
- mockApi:
url: "**/api/users"
method: GET
status: 200
body:
users:
- name: Alice
- name: BobStorage
| Command | Args | Example |
|---------|------|---------|
| setCookie | {name, value, domain?, path?, secure?, httpOnly?, sameSite?, expires?} | see below |
| setLocalStorage | {key, value} | see below |
| clearStorage | — | - clearStorage |
- setCookie:
name: session
value: abc123
domain: example.com
secure: true
- setLocalStorage:
key: theme
value: darkVisual
| Command | Args | Example |
|---------|------|---------|
| takeScreenshot | filename (no extension) | - takeScreenshot: checkout-page |
| assertScreenshot | filename (no extension) | - assertScreenshot: dashboard |
Screenshots are saved to ./screenshots/ (override with --screenshot-dir).
Dialog
| Command | Args | Example |
|---------|------|---------|
| handleDialog | accept | dismiss | {action, text?} | see below |
- handleDialog: accept
# or with prompt text:
- handleDialog:
action: accept
text: "my input"
- tapOn: "Delete Account" # triggers the dialoghandleDialog must appear before the action that triggers the dialog.
Scripting
| Command | Args | Example |
|---------|------|---------|
| evalScript | JavaScript expression | - evalScript: "document.title" |
| makeHttpRequest | {url, method?, headers?, body?} | see below |
Results are stored in the output namespace:
evalScript→${output.result}makeHttpRequest→${output.status},${output.body},${output.json}
- makeHttpRequest:
url: https://api.example.com/token
method: POST
body:
username: admin
password: secret
- tapOn: "API Token"
- inputText: ${output.json}Flow Control
| Command | Args | Example |
|---------|------|---------|
| runFlow | file path | - runFlow: ./shared/login.yaml |
| repeat | {times, commands[]} | see below |
| when | {visible?, notVisible?, commands[]} | see below |
| wait | milliseconds | - wait: 1000 |
- repeat:
times: 3
commands:
- tapOn: "Add Item"
- assertVisible: "Item added"
- when:
visible: "Cookie banner"
commands:
- tapOn: "Accept"Selectors
Waestro resolves selectors automatically — no need to write CSS or XPath by hand.
Bare String Resolution
When you write - tapOn: "Submit", waestro tries these strategies in order until one matches:
| Priority | Strategy | Matches |
|----------|----------|---------|
| 0 | CSS selector | if string contains #, ., [, >, etc. |
| 1 | Role + name | button[name="Submit"], link[name="Submit"], etc. |
| 2 | Exact text | element with exact text content |
| 3 | Test ID | data-testid="Submit" |
| 4 | Partial text | element containing the text |
Explicit Selector Objects
For precise control:
# By CSS selector
- tapOn:
css: "#submit-btn"
# By test ID (data-testid)
- tapOn:
id: submit-button
# By ARIA role + accessible name
- tapOn:
role: button
name: Submit
# By exact text
- tapOn:
text: "Sign in with Google"
# Nth match (0-indexed)
- tapOn:
role: listitem
index: 2Variables & Interpolation
Syntax
Use ${VAR_NAME} in any string argument.
env:
BASE_URL: https://staging.example.com
USER: [email protected]
---
- navigate: ${BASE_URL}/login
- inputText: ${USER}Sources (resolution order)
envblock in the flow header--env KEY=VALUECLI flagsprocess.envenvironment variablesoutput.*namespace (fromevalScript/makeHttpRequest)
Random Generators
| Expression | Example Output |
|------------|---------------|
| ${random.email} | [email protected] |
| ${random.name} | Alex Johnson |
| ${random.number} | 4782 |
| ${random.text} | kxqmrpzl |
- tapOn: "First Name"
- inputText: ${random.name}
- tapOn: "Email"
- inputText: ${random.email}Output Namespace
evalScript and makeHttpRequest write results to ${output.*}:
- evalScript: "window.APP_VERSION"
- assertText:
target: "#version-badge"
text: ${output.result}Child Scopes
runFlow creates a child variable scope — the sub-flow inherits parent variables but cannot overwrite them in the parent scope.
CLI Reference
waestro run <flow>
Run a flow file or all .yaml/.yml files in a directory.
waestro run login.yaml
waestro run flows/| Flag | Default | Description |
|------|---------|-------------|
| --headed | false | Show browser window |
| --timeout <ms> | 5000 | Override global timeout |
| --reporter <type> | console | console or json |
| --env <KEY=VALUE...> | — | Override/add env variables |
| --screenshot-dir <dir> | ./screenshots | Screenshot output directory |
| --on-error <mode> | fail | fail or continue |
| --base-url <url> | — | Override base URL |
Exit codes: 0 = all passed, 1 = test failure / runtime error
waestro validate <flow>
Parse and validate YAML syntax without running the browser.
waestro validate login.yaml
waestro validate flows/waestro init [name]
Scaffold a new flow template.
waestro init # creates flow.yaml
waestro init checkout # creates checkout.yamlNode.js API
run() — convenience function
import { run } from 'waestro';
const report = await run('login.yaml', {
headed: false,
timeout: 8000,
env: { EMAIL: '[email protected]' },
baseUrl: 'https://staging.example.com',
});
console.log(report.status); // 'passed' | 'failed' | 'error'WaestroEngine — full control
import { WaestroEngine } from 'waestro';
const engine = new WaestroEngine({
headed: true,
timeout: 10000,
screenshotDir: './test-screenshots',
onError: 'continue',
});
await engine.loadFlow('checkout.yaml');
const report = await engine.execute();
await engine.close();
if (report.status !== 'passed') {
console.error('Failed at command:', report.error?.commandIndex);
}validate() — schema check only
import { validate } from 'waestro';
const errors = await validate('login.yaml');
if (errors.length > 0) {
console.error(errors);
}registerCommand() — custom commands
import { registerCommand } from 'waestro';
import type { CommandContext } from 'waestro';
registerCommand('scrollToBottom', async (_args: unknown, ctx: CommandContext) => {
await ctx.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
});
registerCommand('waitForAnimation', async (args: unknown, ctx: CommandContext) => {
const ms = (args as number) ?? 300;
await ctx.page.waitForTimeout(ms);
});Then use in YAML:
---
- navigate: /feed
- scrollToBottom
- waitForAnimation: 500
- assertVisible: "Load more"WaestroOptions
interface WaestroOptions {
headed?: boolean; // Show browser (default: false)
headless?: boolean; // Explicit headless flag
timeout?: number; // Per-command timeout ms (default: 5000)
env?: Record<string, string>;
reporter?: 'console' | 'json';
screenshotDir?: string; // Default: './screenshots'
baseUrl?: string; // Override flow header url
onError?: 'fail' | 'continue';
}Reporters
Console (default)
Colored, human-readable output:
✓ Flow: login.yaml — PASSED
Duration: 2341ms
✓ navigate "/login" (312ms)
✓ tapOn "Email" (88ms)
✓ inputText "[email protected]" (45ms)
✓ tapOn "Sign In" (201ms)
✓ assertVisible "Welcome back!" (134ms)
Summary:
5 passed, 0 failed (5 total)
Duration: 2341msJSON
Machine-readable, pipe-friendly:
waestro run login.yaml --reporter json | jq '.status'FlowReport schema:
interface FlowReport {
flow: string; // file path
status: 'passed' | 'failed' | 'error';
duration: number; // total ms
startedAt: string; // ISO 8601
completedAt: string;
commands: CommandReport[];
error?: {
message: string;
commandIndex: number;
screenshot?: string; // path to failure screenshot
url: string; // page URL at time of failure
};
env: Record<string, string>; // resolved env vars
}
interface CommandReport {
index: number;
command: string; // e.g. "tapOn"
args: unknown;
status: 'passed' | 'failed' | 'skipped';
duration: number; // ms
error?: string;
}Examples
Login with variables
url: https://app.example.com
env:
EMAIL: [email protected]
PASSWORD: supersecret
timeout: 8000
---
- navigate: /login
- tapOn: "Email"
- inputText: ${EMAIL}
- tapOn: "Password"
- inputText: ${PASSWORD}
- tapOn: "Sign In"
- assertVisible: "Dashboard"
- assertUrl: "**/dashboard"
- assertTitle: "Dashboard — My App"Form interaction with assertions
url: https://app.example.com
---
- navigate: /profile/edit
- assertVisible: "Edit Profile"
- tapOn: "First Name"
- clearText
- inputText: ${random.name}
- selectOption:
target: "#timezone"
value: America/New_York
- check: "Email notifications"
- uncheck: "SMS notifications"
- uploadFile:
target: "#avatar"
path: /tmp/avatar.jpg
- tapOn: "Save Changes"
- assertVisible: "Profile updated"
- takeScreenshot: profile-savedAPI mocking with network commands
url: https://app.example.com
---
# Intercept API before navigating
- mockApi:
url: "**/api/users"
method: GET
status: 200
body:
users:
- id: 1
name: "Alice"
- id: 2
name: "Bob"
- navigate: /users
- assertVisible: "Alice"
- assertVisible: "Bob"
# Wait for a specific request
- tapOn: "Export CSV"
- waitForResponse: "**/api/export"
- assertVisible: "Download ready"Advanced: repeat, when, and sub-flows
url: https://shop.example.com
env:
ITEMS_TO_ADD: "3"
---
- navigate: /products
- runFlow: ./shared/dismiss-cookie-banner.yaml
# Conditionally handle a promo modal
- when:
visible: "Special offer"
commands:
- tapOn: "No thanks"
# Add 3 items to cart
- repeat:
times: 3
commands:
- tapOn:
role: button
name: "Add to cart"
index: 0
- assertVisible: "Added to cart"
- navigate: /cart
- assertVisible: "3 items"
- tapOn: "Checkout"
# Verify the order total via API
- makeHttpRequest:
url: https://shop.example.com/api/cart
method: GET
- assertText:
target: "#order-total"
text: ${output.json}Maestro Compatibility
Waestro uses the same YAML syntax and command names as Maestro where applicable, making flows portable between mobile and web.
| Maestro Command | Waestro Equivalent | Notes |
|----------------|-------------------|-------|
| tapOn | tapOn | Identical |
| longPressOn | longPressOn | Identical |
| doubleTapOn | doubleTapOn | Identical |
| inputText | inputText | Identical |
| pressKey | pressKey | Identical |
| assertVisible | assertVisible | Identical |
| assertNotVisible | assertNotVisible | Identical |
| scroll | scroll | Identical |
| runFlow | runFlow | Identical |
| repeat | repeat | Identical |
| when | when | Identical |
| swipeLeft/Right | — | Web-only: use pressKey: ArrowLeft |
| launchApp | navigate | Use URL navigation |
| back | goBack | Renamed |
| — | assertUrl | Web-only |
| — | mockApi | Web-only |
| — | setCookie | Web-only |
| — | evalScript | Web-only |
License
MIT — see LICENSE.
