This documentation is available as Markdown. For the complete index, see llms.txt. Skip to content

Variables & secrets

For the complete documentation index, see llms.txt

Environments, 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_KEY or GEMINI_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_TOKEN and 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 variable

The 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 defaults
APP_BASE_URL=http://localhost:3000
TEST_USER_EMAIL=demo@example.com
# unotest/.env.staging — overlay: only what differs on staging
APP_BASE_URL=https://staging.app.dev

Secrets 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:

Terminal window
cd unotest && npx @unotest/web e2e auth/login --env staging
# or: cd unotest && UNOTEST_ENV=staging npx @unotest/web e2e auth/login

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