Step Types
Prowl supports 30 step types. Most have both a shorthand form (concise, readable) and an explicit form (full control over selectors).
These steps target the web. A portable subset also runs on the experimental native targets (macOS, Android, iOS) — see Step compatibility by target below. Web-only steps (navigate, network mocking, dialogs, etc.) are rejected up front on native targets.
Step compatibility by target
The web target runs every step. On a native target, web-only steps in the top-level hunt are rejected up front at validation time — before anything launches — with a clear, target-labelled error. A runHunt step validates its referenced hunt when that step executes, before the nested hunt starts.
| Step | Web | macOS | Android / iOS |
|---|---|---|---|
click, fill, type, press | ✅ | ✅ | ✅ |
wait, waitForSelector | ✅ | ✅ | ✅ |
assert: visible / notVisible | ✅ | ✅ | ✅ |
screenshot, assertScreenshot | ✅ | ✅ | ✅ |
repeat, if, runHunt, copyText | ✅ | ✅ | ✅ |
scrollTo | ✅ | ✅ | ✅ |
scroll (directional) | ✅ | ❌ | ✅ |
hover | ✅ | ✅ | ❌ (no touch equivalent yet) |
assertWithAI | ✅ | ✅ | ✅ |
Hunt-level assertions: (selectorExists / selectorNotExists) | ✅ | ✅ | ✅ |
navigate, waitForUrl, waitForNetworkIdle, waitForResponse | ✅ | ❌ | ❌ |
mockRoute / unmockRoute | ✅ | ❌ | ❌ |
evalScript, runScript | ✅ | ❌ | ❌ |
onDialog | ✅ | ❌ | ❌ |
select / selectOption, setInputFiles | ✅ | ❌ | ❌ |
waitForDownload | ✅ | ❌ | ❌ |
assert: urlIncludes / urlEquals | ✅ | ❌ | ❌ |
Hunt-level urlIncludes / urlEquals / noConsoleErrors / noNetworkErrors | ✅ | skipped | skipped |
The scroll verbs are asymmetric across native targets: directional scroll is a synthesized touch swipe on the Android and iOS targets but has no macOS accessibility equivalent (rejected there); scrollTo is supported everywhere — macOS resolves it via AXScrollToVisible, and the touch targets run a bounded swipe-loop probe. hover is portable on macOS but rejected on the touch targets (Android and iOS) — they have no hover concept.
A web-only step produces an error naming the offending step, for example:
Step "navigate" is not supported by the Android target. It is web-only;
use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).
See the Android and iOS target pages for per-target selector dialects and step notes (e.g. press supports a smaller key set on the touch targets).
Native targets do evaluate hunt-level assertions: blocks: selectorExists / selectorNotExists run against the app, while web-only assertion types (urlIncludes, urlEquals, noConsoleErrors, noNetworkErrors) are reported as skipped rather than dropped. See the Assertions page.
navigate
Navigate to a URL (relative to your target.url or absolute).
- navigate: "/"
- navigate: "/login"
- navigate: "https://example.com/page"
click
Click an element. Shorthand finds buttons by text, then falls back to any matching text.
- Shorthand
- Explicit
# Finds by button role, then text
- click: "Sign In"
# Use any Playwright selector
- click:
selector: "[data-testid='submit-btn']"
Shorthand click uses substring matching on button names. click: "Save" will match a button labeled "Save & New". For exact matching, use click: { selector: 'button:text-is("Save")' }.
fill
Fill an input field. Shorthand finds inputs by label or placeholder text.
- Shorthand
- Explicit
# Finds by label, then placeholder
- fill:
"Email": "user@example.com"
# Use any selector
- fill:
selector: "input[name='email']"
value: "user@example.com"
type
Type into the currently focused element. Useful after clicking into a field.
- Shorthand
- Explicit (fill equivalent)
- click: "Message"
- type: "Hello, I have a question."
- fill:
selector: ":focus"
value: "Hello, I have a question."
copyText
Extract the text content of an element and store it as a runtime variable for use in later steps. Explicit-only.
selector(required) — element to read text fromas(required) — variable name to capture the text into
- copyText:
selector: "[data-testid='order-id']"
as: "ORDER_ID"
- navigate: "/orders/{{ORDER_ID}}"
press
Press a keyboard key on a specific element.
- press:
selector: "input[name='search']"
key: "Enter"
On the web and macOS targets, press accepts the full Playwright-style key vocabulary: Enter / Return / Space, Escape, Tab, Backspace, Delete, Home, End, PageUp, PageDown, the arrows (ArrowUp / ArrowDown / ArrowLeft / ArrowRight), F1–F12, single printable characters, and +-joined modifier combos using Control / Shift / Alt / Meta (with Ctrl / Option / Cmd / Command aliases), e.g. Meta+s or Control+a. On macOS, a bare Enter / Return / Space takes a deterministic AXPress fast path when the element supports it; everything else is synthesized as a real keystroke posted to the target app. See the macOS Target page.
select / selectOption
Select a dropdown value. Shorthand finds by label, explicit uses a selector.
- Shorthand
- Explicit
# Finds <select> by label, aria-label, or placeholder
- select:
"State": "FL"
- selectOption:
selector: "select[name='state']"
value: "FL"
assert
Mid-flow assertions. Fails the hunt immediately if the assertion fails.
- assert:
visible: "Welcome back"
- assert:
notVisible: "Error"
- assert:
urlIncludes: "/dashboard"
- assert:
urlEquals: "https://example.com/dashboard"
See the Assertions page for the full reference.
wait
Wait for text to appear on the page.
- Shorthand
- Explicit
# Wait for text with default timeout
- wait: "Loading complete"
# With custom timeout
- wait:
for: "Loading complete"
timeout: 10000
wait uses exact text matching (text="X"). It won't match substrings. For substring matching, use waitForSelector with text=X (without quotes).
waitForSelector
Wait for any Playwright selector to appear.
- waitForSelector:
selector: "[data-testid='results-table']"
timeout: 5000
waitForUrl
Wait for the URL to contain a substring.
- waitForUrl:
value: "/dashboard"
timeout: 10000
waitForNetworkIdle
Wait for all network requests to complete.
- waitForNetworkIdle:
timeout: 5000
waitForResponse
Wait for a specific network response before continuing (0.1.10). Web-only. More precise than waitForNetworkIdle, which waits for all traffic to settle — waitForResponse resolves as soon as the one response you care about arrives.
- waitForResponse:
url: "**/api/orders"
status: 200
timeout: 10000
url(required) is matched against each response URL as an unanchored glob:*/**match any run of characters (including/,?, and&), and?matches a single character. Because it is unanchored, a wildcard-free pattern behaves as a substring match —/api/ordersmatcheshttps://shop.test/api/orders?page=2.status(optional) resolves the step only on a response with that exact HTTP status.timeout(optional, milliseconds) fails the step with a clear message naming the pattern (and thestatus/timeoutyou set) if no matching response arrives in time.
The url supports both hunt-var interpolation ({{VAR}} from vars/env) and runtime-var substitution from a value captured earlier with copyText or evalScript:
- copyText:
selector: "[data-testid='order-id']"
as: "ORDER_ID"
- click: "Refresh status"
- waitForResponse:
url: "**/api/orders/{{ORDER_ID}}"
status: 200
Like the other network steps, waitForResponse is web-only — it is rejected up front on the experimental macOS, Android, and iOS targets.
waitForDownload
Wait for a file download to start and save it into the run's artifact directory. Explicit-only; pass null to accept any download. Author it immediately after the step that triggers the download.
filename(optional) — assert the downloaded file's name; the step fails if it doesn't matchtimeout(optional, default30000) — max time to wait, in ms
# Assert the filename
- click: "Export CSV"
- waitForDownload:
filename: "report.csv"
# Accept any download
- click: "Download"
- waitForDownload: null
The downloaded file is saved into the run directory (.prowl/runs/<timestamp>/).
onDialog
Handle browser-native dialogs (alert, confirm, prompt). Register the handler before the action that triggers the dialog.
- onDialog:
action: accept # or "dismiss"
- click: "Delete" # this triggers the confirm dialog
setInputFiles
Set files on <input type="file"> elements. Paths are relative to .prowl/.
# Single file
- setInputFiles:
selector: "[data-testid='avatar-upload']"
files: "fixtures/avatar.png"
# Multiple files
- setInputFiles:
selector: "[data-testid='attachments']"
files:
- "fixtures/doc1.pdf"
- "fixtures/doc2.pdf"
runHunt
Execute another hunt file inline. Enables reusable sub-flows like login.
- Shorthand
- Explicit
# Run the hunt as-is
- runHunt: "login-flow"
# With variable overrides
- runHunt:
name: "login-flow"
vars:
EMAIL: "admin@test.com"
PASSWORD: "{{ADMIN_PASSWORD}}"
Circular dependencies are detected automatically (A → B → A will error).
if
Conditionally run steps based on whether a selector is visible. Explicit-only.
visibleornotVisible(exactly one) — the selector to testthen(required) — steps to run when the condition holdselse(optional) — steps to run otherwise
- if:
visible: ".cookie-banner"
then:
- click: ".accept"
else:
- wait: "Welcome back"
repeat
Repeat a block of steps a fixed number of times or while a selector condition holds. Explicit-only. The maxSteps guardrail is enforced across all iterations.
times(fixed count) orwhile(condition) — exactly onewhile.visible/while.notVisible— the condition, when usingwhilemaxIterations(required withwhile) — safety cap on loop countsteps(required) — steps to run each iteration
# Fixed count
- repeat:
times: 3
steps:
- click: ".load-more"
# Condition-based loop
- repeat:
while:
visible: ".load-more"
maxIterations: 10
steps:
- click: ".load-more"
mockRoute
Intercept a URL pattern and return a custom response — useful for testing error, loading, or empty states. Explicit-only.
url(required) — Playwright route pattern (e.g.**/api/users)response.status(required)- exactly one of
response.bodyorresponse.file response.contentType(optional, defaultapplication/json)
- mockRoute:
url: "**/api/users"
response:
status: 200
body: '{"users":[{"id":1}]}'
unmockRoute
Remove a previously registered route mock. Explicit-only.
url(required) — must match the mocked route's pattern
- unmockRoute:
url: "**/api/users"
evalScript
Evaluate a JavaScript expression in the browser page context, optionally capturing the result as a runtime variable.
- Shorthand
- Explicit (capture result)
# Evaluate without capturing
- evalScript: "window.scrollTo(0, 0)"
- evalScript:
expression: "document.querySelectorAll('tr').length"
as: "ROW_COUNT"
- assert:
visible: "{{ROW_COUNT}} results"
The captured result is stringified and available as {{ROW_COUNT}} in later steps.
runScript
Execute an external JavaScript file in the browser page context. Explicit-only.
file(required) — path to the script; absolute, or relative to the config directory (.prowl/)
- runScript:
file: "scripts/seed-data.js"
assertScreenshot
Visual regression check: compare the current screenshot against a stored baseline. Explicit-only.
name(required) — baseline identifier; stored at.prowl/baselines/<name>.pngthreshold(optional, default0.1) — allowed pixel-difference fraction (0–1)
- assertScreenshot:
name: "homepage"
- assertScreenshot:
name: "checkout-form"
threshold: 0.05
On the first run the baseline is created and the step passes. On later runs the current screenshot is compared against the baseline; the step fails if the difference exceeds threshold, writing a diff image into the run directory. Accept new baselines with prowl update-baselines.
assertWithAI
AI-powered visual assertion (0.1.6): screenshots the current page/screen, sends the image plus a natural-language claim to a vision-capable LLM, and passes or fails the step on the model's verdict. The model's explanation is recorded in the step result — on pass and fail — for auditability.
- assertWithAI: "The checkout total shows $42.00 and the Pay button is enabled"
- assertWithAI: "An error banner is visible near the top of the page"
Works on the web target and any target that exposes a screenshot capability — including the experimental macOS, Android, and iOS targets.
BYOK configuration
assertWithAI runs on your own API key (BYOK) — the request goes directly from your machine to the provider. Configure it with environment variables:
| Variable | Purpose |
|---|---|
PROWL_AI_KEY | Your Anthropic or OpenAI API key. Required to enable the step. |
PROWL_AI_PROVIDER | anthropic (default) or openai. |
PROWL_AI_MODEL | Override the model. Defaults are vision-capable per provider. |
PROWL_AI_BASE_URL | Override the API endpoint (self-hosted gateway or a future managed path). |
AI verdicts are not deterministic, so this is the one documented exception to Prowl's determinism principle. The call is made at temperature 0 and the model is asked for a strict pass/fail verdict, and the explanation is always recorded — but treat assertWithAI as a smoke check, not a pixel-exact gate. Prefer assert: visible / assertScreenshot where a deterministic check will do.
With no AI provider configured (PROWL_AI_KEY unset), the step skips with a warning (a non-fatal ○ outcome) — it never fails the run and never silently passes. So a hunt using assertWithAI stays green in a CI job that has no key, then starts asserting once a key is provided.
screenshot
Capture a screenshot at any point.
- screenshot:
name: "after-login"
hover
Hover over an element. Useful for triggering tooltips, dropdown menus, or hover states. Explicit-only — requires a selector.
- hover:
selector: "[data-testid='user-menu']"
- hover:
selector: "text=Profile"
scroll
Scroll by direction and optional amount.
# Scroll down (default amount)
- scroll:
direction: "down"
# Scroll up by 300 (px on web, device points on mobile)
- scroll:
direction: "up"
amount: 300
On the web target the amount is pixels (window.scrollBy). On the Android and iOS targets (0.1.8) scroll is a synthesized touch swipe: amount is the swipe distance in device points, clamped to a safe fraction of the screen and defaulting to 75% of the relevant axis when omitted; a negative amount reverses direction, matching the web target. Directional scroll has no macOS equivalent and is rejected on the macOS target (use scrollTo there).
scrollTo
Scroll a specific element into view. Explicit-only — requires a selector. Supported on every target.
- scrollTo:
selector: "[data-testid='pricing-section']"
- scrollTo:
selector: "text=Footer"
On the web target the element is scrolled into view; on macOS it resolves to the accessibility AXScrollToVisible capability; on the Android and iOS targets (0.1.8) it runs a bounded swipe-loop probe — searching downward, then reversing to search upward — and fails with an error naming the selector and the attempt count if the element never becomes visible.
Shorthand vs Explicit Quick Reference
| Shorthand | Explicit Equivalent |
|---|---|
click: "Sign In" | click: { selector: 'button:has-text("Sign In")' } |
fill: { "Email": "val" } | fill: { selector: 'input[placeholder="Email"]', value: "val" } |
type: "text" | fill: { selector: ':focus', value: "text" } |
select: { "State": "FL" } | selectOption: { selector: 'select[name="state"]', value: "FL" } |
wait: "Welcome" | waitForSelector: { selector: 'text="Welcome"' } |
runHunt: "login" | runHunt: { name: "login" } |