Variables & secrets
For the complete documentation index, see llms.txtEnvironments, tokens and credentials live in variables — not hardcoded in scenarios.
Two files
unotest/.env— ordinary variables.unotest/.secrets— secrets, git-ignored.
Reference them by bare UPPER_SNAKE identifiers in scenarios:
goto(APP_BASE_URL + "/login");fill(getByLabel("Email"), TEST_USER_EMAIL);fill(getByLabel("Password"), TEST_PASSWORD);Secrets are masked
Values registered as secrets are redacted in logs and failure artifacts (shown
as ‹secret:NAME›), so they never leak into a trace or a screenshot bundle.
That covers everything a run says out loud as well as everything it writes:
the run journal, what the runner prints to its terminal, the same lines as
the viewer’s System pane streams them, and the copy kept beside the run in
stdout.log / stderr.log.
Masking is by value, wherever the value appears — with one explicit
exception: the run’s own identifiers in runtime.json (the run id, the
scenario path, the test function, the file and function names of the call
stack) are never rewritten, so LOGIN=admin does not turn
guard-admin.js into guard-‹secret:LOGIN›.js.
What masking cannot cover
Masking knows the values you gave it. A secret the application shows for the first time — a token it has just minted, a one-time code, a recovery key — is not one of them, and an assertion that reads the block containing it puts it in the journal, the screenshot and the failure bundle, because an assertion’s job is to report what it saw:
// Leaks the token into every artifact of a failed run.assertText(page.locator('.token-panel'), 'copy it now');Assert on the label, the heading or the presence of the element instead, and never on the text of the block that displays the value.
The judge’s connection (UNOTEST_JUDGE_MODE, _URL, _TOKEN, …) belongs
in the same files — the token in .secrets; see
LLM judge.
Names the runner keeps to itself
A bare identifier also resolves against the runner’s own environment, so a
CI job can hand the suite BASE_URL or a token from its secret store
without any file. The runner’s own credentials are the exception: a
scenario does not read them, and a shell() step’s process does not
inherit them.
UNOTEST_JUDGE_TOKEN,UNOTEST_JUDGE_ACCESS_TOKEN- the judge provider’s API key (
ANTHROPIC_API_KEY,OPENAI_API_KEYorGEMINI_API_KEY), only while the judge runs in-process (UNOTEST_JUDGE_MODE=local) and only the active provider’s; with a remote judge, or another provider, the name is the application’s and reads as usual UNOTEST_CLOUD_TOKEN,UNOTEST_BOX_TOKEN,UNOTEST_BOX_READ_TOKEN- the grounder’s credentials:
UNOTEST_GROUNDER_REMOTE_TOKENand the local grounder’s_PROVIDER_KEY,_PICKER_KEY,_EMBED_KEY,_SHARED_TOKEN,_METRICS_TOKEN,_REVOCATIONS_TOKEN
Reading one is refused with whose it is, not with not found:
ANTHROPIC_API_KEY is the judge's credential (UNOTEST_JUDGE_MODE=local)on this runner, not a scenario variableThe same sentence comes from the check a run makes before the browser
starts, from {{NAME}} in an explore_step, and from
sandbox.exportSecrets naming such a variable.
If the application under test needs the same key, give it its own name
(APP_ANTHROPIC_API_KEY) in .secrets.
A shell() command runs with the rights of the account that runs the
suite: exported keys and key files are visible to it. Dropping these
names keeps them out of an accidental printenv and out of logs; it is
not a wall. The same command can read your files (unotest/.secrets,
the cloud sign-in in ~/.config/unotest/) and the environment your
shell exported. Do not run a suite you do not trust from a shell that
exports a key. On a box, a run’s container holds none of the box’s
tokens.
Across environments
Because the test references variables, the same scenario runs against dev, staging or prod. Named environments are overlay files layered over the base pair — an overlay holds only the keys that differ (later file wins; ambient shell env beats every file):
# unotest/.env — base: shared values + local defaultsAPP_BASE_URL=http://localhost:3000TEST_USER_EMAIL=demo@example.com
# unotest/.env.staging — overlay: only what differs on stagingAPP_BASE_URL=https://staging.app.devSecrets overlay the same way: unotest/.secrets.staging over
unotest/.secrets. An environment exists as soon as either overlay does —
.env.<name> or .secrets.<name>; the names example, sample and
template are never offered as environments. Each environment keeps its own
run history under unotest/.runs.<name>/; a target adds its own axis in
front (unotest/.runs-mobile.staging/).
unotest/.env.example is the template init writes once. It is your file:
a later init never rewrites it, but reports the keys the current template
declares that your file does not mention (skipped — the current template also declares: …) — add them by hand, or re-run with --force to take the
template.
The twins also declare. unotest/.env and .secrets are gitignored, so
in a fresh checkout — a CI runner, a colleague who has just cloned —
.env.example and .secrets.example are the only place your project still
says which externals it expects. lint accepts a name written in one, and a
pushed bundle carries the same list, so a box and the linter agree on what
the suite needs. Write the name with no value (LOGIN=); credentials belong
in .secrets.example, everything else in .env.example. Values are never
read from a twin — a scenario that reads such a variable fails at that step
with no value, which is a different problem from no such name.
Select the environment per run:
cd unotest && npx @unotest/web e2e auth/login --env staging# or: cd unotest && UNOTEST_ENV=staging npx @unotest/web e2e auth/loginNo --env / UNOTEST_ENV means base files only.
Run history follows the same axis: a run started with --env staging
writes its artifacts under unotest/.runs.staging/, while base runs stay
in unotest/.runs/. Each environment keeps its own history, so results
from different environments never mix (keep the wildcard
unotest/.runs*/ line in .gitignore — that’s what init writes).
The viewer’s Variables panel lets you edit values,
reveal/hide secrets, and toggle boolean flags without leaving the window.
It also has an env switcher at the top of the panel — the same
.env.<name>-overlay switcher the status bar shows as env overlay: pick
base or any overlay and the panel shows
the merged layers exactly as a run in that environment resolves them —
overlay-owned values carry an env badge, edits go to the file that
defines the key, and new variables are always created in the base file.
Switching is server-side: the Overview and Runs views re-scope to that
environment’s history, and runs started from the viewer spawn with its
UNOTEST_ENV. Template files like .env.example are never offered as
environments. On a box, the project and environment the viewer serves are a
different thing: they are named and switched in the tree header, not here.
On a box the panel also lists what the box injects into every run of the
environment — its target, the operator’s variables and the names of its
secrets — each with a box badge. Those rows are read-only here: a write
that names one is refused (HTTP 409, <NAME> is set by the box for this environment — change it on the box), because no file defines it and the
box’s value is applied after the files.