Skip to content

Flow file & selectors

A flow is one YAML file. Top-level keys:

Key Summary
appId Default app under test — a bundle / package id, or a URL for web
commands The ordered list of steps (the only required key)
tags Tags for --tags / --exclude-tags filtering
before Setup steps, or clean; a failure here skips commands (onFlowStart is the older name)
after Teardown steps — always run, whatever happened (onFlowComplete is the older name)
inheritHooks false drops the before/after inherited from the project and the testspace
env Flow variables, used as ${VAR}; config env:, .env, -e override them
params Declared inputs: description, required, default, options; callers pass them via runFlow env: or -e
device none = pure-API flow: no device is resolved or booted
db Default connection string for sql steps — usually ${DATABASE_URL}
vscode VS Code extension target: testspace, vsix, workspace, settings
appId: https://staging.example.com
tags: [smoke]
env:
USER: qa@example.com
before:
- launchApp: https://staging.example.com
commands:
- tapOn: "Log in"
- inputText: "${USER}"
- assertVisible: "Welcome"
after:
- takeScreenshot: end

Commands that target an element take a bare string (exact visible text) or a selector object. Matchers, filters and relations can be combined; at most one relational key per selector, and css runs first — every other key the selector carries then filters what it matched. Values must be strings — quote numbers (text: "2025").

above, below, leftOf and rightOf read the anchor’s own line: a row for leftOf/rightOf, a column for above/below. So leftOf a caption means the control beside that caption, not everything further along the page, and the caption of the next row down does not answer for this one. Matches are ordered nearest the anchor first — which is the order index counts in — so { role: switch, leftOf: "Draft" } is the switch closest to Draft. On a screen where nothing at all stands in the anchor’s line the older, looser rule applies instead (the anchor’s centre alone, in tree order), so a selector written against a caption off to the side keeps resolving.

text and contains are read twice. First exactly as the flow wrote them; only when nothing on the screen answers that way is the screen read a second time with runs of whitespace collapsed (a non-breaking space among them) and case folded — so "log in" still finds LOG IN, and a label that gained a line break still matches. An exact match always wins, an index past the end is not a reason to loosen anything, and a step that only matched the second way carries the note matched ignoring case/spacing. The second reading belongs to steps that look for an element: assertNotVisible, waitForNotVisible and a notVisible condition read the text as written only, because a screen that spells it differently no longer has that text on it.

of is what makes an index checkable: it records how many elements the selector matched when the step was written, and filters nothing. { text: "Log In", index: 1, of: 2 } on a page that now answers with three notes 2 matched when this was recorded, 3 match now — the index may point at the wrong one, and says nothing while the count holds. An indexed selector without of is flagged once per run instead. A css step counts the same way, and — like the tree walk — leaves hidden elements out of both the count and the numbering index follows. A css match is an ordinary node of that walk: relations, scrollUntilVisible and centerElement read it like any other match, and its index counts the page as a whole — the main document first, then every frame in it, in one numbering. { css: ".row", text: "Save" } is the rows the CSS found, kept to the one whose text reads Save; when nothing survives the filter the step fails as not found and names both keys.

A name that reaches both a control and a caption reading the same words is one thing to press, not two. A step that acts on an element — tapOn, doubleTapOn, longPressOn, hover, check/uncheck, selectOption, uploadFile, dragAndDrop, scrollUntilVisible — resolves to the controls only; a step that reads a word (copyText) or proves one is on screen (assertVisible, waitForVisible, assertNotVisible, a when: gate) matches every element the name reaches. Where several controls remain it is the ambiguity it always was — first match, index, a relation. Where no control answers to the name the word is acted on anyway and the step notes matched only text, nothing to press — a note a selector that names a role never carries, having already said what kind of element it means. A control that wears its name on an element it wraps (<button><span>Save</span></button>, where the page gives the button no text of its own) is a control: the word inside it is the thing to press, the caption beside it is not. An index switches the rule off — the number already chose which match it meant, and it counts every element, text nodes included.

or gives a selector up to three fallbacks. Each is a full selector with its own index/of; alternatives cannot nest. The primary is tried first, and only on a page that has settled — and only when the primary matches nothing — are the alternatives read in order. A hit on one passes the step and marks the row drifted: the note says found by alternative 2 (…) — the primary selector no longer matches, the JSON report carries a drifted field, JUnit a <property name="drifted">, the HTML report a chip, and the run summary counts them. The exit code is unchanged unless tapstep test --strict-selectors, which fails a run that leaned on a fallback. Exporters write the primary alone and name the alternatives in a comment on the line.

or belongs to the selector, not to one command: assertNotVisible is green only when neither the primary nor any alternative is on screen, a when: / while: gate opens on whichever of them answers, and scrollUntilVisible stops as soon as one of them is in view.

Key Summary
text Exact match against any name the element carries — own text, accessibility label, current value or placeholder (a bare string selector is text)
id Accessibility id / resource id / DOM id
testId Test hook set by the app: data-testid/data-test/data-qa/data-cy on web, the accessibility identifier on native — the most stable matcher
role ARIA-style role, usually paired with name (native widget classes map onto the same vocabulary) — the engine names: button | checkbox | radio | switch | menuitem | tab | option | link | textbox | searchbox | combobox | spinbutton | slider | list | listitem | dialog | navigation
name Accessible name — the label when there is one, else the own text; whitespace collapsed. Pair with role
label Accessibility label / <label> text — keeps matching after a field is filled in
placeholder Placeholder / hint shown while the field is empty
value What the field currently holds
contains Substring of any name the element carries
regex Regular expression over any name the element carries (no ${} substitution)
index Pick the N-th match (0-based)
of How many elements matched when the step was recorded — filters nothing; the run flags it when the page answers with a different number
enabled Filter by enabled state (true/false)
checked Filter by checked state
focused Filter by focus
selected Filter by selected state
traits Shape hints: text, long-text, square
width Expected width in px (with tolerance)
height Expected height in px (with tolerance)
tolerance Allowed ± px for width / height
css CSS selector (web / VS Code only; the other keys narrow what it matched)
or Up to 3 fallback selectors, tried in order when the primary no longer matches on a settled page; the step passes and the report marks the row drifted
above Relative: the match is above this selector, in its column
below Relative: below this selector, in its column
leftOf Relative: left of this selector, in its row
rightOf Relative: right of this selector, in its row
childOf Relative: a descendant of this selector
containsChild Relative: has this selector as a direct child
containsDescendants Relative: contains all of these selectors (a list)
commands:
- tapOn:
text: "Delete"
index: 1
of: 3
- assertVisible:
contains: "items"
below:
id: cart-title
- tapOn:
css: "button[data-testid=pay]"
- tapOn:
text: "Place order"
or:
- testId: place-order
- role: button
rightOf: "Total"
- check:
role: switch
leftOf: "Published"
index: 0
- assertVisible:
id: card
containsDescendants:
- text: "Visa"
- contains: "4242"

Both take the same steps commands: takes — runFlow, request, sql, launchApp, openLink, vscodeCommand — and every step-level feature (when, retry, repeat, ${}) works inside them unchanged. onFlowStart: / onFlowComplete: are the older names of these two keys and still work.

before runs before step 1. after runs after the last step always: after a step failed, after a before that broke part-way, after a stop — and every row of a multi-step after runs even when one of them fails. A failure in before fails the flow with its own verdict, setup failed at B2: …, and the numbered steps do not run; a failure in after is reported beside the verdict as teardown failed at A1: … and does not change it. Reports and the desktop run view show the two as Setup and Teardown blocks with rows labelled B1.. / A1..; the numbered steps stay 1..N.

A project’s config.yaml may carry top-level before: / after: that apply to every flow in it, and a VS Code testspace (vscode.testspaces.<name>.before / .after) its own. They wrap the flow’s, outermost first in and last out:

project.before → testspace.before → flow.before → steps
→ flow.after → testspace.after → project.after

before: [] in a flow means “none of my own”, not “none at all”; to drop what is inherited, write inheritHooks: false in the flow header. The resolved chain is what tapstep export playwright writes into the spec’s test.beforeEach / test.afterEach.

A layered hook may call a helper like any other block. Its runFlow: path — and a runScript: source — is resolved against the folder the level that wrote it lives in: the project root for config.yaml, the config’s folder for a testspace. A helper that is not there is caught before anything runs, by validate as much as by test.

before: clean — a string instead of a list — puts a VS Code window back to a known state before the steps run: vscodeCommand × workbench.action.closeAllEditors, workbench.action.closePanel, workbench.action.closeAuxiliaryBar, notifications.clearAll, workbench.action.terminal.killAll. On any other platform clean is a validation error naming what to write instead (web: openLink and clearState; mobile: launchApp with clearState: true).

vscode: { testspace: pd-full }
before: clean
commands:
- vscodeCommand: { title: "My Ext: Generate Config" }
- assertFile: gen/config.yaml
after:
- vscodeCommand: workbench.action.closePanel

vscodeCommand runs a VS Code command outright: by id (a bare workbench.action.closeAllEditors, or { id, args }args need id) through the helper extension the driver bundles, which answers when the command resolves; by { title: … } through the command palette. A Playwright export can only type titles, so an id it has no palette title for is left as a // TODO rather than exported as a command doing something else.

Any string value may contain ${NAME}. A name resolves, in order: the copy register (copiedText), flow variables (env:, params: defaults, values captured by copyText, extractTextWithAI, request.extract, JS output.*), then the process environment; an unknown name becomes an empty string. Values are layered — see config.yaml for the full precedence (params: defaults < flow env: < config env: < environments.<current> < .env < .env.<current> < --env-file < -e). --env-set NAME does not sit in that chain: it picks which environment the two <current> layers are, and the name it picked is ${TAPSTEP_ENV} — a variable of the environment’s own layer, so a -e TAPSTEP_ENV=… still wins over it. regex: values are not substituted.

A JS expression inside an env: value — EMAIL: "qa+${Date.now()}@example.com" — is evaluated once, before the first step, and the result is stored: the value is fresh on every run yet identical in every step that reads it. The same expression written in a command is evaluated per command instead. A runFlow env: resolves the same way when the block starts (once per scope, nesting included), and a failing expression fails the run naming the key.

params: declares a flow’s inputs so callers and the desktop can validate them:

params:
USER:
description: Login to use
required: true
PLAN:
default: free
options: [free, pro]
RETRIES: "3" # shorthand for { default: "3" } — a bare number is refused

A missing required param fails before the first step; options restricts the value. Callers pass params through runFlow env:, -e NAME=value, or the config chain above.

Three commands shape a flow: runFlow (include a helper file or an inline block, optionally guarded by when), repeat (times or while) and retry. Blocks nest freely. Conditions are { visible: <selector> }, { notVisible: <selector> } or { platform: web | android | ios | [list] } (while accepts only the visibility forms).

A helper is a flow file named *.helper.yaml. It has the same shape as a flow (own params:/env:), but only its commands are inlined at the runFlow site; its appId, tags and hooks are ignored. Rules:

  • runFlow: <path> only accepts .helper.yaml files, resolved relative to the calling file; helpers may include helpers, cycles are an error.
  • tapstep test refuses to run a helper directly and directory runs skip them.
  • Required helper params: are checked at load time and come from the caller’s env:, the calling flow’s variables, or the process environment.
  • In reports a helper call is one step with the helper’s steps nested under it.

evalScript, runScript and the JS form of assertTrue share one QuickJS context per run. Available there:

  • every flow variable whose name is a valid identifier, as a global (plus copiedText);
  • output — an object; each key becomes ${output.<key>} for later steps;
  • console.log/info/warn/error — captured into the step’s report;
  • json(text), maestro.copiedText, and a blocking http.get/post/put/delete/request(url, { headers, body }) returning { status, ok, body } (30 s timeout).

device: none runs a flow without resolving or booting any device: only request, sql, scripts, assertTrue and control flow are allowed. See API testing.

A vscode: header turns the flow into a VS Code extension test and selects the VS Code driver:

vscode:
testspace: default # a named entry from config.yaml vscode.testspaces
vsix: [dist/my-ext.vsix] # or a single path; ${} is substituted
workspace: fixtures/ws
settings:
editor.minimap.enabled: false

Explicit fields win over the testspace’s. See config.yaml for vscode.testspaces.

  • A flow file is one YAML document; commands is the only required key.
  • Unknown top-level keys are ignored silently — a typo like platfrom: will not error. params and vscode sub-maps are strict.