yardsort

Smoke tests

A smoke test is a short, structured list of steps that checks a piece of software from the outside: start the server, wait for it, hit an endpoint, open a page, look for a word. It is the ten-minute check you do before saying "done", written down so an agent can write it and Yardsort can run it. A test is a YAML file in your repository, .yardsort/tests/<id>.yaml, so it belongs to the code it checks: an agent writes one for the feature it built, in the worktree it is standing in, and the test travels with the branch.

This release reads, checks and runs the files, and shows the runs. ys test validate names every mistake at its line and column, ys test list shows a workspace's tests, ys test guide tells an agent how to write one, and ys test run queues a run that Yardsort carries out, step by step, with the verdict from ys test wait. A file from a repository never runs unseen: a run of a new or changed file waits until you approve it, on a card at the top of its workspace. The run opens as a tab beside the workspace's terminals, with every step, its output as it comes, and Pass / Fail for the steps a person does; Run smoke test… in a workspace's menu starts one from the app, and Tests in the sidebar lists every project's tests and runs in one place. See Running a test and In the app.

Where tests live

Every .yaml or .yml file directly in a worktree's .yardsort/tests folder is a test. Each workspace has its own copy, since each worktree is its own checkout: a test an agent wrote on a feature branch exists in that workspace until the branch is merged. ys test list reads the folder of the workspace it is run in, or of --workspace <name>.

A file that does not check out is still listed, under the id it says or its file name, with its problems. Two files with one id: the first by file name is used, and the second is listed with the clash.

A test says what to run on your machine, and it comes from a repository. That is why nothing here runs unseen: Yardsort shows a new or changed file's steps and asks you first, once per version of the file. See The approval card.

An example

ys test example prints this file:

id: health
name: The server starts and answers
description: Starts the dev server on a free port, waits for it, and checks its health endpoint.
version: 1

inputs:
  - id: greeting
    kind: text
    label: What the home page should say
    default: Welcome

setup:
  - id: server
    action: start
    program: npm
    args: [run, dev, --, --port, "{{ run.port }}"]
  - id: ready
    action: wait_for
    url: "http://localhost:{{ run.port }}/health"
    timeout: 60s

steps:
  - id: health
    action: http
    get: "http://localhost:{{ run.port }}/health"
    expect:
      status: 200
      json: { status: ok }

  - id: version
    action: run
    program: node
    args: [--version]
    expect:
      stdout_matches: '^v\d+\.'

  - id: home
    action: browser
    do:
      - goto: "http://localhost:{{ run.port }}/"
      - expect_text: "{{ inputs.greeting }}"
      - screenshot: home

  - id: looks_right
    action: manual
    prompt: Does the home page load without a flash of unstyled content?

setup runs first; a failure there fails the run before any step. steps run in order and stop at the first failure. teardown, when there is one, always runs, however the run ended, and a start process is stopped at the end whatever happens. The three lists share one set of step ids.

{{ run.port }} is a port that is free when the run starts. Several workspaces may run the same test at once, and they would otherwise fight over the same port. Start the server on the run's port and point every later step at it.

The file

KeyWhat it is
idLowercase letters, digits and -, like login. Name the file after it.
nameWhat the test is called in lists.
descriptionOptional. One or two sentences.
version1. A file with a later version is listed but not run, and says to update Yardsort.
inputsOptional. What you are asked before a run starts.
timeoutOptional. For the whole run; 30m when left out. A whole number and a unit: 30s, 45m, 2h. At most a week.
setupOptional. Steps run before the test itself.
stepsAt least one.
teardownOptional. Steps always run at the end.

Keys a test does not have are mistakes, not ignored. A misspelt progam: is reported, not silently skipped.

Inputs

KeyWhat it is
idStarts with a lowercase letter; then lowercase letters, digits, _ and -. Used as {{ inputs.<id> }}.
kindtext or choice. There is no harness input, as a workflow has: a test starts no agent.
labelOptional. The question. The id when left out.
requiredOptional. false when left out. An optional input left empty is empty text wherever it is used.
defaultOptional. For a choice, one of its options.
optionsFor a choice only: the answers to pick from.

Steps

Every step has an id, named the same way as an input, and an action. Each may have a timeout (5m when left out), except start, which runs to the end of the test, and manual, which waits for a person.

ActionKeysWhat it does
runprogram (required), args, cwd, env, stdin, expectRuns a program with its arguments, each argument its own item of the list, and waits for it to exit. Passes when every expectation holds; with none given, when it exits 0.
shellline (required), cwd, env, expectRuns one line in your shell, for pipes: the login shell on Unix, PowerShell on Windows. Passes as run does.
startprogram (required), args, cwd, envStarts a program and leaves it running, a dev server for example, until the end of the test, when it is stopped.
wait_forOne of port, url, file, or program with args; interval (1s when left out)Waits until a port on localhost accepts a connection, an address answers 2xx, a file exists, or a program exits 0, asking again every interval. args goes with program only. Fails when the timeout runs out first.
httpOne of get, post, put, delete, patch with the address; headers, body or json, expectMakes one request. json is sent as JSON with the matching content type. Passes when every expectation holds; with none given, on any 2xx.
browserdo (required), screenshots (on_failure when left out, every_step, never), viewport (like 1280x800), headedOpens a page in a Chromium found on your machine, headless unless headed: true, and does each action in do in turn. A failing action takes a screenshot; every_step takes one after each.
manualprompt (required)Shows prompt to a person and waits for Pass or Fail, with an optional note. The half of a smoke test a machine cannot judge stays in the file.

A key that belongs to another action is reported as misplaced. prompt on a run step is an example.

cwd is a folder inside the workspace, relative to it, like web or packages/api. No .., no absolute path, no variables, and no symbolic link that leads outside the workspace: the step fails and says where the link went.

env is a mapping of names to values. A name is letters, digits and _, not starting with a digit. Values may use variables.

A shell line cannot hold {{ … }}. A value pasted into a shell line could become a command, so the one place where that could happen is the one place nothing is pasted. The run's variables are in the environment instead, and every run and start process gets them too:

VariableEnvironment
{{ run.id }}YS_RUN_ID
{{ run.port }}YS_RUN_PORT
{{ run.dir }}YS_RUN_DIR
{{ workspace.path }}YS_WORKSPACE_PATH
{{ inputs.<id> }}YS_INPUT_<ID>, upper case
{{ env.<NAME> }}YS_ENV_<NAME>

So line: curl -s "http://localhost:$YS_RUN_PORT/health" | jq .status is right, and line: curl localhost:{{ run.port }} is reported. Prefer run where there is no pipe: it works the same on every platform.

Expectations

Under expect, for run and shell:

KeyWhat it checks
exitThe exit code: a number, or nonzero. 0 when left out.
stdout_containsThe output has this text.
stdout_matchesThe output matches this regular expression. One that does not compile is reported.
stderr_emptytrue: nothing was written to standard error.
stdout_json{ path: status, equals: ok }: the output is JSON and has this value at this path. equals is compared as JSON: ok is the string, 1 the number, true the boolean, and "1" a string.

For http:

KeyWhat it checks
statusThe status: a number from 100 to 599, or a class like 2xx. 2xx when left out.
jsonA mapping. The response is JSON and has every key of it, with the same value.
body_containsThe body has this text.
body_matchesThe body matches this regular expression.
header{ name: content-type, contains: json }: the response has this header, containing this text.

Every expectation given is checked, and the step's note lists each one that failed, as expected status 200, got 502. An expectation of the wrong kind, status on a run step, say, is reported.

Browser actions

Each item in do is one key and its value. Selectors are CSS, or text=… for an element by its text (which skips script and style, and prefers one you can see). Every action waits for its selector to exist and be visible before acting, up to the step's timeout, so there is no sleep and none is needed.

goto waits for the page to finish loading and fails if it never does, but an HTTP error status (a 404 or a 500) is still a page it went to — check what the page shows with expect_text or expect_title. fill sets the value the way a real keypress would, so an input a framework controls (React and the like) sees the change. press sends one key to the focused element: a single character types itself, or one of Enter, Tab, Escape, Backspace, Delete, ArrowUp/ArrowDown/ArrowLeft/ArrowRight, Home, End, PageUp, PageDown, Space — any other multi-letter name is reported when the file is checked.

ActionValue
gotoAn address
clickA selector
fill{ selector, text }; an empty text clears it
pressA key, to the focused element: a character, or a name like Enter or Tab
select{ selector, value }
wait_forA selector, or { text: … }
expect_textText that must be on the page
expect_visibleA selector
expect_hiddenA selector
expect_urlA pattern over the address, like **/dashboard
expect_titleThe page title
screenshotA name for the file

Variables

Any text in a step can use {{ name }}, except a shell line. Only these names exist. A name is a dotted path and nothing else: no filters, no expressions. Anything else is reported where it is written.

VariableWhat it is
project.name, project.rootThe project, and the folder of its own checkout.
workspace.name, workspace.branch, workspace.pathThe workspace the run is about.
run.id, run.port, run.dirThis run: its id, one free port, picked when the run starts, and a scratch folder of its own, removed with the run's output.
inputs.<id>What was given for that input.
env.<NAME>A value from the project's test environment, for passwords and anything machine-specific. Never in the file; you set them in Yardsort. NAME is letters, digits and _.
steps.<id>.<field>What an earlier step left: exit, stdout and stderr from run and shell; pid, stdout and stderr from start; waited from wait_for; status, body and headers from http; url and title from browser; note from manual. Only steps earlier in the file.

To write {{ literally, write \{{.

Checking a file

ys test validate <file> checks a file without needing Yardsort to have run on this machine. It prints each problem as file:line:column: message and exits 1 if there are any:

$ ys test validate .yardsort/tests/login.yaml
.yardsort/tests/login.yaml:20:36: `{{ workspace.brnch }}`: `workspace` has no `brnch`. It has `name`, `branch`, `path`.
.yardsort/tests/login.yaml:31:11: A `shell` line cannot hold `{{ … }}`: a value pasted into a shell line could become a command. …

- reads the file from standard input. --json prints { valid, id, problems }.

ys test list shows the tests of the workspace you are in, or of --workspace <name>, whether each is ready, and where the folder is. ys test show <id> prints the file that would be used, with any problems after it. ys test example prints the example above, and ys test guide prints the format written for an agent: every action, key and variable, with whole files as examples. See the ys command line.

Running a test

ys test run <id> queues a run of a test in the workspace you are in, or in --workspace <name>, with --input id=value for each of the test's inputs that has no default. Yardsort must be open: the app carries runs out, and ys says so and queues nothing when it is not. The command checks the file, every input and every {{ env.NAME }} the test uses before anything is queued. A test with a browser step needs a browser on the machine; if none is found it is refused, saying whether the project's named browser was not there or none was found at all, so name one under the project's smoke test settings. It prints the run's id and returns at once.

Approval. A test says what to run on your machine, and it comes from a repository. So a run of a file you have not seen — new, or changed in any way since you last approved it — is queued as awaiting approval, and nothing runs until you approve the file in Yardsort, on the card at the top of the workspace. The approval is recorded for that exact file, by its content hash, so the next run of the same file goes straight ahead; a changed file waits again, and the card shows what changed. A project can ask to see every run instead: Ask every time in its settings. ys has no way to approve, for the reason ys memory has no way to approve: an agent can run anything ys offers.

What a run does. It gets a free port of its own, {{ run.port }}, and a scratch folder, {{ run.dir }}, removed afterwards. setup runs first, then steps, stopping at the first step that fails; teardown runs whatever happened. Every run, shell and start process gets the run's variables in its environment (the table above) and the project's test environment as YS_ENV_<NAME>. A start process is kept running until the run ends, when it is stopped with everything it started; if it exits before that, the run fails there and the step's note has its exit code and last lines. A manual step waits for your Pass or Fail in the app. Each step has five minutes unless its timeout says, the whole run thirty unless the file's timeout says; a process that writes more than 16 MiB to stdout or stderr is stopped and fails, a start process too, whatever step the run is on by then. Each process's output goes to files under Yardsort's data directory, test-runs/<run id>/<step>.stdout and .stderr, kept for thirty days.

Cancelling. ys test cancel <run> ends a run that is waiting or queued at once. A running run is asked to stop: the step it is on is ended, with its process and everything that process started, or the request or question it was waiting on, the steps not reached are marked cancelled, the teardown runs, and the run ends as cancelled. If Yardsort stops during a run, the next start ends the run as errored and stops any server it left behind. A run Yardsort could not see through for a reason of its own, its database locked for too long for one, ends as errored with that reason, its servers stopped.

The verdict. ys test wait <run> blocks until the run ends and prints it the way an agent can act on it:

$ ys test wait 8c1d
failed  login  (3 of 6 steps passed, 1m 12s)
  sign_in  http  failed after 31s
    status: expected 200, got 500.
    last lines of `server` (setup step):
      [vite] 500 /api/session: TypeError: cannot read 'id' of undefined

The first line is the status, the test and how many of the setup and test steps passed. Then every step that did not pass, with its note: each expectation that failed, in words. Then, for a run that failed, the last lines of each server the test started. wait exits 0 when the run passed, 1 when it failed and 2 for anything else: cancelled, refused, errored, or still going when --timeout (20 minutes unless said) passed. ys test runs lists runs, newest first, and --run <id> shows one step by step; ys test log <run> prints a step's output, the first failed step's unless --step says, --stderr for the other stream. A run can be named by the start of its id.

In the app

The Tests view

Tests, in the sidebar under Workflows, opens every project's smoke tests in one place. Its number is the runs waiting for your approval, across every workspace; it reaches zero when nothing is waiting. The view is read from the repositories when you open it and on Refresh, the way Tasks reads the forge.

It has two tabs. Tests lists every test across every workspace, with the verdict of its last run — passed, failed, never run, waiting for approval — when it ran, and in which workspace. Runs lists every run, newest first. The header filters narrow both: by project, by workspace, and — on Tests — the last verdict and whether a test needs attention (a browser the machine lacks, or an environment value the project has not set); on Runs, who asked (you, an agent, a workflow). A search box narrows by name. The filters are remembered between sessions. Filtering to a workspace reaches back through that workspace's own history, so an older workspace's runs stay findable however many newer ones other workspaces have since produced.

A test opens beside the list, to its file (read-only — it is the repository's, so edit it in the worktree), the problems or missing pieces it has, its run history (that test, in that project, across its workspaces), and Run in… a workspace of its project. A run, from either tab, opens its run tab in its workspace. A row with a problem, a missing browser or an unset environment value is flagged where it sits, so what cannot run yet is plain before you try it.

Describe it

Rather than write a test by hand, press Describe it… in the Tests view header, say what it should check, and a model writes the file: the agent you already have, in its non-interactive mode, or your Anthropic key — the same writers that draft a workflow, chosen in Settings → Assist. What comes back is editable, with any problems marked, and is nothing until you pick a workspace and Save it into that worktree's .yardsort/tests, where it is then an ordinary uncommitted change in the Changes panel. You can save into any live workspace of any project, including one that has no smoke tests yet. Saving only ever creates: if the id is already taken — by this test's own file or another's — the save is refused rather than quietly replacing it, so an agent's uncommitted edits are never lost; give the new test an id of its own, or edit the existing file in the worktree. The usual way a test gets written, though, is the agent in the workspace writing it itself, because it knows the feature — press Describe it when you want one and no agent is on it.

The approval card

A run waiting for your approval shows at the top of its workspace, above the terminal tabs, and the workspace's row in the sidebar carries a count of the runs waiting there. The card is the whole of what the file does, in words: who asked for the run and with what inputs, then every step, phase by phase — run bun run seed --user demo@example.test; expect exit 0, GET http://localhost:{{ run.port }}/health; expect status 200, ask you: Does the dashboard chart animate in? — with every {{ variable }} left as written, since a port is picked when the run starts and a secret is never printed. A script a step feeds to a program and a request body are shown whole, so nothing a step carries out is hidden behind a summary. Under the steps, what it reads from the project's test environment: a step that runs a program is handed all of it (as YS_ENV_*), so when the file has such a step the card names the whole environment, not only the values the file mentions, and flags any it mentions that are not set. A file that was approved before and has changed since shows what changed, line by line.

Run approves the file for the project and starts the run; its tab opens. Don't run ends the run as refused, which ys test wait reports to the agent in words; nothing is recorded against the file, so the same file asked for again waits again. While the card has the keyboard, Mod+Enter runs and Esc refuses, as in the composer; it never takes the keyboard from the terminal behind it, so an Esc meant for the agent refuses nothing. Several runs waiting in one workspace are shown one at a time, oldest first.

The run tab

A run opens as a tab in its workspace's tab strip, beside the terminals, named after the test with a dot for how it is going. It opens when you approve a run or start one from the app; Run smoke test… lists the workspace's recent runs to open any of them again. The terminal behind it keeps running; Mod+W closes the tab, and closing it loses nothing, since the tab is a view of what the run recorded.

Down the left are the steps in their three phases, each with its status and how long it took. The one in view is the step going, else the first that failed, else the last that ended; click any to see it. On the right: the step in words, its note — every expectation that failed, in words, or why it was skipped — the facts it left (exit, status, pid, waited), the response body of an http step, and the stdout and stderr of a process, tailed as it writes while the step runs. A manual step shows its question with Pass, Fail and a note, which the step keeps. The header has Cancel while the run goes — the step it is on is ended, the teardown runs, and the run ends as cancelled — and, once it has ended, Run again, which opens the Run dialog with the same inputs, and Send result to agent.

Run smoke test…

In a workspace's ⋯ menu. The dialog lists the worktree's tests with their readiness — ready, the number of problems, or that a browser test needs a browser none was found for — asks the chosen test's inputs, starting from their defaults, and shows its steps as the card does, with the environment it reads and what changed since it was last approved. Pressing Run is the approval — of the file the dialog showed: the file is approved for the project, so no card follows, and the next run of the same file goes straight ahead. If an agent has changed the file since the dialog opened, the run waits on the approval card instead, showing the new version, so what runs is always a version you have seen. A workspace with no tests is told where they go and that ys test guide tells an agent how to write one.

Send result to agent

On an ended run's tab. It types what ys test wait would have printed — the verdict, the step that failed and why, the last lines of any server the test started — into the workspace's agent, with a line first saying what it is, the way a workflow's send_to_session does: at once if the agent is quiet, otherwise as soon as it is, and never while someone else holds the terminal. With one agent running in the workspace it goes there; with several, a menu asks which. A run started by an agent with ys test run needs none of this: the agent reads the same words from ys test wait.

Project settings

Project settings… in a project's menu, or Project settings above a workspace's terminal tabs, has a Smoke tests part:

  • The test environment — names and values a test reads as {{ env.NAME }} and every run, shell and start process gets as YS_ENV_<NAME>. Kept in Yardsort's own database, never in the repository; the values are hidden until Show values. A test that uses a name not set here is refused by ys test run and the Run dialog, and the card says which names a waiting run would find empty.
  • Ask every time — show every run's steps and ask, even for a file approved before. Off by default: a file you have approved runs straight away until it changes.
  • Keep runs for — how many days ended runs are kept, with their output files, thirty unless changed. Runs waiting, queued or going are never removed.
  • Browser — the browser a browser step drives. Left empty, Yardsort finds one: Chrome, then Chromium, then Edge, on your PATH and in the usual install folders. Set the full path to a Chromium-family browser to use that one, and only that one — a path that is not there is needs a browser, never a quiet fall back to a different browser. Nothing is installed and no Node is needed; a browser test whose browser is not found is refused, saying whether it was the path you named or no browser at all.

Agents

The usual way a test gets written is by the agent in the workspace, because it knows the feature. Ask it to write a smoke test for what it built: ys test guide is the reference it reads, ys test validate is how it checks the result, and ys test run then ys test wait is how it runs the test and reads the verdict, once you have approved the file. Every example in the guide is itself checked against the parser when Yardsort is built, so what an agent is told and what the validator accepts cannot drift.

An agent is told about this the way it learns about ys memory and ys task: in its first message, when the project has a .yardsort/tests folder or a test environment set. It gets a short paragraph pointing it at ys test list, ys test run and ys test guide, so you need not explain the commands each time.

A workflow can run a test too, with its run_test step, which queues a run and waits for its verdict — passing or failing with it, so a later step can act on either. The built-in Smoke test, then tell the agent is exactly that followed by a message to the workspace's agent with the result, whether it passed or failed — the shape of "when the agent is done, smoke test it and tell it what broke."