Self-test: the release smoke check
--self-test <scenario> plays a scenario inside the engine and exits with its verdict.
--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.
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_OUTif set, else beside$ARGUS_LOG_FILE(an exported game'slogs/folder in its unpack directory), else the platform's log folder (%LOCALAPPDATA%\Argus\logs,~/Library/Logs/Argus,$XDG_STATE_HOME/argus/logsor~/.local/state/argus/logs). Captures are saved beside it asself-test-<name>.png. Besides the steps and checks it carriesframes(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) andscreensat the end of the run:stack(screen names, bottom first),registeredand the active inputcontext, as/ui/screensnames 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.saveneither reads nor writes the player's save.--save-diror$ARGUS_SAVE_DIRnames 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
--headlessthe engine renders offscreen, so the capture checks run; with--headlessthere is no GPU and every capture is skipped (reported as skipped, never faked). An exported game's launcher forwards--self-testand runs the engine offscreen unless--headlessis given. - Serving: none.
--self-testconflicts with--serve,--watchand 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:
- 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;
<project root>/selftest/<name>.json.play,play.jsonandselftest/play.json(either slash) all nameselftest/play.json. A project export, fromengine exportor the editor, packs the project'sselftest/*.jsonatapp/selftest/, so a shipped game runs its own scenarios by name;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.
{
"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-infixtures/selftest/starter-course-start.jsonon the starter course, and each way a scenario has to fail.packages/export/test/native.test.ts—--self-test smokeon 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 indist/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.