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.comtags: [smoke]env: USER: qa@example.combefore: - launchApp: https://staging.example.comcommands: - tapOn: "Log in" - inputText: "${USER}" - assertVisible: "Welcome"after: - takeScreenshot: endSelectors
Section titled “Selectors”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"Hooks: before and after
Section titled “Hooks: before and after”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.
Layering
Section titled “Layering”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.afterbefore: [] 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.
The clean preset
Section titled “The clean preset”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: cleancommands: - vscodeCommand: { title: "My Ext: Generate Config" } - assertFile: gen/config.yamlafter: - vscodeCommand: workbench.action.closePanelvscodeCommand 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.
Variables
Section titled “Variables”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
Section titled “Params”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 refusedA 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.
Control flow
Section titled “Control flow”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).
Helpers
Section titled “Helpers”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.yamlfiles, resolved relative to the calling file; helpers may include helpers, cycles are an error.tapstep testrefuses to run a helper directly and directory runs skip them.- Required helper
params:are checked at load time and come from the caller’senv:, 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.
Scripts
Section titled “Scripts”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 blockinghttp.get/post/put/delete/request(url, { headers, body })returning{ status, ok, body }(30 s timeout).
Pure-API flows
Section titled “Pure-API flows”device: none runs a flow without resolving or booting any device: only
request,
sql, scripts, assertTrue and
control flow are allowed. See API testing.
VS Code extension targets
Section titled “VS Code extension targets”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: falseExplicit fields win over the testspace’s. See config.yaml for
vscode.testspaces.
- A flow file is one YAML document;
commandsis the only required key. - Unknown top-level keys are ignored silently — a typo like
platfrom:will not error.paramsandvscodesub-maps are strict.