# Self-test: the release smoke check

`--self-test <scenario>` plays a scenario inside the engine and exits with its verdict. It is how a
release build is smoke-tested on every platform (docs/PLAN-PLATFORMS.md §8): a release engine serves
no query API (G2), and on Android, iOS and the web there is nothing to connect to anyway, so the
check runs in-process and needs no server.

```text
argus run <project | scene.json> --self-test <scenario> [--headless]
<exported game> --self-test <scenario> [--headless]
```

- **Exit code:** 0 when every step and every check passed, 1 when anything failed, 2 when the
  scenario could not be found or read.
- **Report:** `self-test.json`, written to `$ARGUS_SELF_TEST_OUT` if set, else beside
  `$ARGUS_LOG_FILE` (an exported game's `logs/` folder in its unpack directory), else the platform's
  log folder (`%LOCALAPPDATA%\Argus\logs`, `~/Library/Logs/Argus`, `$XDG_STATE_HOME/argus/logs` or
  `~/.local/state/argus/logs`). Captures are saved beside it as `self-test-<name>.png`. Besides the
  steps and checks it carries `frames` (ticks run), `digest` (the simulation's state at the end, `WorldState::digest`: every entity, its transform, script state, HUD and goals, so two engines given the same scenario can be compared) and `screens` at the end of the run: `stack`
  (screen names, bottom first), `registered` and the active input `context`, as `/ui/screens` names
  them. A release engine has no query API, so this is how a caller sees which menu is up.
- **Saves:** a run plays from no save, as a first launch does, in a folder of its own made in the
  system's temporary folder, so `ctx.save` neither reads nor writes the player's save. `--save-dir`
  or `$ARGUS_SAVE_DIR` names another folder, for a test that inspects what the game saved.
- **Stdout:** one line, `argus-self-test {"ok":…,"scenario":…,"frames":…,"report":"<path>"}`.
- **Surface:** never a window. Without `--headless` the engine renders offscreen, so the capture
  checks run; with `--headless` there is no GPU and every capture is skipped (reported as skipped,
  never faked). An exported game's launcher forwards `--self-test` and runs the engine offscreen
  unless `--headless` is given.
- **Serving:** none. `--self-test` conflicts with `--serve`, `--watch` and the session flags.
- **Time:** fixed 60 Hz ticks. Seconds and milliseconds in a scenario become ticks, so a scenario
  is deterministic in the critic build and never depends on how fast the machine is.

## Finding the scenario

In this order:

1. a file path, absolute or relative to the working directory. An exported game's launcher runs the
   engine in its unpack directory, so it makes a relative path to a file it can see absolute against
   the folder the game was started from before handing it over;
2. `<project root>/selftest/<name>.json`. `play`, `play.json` and `selftest/play.json` (either slash)
   all name `selftest/play.json`. A project export, from `engine export` or the editor, packs the
   project's `selftest/*.json` at `app/selftest/`, so a shipped game runs its own scenarios by name;
3. `smoke`, the built-in scenario: 180 ticks, no script errors, and a frame that is not blank.

## The format

One format for scripted play: the bench's step language (`bench/README.md`, "Scripts";
`bench/lib/exportrun.mjs`) with the same spellings, run against the world directly instead of over
HTTP, plus an `expect` block.

```json
{
  "selfTest": 1,
  "description": "What this scenario proves.",
  "maxFrames": 1800,
  "steps": [
    { "wait": "screen", "name": "main-menu", "top": true },
    { "press": "Start" },
    { "wait": "screen-gone", "name": "main-menu" },
    { "axis": "move", "value": [0, 1], "frames": 60 },
    { "capture": "playing" }
  ],
  "expect": { "goals": 1, "screen": "hud", "top": false, "scriptErrors": 0, "capture": "auto" }
}
```

`selfTest` is required, so a stray JSON file is never run as a scenario. Unknown keys are refused.
`maxFrames` (default 3600) is the tick budget for the whole run; running out fails it.

### Steps

| step | does | differs from the bench |
|---|---|---|
| `{ "frames": 30 }` | run 30 ticks | self-test only |
| `{ "sleep": 500 }` | run 500 ms of ticks (30) | ticks, not wall-clock |
| `{ "wait": "screen", "name": "hud", "top": true }` | until the screen is on the stack (on top, with `top`) | |
| `{ "wait": "screen-gone", "name": "main-menu" }` | until it is not | |
| `{ "wait": "text", "match": "score" }` | until a visible node's text contains it (case-blind) | a plain substring: the engine links no regex engine, and a pattern with regex characters is refused |
| `{ "press": "Start" }` | activate the nearest focusable, enabled ancestor of the visible text `Start` (whole, case-blind), through the same code `POST /ui/activate` runs; `{ "press": { "id": "main-menu:start" } }` names the node | waits `settleMs` (default 1000) of ticks after start first, as the bench does; a label starting `^` is not a regex here |
| `{ "action": "jump", "frames": 6, "repeat": 3, "gapFrames": 18 }` | hold the action, through the same code `POST /input` places holds with; fails if nothing consumes it within `bindTimeout` seconds (default 3). `"player": 2` (a whole number, 1 to 4) drives that local player's entity, as `POST /input`'s `player` does, once the game has seated them (waited for within `bindTimeout`; a number past the scene's `meta.players` `max` fails at once); player 1's when absent | `gapMs` also accepted |
| `{ "axis": "move", "value": [0, 1], "frames": 30 }` | the same, for an axis action: `value` is `[x, y]` for every axis, and a one-axis action (`axis1`, such as a vehicle's `throttle` or `steer`) reads `x`, so `{ "axis": "steer", "value": [1, 0] }` steers right; a bare number is read as `[value, 0]`; `player` as for `action` | |
| `{ "drive": "road", "road": "track", "frames": 600, "minDistanceM": 300 }` | drive the player along a road for `frames` ticks: each tick it holds `throttle` (default 1, eased off the harder it steers) and a `steer` toward the point `lookAheadM` (8) ahead on the road, the way the player faces, on the actions `throttleAction` / `steerAction` (`throttle`, `steer`); a throttle the scene binds as a button is pressed on that share of ticks instead (0.5: every other tick); `road` may be left out when the scene has one, and may be a list of roads driven as one route, each joined to the one before whichever way it was drawn (`"road": ["a", "b"]`). Answers `distanceM` (along the road) and `maxOffRoadM` (furthest past the road's edge: flat out, a kart runs a metre or two wide in a tight bend, and a lower `throttle` holds a tighter line); fails short of `minDistanceM` (0), or when nothing consumes the actions within `bindTimeout` | self-test only; always player 1's |
| `{ "touch": [0.2, 0.8], "to": [0.2, 0.5], "frames": 60 }` | put a finger down at a normalised screen position, walk it to `to` (default: where it landed) and lift it after `frames` ticks, through the same holds; fails if no action reads touch within `bindTimeout` (docs/INPUT.md, "Touch") | self-test only; always player 1's |
| `{ "capture": "menu" }` | render, save `self-test-menu.png`, fail if the frame is blank | skipped under `--headless` |
| `{ "expect": "goals", "min": 2 }` | until that many distinct goals have been reached (`timeout` seconds) | self-test only |
| `{ "repeatUntil": { "screen": "game-over" }, "max": 80, "do": [ … ] }` | the inner steps again and again until the screen is on the stack (`screenGone`: until it is not), checked before each round; fails after `max` rounds (default 30) or when an inner step fails | no `exited` condition |

Steps run one after another, and an `action` or `axis` step holds its one action for its `frames` before the next step starts: one step cannot hold two actions, so a steer step after a throttle step steers with the throttle released (a vehicle coasts through it).

Every waiting step takes `timeout` in seconds (default 15). A failed step stops the run: every later
step assumed the ones before. The bench steps that only make sense from outside the process —
`launch`, `restart`, `checkUi`, `track`, `read`, and the `expect` kinds `var`,
`moved`, `saved` and `exit` — are refused by name rather than skipped.

### XR steps (`--xr-sim`)

With the desktop XR simulator (`argus run <project> --xr-sim[=profile] --self-test <scenario>`,
docs/status/xr-sim.md) a scenario places and works the head and both hands, in fixed ticks, so a VR game is checked
with no headset. Every pose is in the XR origin's tracking space, or, with `relativeTo`, in that entity's own frame,
worked out again every tick: a hand put on a moving vehicle's control stays on it.

| step | does |
|---|---|
| `{ "xrHead": …, "position": [x, y, z], "yaw"?, "pitch"?, "roll"?, "relativeTo"?, "seconds"? }` | moves the head there (degrees; or `"rotation": [x, y, z, w]`), over `seconds` |
| `{ "xrHand": "left"\|"right", "position": [x, y, z], "rotation"?, "relativeTo"?, "seconds"?, "hold"? }` | moves a hand there over `seconds`; with `relativeTo` it then stays on that entity (`hold`, default `true`) until another `xrHand` step moves it |
| `{ "xrInput": "left"\|"right", "trigger"?, "squeeze"?, "thumbstick"?: [x, y], "primary"?, "secondary"?, "thumbstickClick"?, "menu"?, "seconds"? }` | sets the controller's values (0 to 1, -1 to 1 for the stick), then runs `seconds` |
| `{ "xrHandPose": "left"\|"right", "tracked"?: true, "curl"?: 0 }` | hand tracking on or off; `curl` closes the fingers, 0 open to 1 a fist |
| `{ "xrTracking": "left"\|"right", "lost": true }` | the controller (and the tracked hand) loses tracking, or finds it again with `false` |
| `{ "xrRecenter": true }` | the head and hands back at rest on the origin |

An XR step in a run without the simulator fails, naming `--xr-sim`. The same scenario runs unchanged under every
headset profile (`--xr-sim=focus3`, `pico4`, `quest3`, `index`, or a headset file): the poses are in the scene's
frame, not the headset's.

### Checks (`expect`)

| key | passes when | default |
|---|---|---|
| `goals` | at least this many distinct goals were reached (`goal-reached` events), or every goal the scene declares with `"all"` | not checked |
| `screen`, `top` | the screen is on the stack at the end (on top, with `top`) | not checked |
| `scriptErrors` | the run recorded at most this many script errors, load-time errors included | 0 |
| `capture` | the final frame is not blank: `true` requires it, `false` skips it, `"auto"` checks it when the run renders | `"auto"` |
| `xrEyes` | with `true`, both XR eyes of the last frame are not blank (`--xr-sim`) | not checked |

"Not blank" means at least 0.5% of pixels differ from the frame's first pixel by more than 8 in some
channel: a clear-colour frame, a black frame or a failed draw fails it.

## Where it runs

- `crates/app/tests/self_test.rs` — the checked-in `fixtures/selftest/starter-course-start.json` on
  the starter course, and each way a scenario has to fail.
- `packages/export/test/native.test.ts` — `--self-test smoke` on a release export.
- `scripts/release/matrix.sh` — every platform the machine can build: the same scene export headless
  on Linux and Windows, and a project export offscreen on Linux, recorded in
  `dist/release/<version>/MATRIX.json`.

**Known gap (not the self-test's):** a scene-only export carries no font, and a scene that declares
text refuses to load without one, so such an export can only run `--headless`. A project export packs
a font under `app/ui/fonts/` and renders. The matrix's capture check therefore runs on a project.

