Step library
Every shared step, its parameters and what it touches.
Parameter types: {string}, {int}, {method} (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), {role} (any ARIA role). Every {string} and doc string is rendered with {{variables}}.
API steps
| Step | Effect |
|---|---|
I send a {method} request to {string} | request without body |
I send a {method} request to {string} with body: | request with a doc-string body (json media type parsed) |
I send a {method} request to {string} with the raw body: | doc string sent byte-for-byte: not parsed or rendered |
I set the request header {string} to {string} | header for rest of scenario; name is case-insensitive |
I set the query parameter {string} to {string} | query parameter for the rest of the scenario |
I authenticate with bearer token from {string} | bearer token from an environment variable |
I use no authentication | send without any credential, including env.api.auth |
I use an isolated API client | later calls use a context with an empty cookie jar |
the response status should be {int} | status assertion |
the response JSON path {string} should equal {string} | dot/bracket path or $.. JSONPath, value coerced |
the response JSON path {string} should match {string} | regular expression |
the response should match the JSON schema {string} | ajv with formats |
the response should match the OpenAPI schema for {method} {string} | operation response schema from api.openapi |
the response should match the zod schema {string} | default export of a Zod module |
I save the response JSON path {string} as {string} | capture a variable |
I poll {method} {string} until JSON path {string} equals {string} within {int} seconds | polling with 1 s interval |
the response time should be under {int} ms | latency assertion |
Which credentials an API call carries
The api client sends through Playwright's shared request context. That context is seeded from the test's context options, so what it carries depends on the layer:
| Layer | Cookie jar of the api client |
|---|---|
@api | starts empty (no storageState); cookies set by earlier responses in the scenario are sent back |
@ui, @hybrid | starts with the @user:<role> storageState, that is the role's session cookie, plus cookies set by earlier responses |
any, after I use an isolated API client | a separate context with an empty jar, disposed at the end of the scenario; cookies set by its own responses are kept |
The jar comes from the scenario's storageState, not from the live browser page: cookies the page picks up after the scenario starts do not reach the api client. On @ui and @hybrid, a "credential X alone is refused" scenario needs I use an isolated API client, or the session cookie authenticates the request. The isolated client also drops use.httpCredentials and use.extraHTTPHeaders; env.api.headers and env.api.auth still apply. The raw (without following redirects) and event-stream steps honour it too.
The evidence JSON of each recorded request carries auth (none, bearer, basic or header:<name>, never the value) and isolated: true when it went through the isolated client.
JSON paths that match nothing
A path is either dotted (data.items[0].id) or a JSONPath ($.data.items[*].id, $..id). Both read the same way:
| The path matches | Value read |
|---|---|
| nothing | nothing: the path is absent |
| one value | that value, including null or [] |
| two or more values (JSONPath) | an array of the matches |
So on a missing key, the response JSON path {string} should exist fails and the response JSON path {string} should not exist passes, whichever syntax the path uses. I save the response JSON path {string} as {string}, the response JSON path {string} should have {int} items (and at least) and the UI should show the text from JSON path {string} fail with RUN_FAILED instead of storing or comparing an empty value. A key that is present with the value null or [] still saves.
A wildcard over an empty array matches nothing: $.items[*] on { "items": [] } is absent. To count or save the array itself, point at $.items.
Reading the raw response from a project step
The without following redirects steps keep their response (status, lower-cased headers, every Set-Cookie line, body) out of the normal response snapshot. A project step that needs an assertion core does not phrase reads it with rawResponseOf instead of sending its own request:
import { rawResponseOf } from '@sdods/core/steps';
const raw = rawResponseOf(apiContext); // undefined until a raw request was sent in this scenarioUI steps
| Step | Effect |
|---|---|
I navigate to the {string} page | route name from routes |
I click the {string} {role} | heal-aware click by role and name |
I fill the {string} field with {string} | heal-aware fill by label |
I click the element with test id {string} | heal-aware click by test id |
I fill the form: | data table of field/value |
I should see the text {string} | visible text |
I should not see the text {string} | no visible text |
I should see the exact text {string} | visible text, whole-text match |
I should not see the exact text {string} | no visible exact text |
the page should not contain the text {string} | text absent from the DOM |
the {string} {role} should be visible | heal-aware visibility assertion |
the page URL should contain {string} | URL assertion |
the page should match the visual baseline {string} | toHaveScreenshot |
the page should match the visual baseline {string} masking {string} | toHaveScreenshot, comma-separated selectors added to the mask |
I mock {string} with JSON: | route fulfilment |
I upload {string} to the {string} field | file input |
the page should have no accessibility violations of impact {string} or worse | axe-core, from an impact level up |
the recorded {string} should be within its configured budget | recorded vital vs perf.budgets |
Text assertions: visible, exact, or in the DOM
I should see the text and I should not see the text are about what the user can see: the first passes when a visible element contains the text, the second when no visible element does. Text that is in the DOM but hidden (a closed FAQ answer with hidden, a collapsed panel, display: none) counts as not seen. Both match a substring, so "already" also matches "the clients you already serve".
The exact variants match the whole text of an element (whitespace-normalised, case-sensitive) and are the fix for substring collisions. the page should not contain the text asserts that no element carries the text at all, hidden or not; it is the check I should not see the text made in @sdods/core 0.5.0 and earlier. To assert a value is absent from input values, placeholders, titles and alt text as well, use the text {string} should not appear anywhere on the page.
Data steps
| Step | Effect |
|---|---|
I load dataset {string} row {int} | columns become variables |
I load dataset {string} where {string} is {string} | first matching row |
I use a leased user with role {string} | lease + apply storage state |
I use a leased user with role {string} for API calls | lease; attaches its token only when auth.apiToken is implicit |
I authenticate the API with the leased user's token | bearer token of the leased user (cached file, else token()) |
I generate a {string} from the factory as {string} | deterministic faker object |
I register cleanup {method} {string} | request executed at scenario end |
Hybrid steps
| Step | Effect |
|---|---|
I seed via {method} {string} with body: | API request reported as a seed |
the UI should show the text from JSON path {string} | asserts a captured API value in the page |
I replay HAR {string} for {string} | routeFromHAR for a URL glob |
Email steps
Read a mail catcher configured as mail in the environment yaml. Mailpit is the supported provider. Every address, subject, text and pattern argument renders {{variables}}, so {{email}} from a factory works. See the email guide for setup.
| Step | Effect |
|---|---|
I clear the inbox for {string} | delete every message to that exact address |
the inbox for {string} should receive an email with subject containing {string} within {int} seconds | poll until a match arrives; pins it as the latest email |
the latest email to {string} should contain the text {string} | substring of the text part or the HTML part rendered to text |
the latest email to {string} should have been sent from {string} | sender address, display name, or Name <address> (case-insensitive) |
I save the link matching {string} from the latest email to {string} as {string} | first href containing the pattern, or matching it as a regex |
I save the code matching {string} from the latest email to {string} as {string} | regex over the text; saves capture group 1, else the whole match |
I open the link {string} from the latest email to {string} | page.goto the link by visible text, then by href (@ui and @hybrid only) |
Which email is "the latest"
Scenarios must not read each other's mail, so the inbox is scoped per scenario:
- Only messages received after the scenario started, or after the last
I clear the inbox forthat address, count. The start time is the runner's clock and the receive time is Mailpit's, so the window opensmail.clockSkewMs(2000 by default) early. the inbox for … should receive an email with subject containing …pins the message it matched. Later "latest email" steps for that address read that message, even when a newer one arrives after it: a signup that sends "Welcome" and "Verify your email" in either order still yields the verify link.- With nothing pinned, the latest email is the newest message to the address inside the window, waited for up to
mail.timeoutSeconds(15 by default).
Addresses match exactly and case-insensitively: ann@x.test never reads mail for jo.ann@x.test. Links come from <a href> in the HTML part with & decoded, then from http(s) URLs in the text part; mailto: and tel: links are skipped. A wait that times out lists the subjects that did reach the address, so "nothing was sent" and "sent with a different subject" read differently.
Canvas steps
Graph and node editors: workflow builders, pipelines, diagrams. Nodes, handles, edges, fields and badges are found through the project's canvas selectors, which default to React Flow's DOM. Handles, fields and badges are looked up inside their node, so another node cannot satisfy the step. Exclude the library with steps.core.exclude: [canvas].
| Step | Effect |
|---|---|
I drag the element with test id {string} onto the element with test id {string} | pointer drag onto the target's centre; also drops draggable palettes |
I drag the element with test id {string} by {int} and {int} | pointer drag by screen pixels, right/down positive |
I drag the node {string} by {int} and {int} | same, for a node addressed by id |
I connect the {string} handle of node {string} to the {string} handle of node {string} | pointer drag from the source handle onto the target handle |
I select the node {string} | clicks the node and waits until it matches canvas.selected |
I set the {string} field of node {string} to {string} | fills (or selects an option in) a field inside the node |
the canvas should contain {int} node(s) | exact node count |
the canvas should contain an edge from {string} to {string} | an edge in that direction exists |
the canvas should not contain an edge from {string} to {string} | no such edge; fails on a canvas with no nodes rather than passing |
the node {string} should be visible | node rendered and shown |
the node {string} should show the {string} badge | badge visible inside that node |
Drags are real pointer gestures: press, a short nudge, a dozen intermediate moves, a last move onto the target, release. Editors read the moves, not only the press and release: React Flow ignores a press that never crosses its drag threshold and picks the connection target from the handle the last move was over, so locator.dragTo() can report success while the canvas does not change. The press lands on a plain part of the element, not on a field, button or handle inside it, because editors ignore presses there (React Flow's nodrag). A drag that would start or end outside the viewport fails; scroll or zoom the canvas first. On a zoomed canvas a node moves by the offset divided by the zoom.
None of these steps self-heal. A node id is the node's identity, so healing onto a similar node would move, connect or check the wrong block and report success.
Locale, theme, timezone and viewport steps
The per-scenario emulation axis. The tags @locale:<bcp47>, @timezone:<IANA>, @theme:<light|dark|no-preference>, @viewport:<W>x<H> and @device:<name> set the browser context before it opens; see Tags and suites.
| Step | Effect |
|---|---|
I use the locale {string} | passes when the context locale is that locale; otherwise fails naming @locale: |
I use the timezone {string} | passes when the context time zone is that zone; otherwise fails naming @timezone: |
I use the device {string} | passes when @device: emulates that device; otherwise fails naming the tag |
I use the {string} color scheme | light, dark or no-preference, applied to the open page |
I use the viewport {int} by {int} | resizes the open page |
the html lang attribute should be {string} | <html lang> equals the value |
the page should reflow without horizontal scrolling | the document is no wider than the viewport; fails on an empty body |
the page should have no untranslated keys | no visible untranslated key (defaults below); fails when the page shows no text |
the page should have no untranslated keys matching {string} | no visible text node matches the regular expression, which replaces the defaults |
Locale, time zone and device cannot change on an open browser context, and the context is open before the first step runs. The steps for them therefore never switch anything: they state the scenario's intent and fail when the tag (or the environment's use: block) did not set the value.
By default, a visible text node counts as an untranslated key when it is:
- a whole text node shaped like a catalogue key, such as
nav.settingsorcheckout.summary.total(file names, host names and version numbers are excluded); - an unrendered placeholder, such as
Hello {{name}}; - a library's missing-translation marker:
[missing "fr.nav.home" translation](i18n-js) ortranslation missing: fr.nav.home(Rails).
If your keys look different, pass a pattern, for example the page should have no untranslated keys matching "^[A-Z0-9_]{3,}$" for SCREAMING_CASE keys.