SDODS
Reference

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

StepEffect
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 authenticationsend without any credential, including env.api.auth
I use an isolated API clientlater 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} secondspolling with 1 s interval
the response time should be under {int} mslatency 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:

LayerCookie jar of the api client
@apistarts empty (no storageState); cookies set by earlier responses in the scenario are sent back
@ui, @hybridstarts 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 clienta 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 matchesValue read
nothingnothing: the path is absent
one valuethat 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 scenario

UI steps

StepEffect
I navigate to the {string} pageroute 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 visibleheal-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} fieldfile input
the page should have no accessibility violations of impact {string} or worseaxe-core, from an impact level up
the recorded {string} should be within its configured budgetrecorded 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

StepEffect
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 callslease; attaches its token only when auth.apiToken is implicit
I authenticate the API with the leased user's tokenbearer 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

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

StepEffect
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} secondspoll 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 for that address, count. The start time is the runner's clock and the receive time is Mailpit's, so the window opens mail.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 &amp; 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].

StepEffect
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 visiblenode rendered and shown
the node {string} should show the {string} badgebadge 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.

StepEffect
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 schemelight, 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 scrollingthe document is no wider than the viewport; fails on an empty body
the page should have no untranslated keysno 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.settings or checkout.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) or translation 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.

Using an AI coding agent? Install SDODS:

Install the SDODS plugin in Claude Code: skills, subagents, commands and the MCP server.

  1. /plugin marketplace add YarlisAISolutions/sdods-skills
  2. /plugin install sdods@sdods
Learn more about using SDODS with AI tools

On this page