npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nhdms/waestro

v0.1.1

Published

Maestro-style YAML web testing platform wrapping Playwright, optimized for AI agents

Readme

waestro

Maestro-style YAML testing for the web — powered by Playwright.

TypeScript Playwright License: MIT


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

Assertions

| 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.png

Tabs & 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: Bob

Storage

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

Visual

| 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 dialog

handleDialog 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: 2

Variables & 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)

  1. env block in the flow header
  2. --env KEY=VALUE CLI flags
  3. process.env environment variables
  4. output.* namespace (from evalScript / 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.yaml

Node.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: 2341ms

JSON

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-saved

API 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.