Skip to Content
DocsAuthoring flowsFixtures and secrets

Fixtures and secrets

Test data and credentials live outside the flow. Fixtures are committed YAML files referenced from prose and YAML alike; sentinels let you mark a value as visible (${env:KEY}) or sensitive (${secret:KEY}). ${secret:...} values are scrubbed from every artefact klera writes.

TL;DR

  1. Put structured data in fixtures/*.yaml. Use ${env:KEY} for non-sensitive config (URLs, IDs) and ${secret:KEY} for anything that would be a leak if it appeared in a snapshot.
  2. Reference the data from prose via {{.dotted.path}} (or directly from a YAML flow).
  3. Define the env vars in .env (local dev — gitignored) or your CI runner’s secret store.

The framework reads .env first, falls back to process.env, registers ${secret:...} values with the per-run Redactor, and scrubs them from every artefact before write.

Anatomy of a fixture file

# fixtures/users.yaml — committed to your repo users: regular: email: ${env:E2E_LOGIN_EMAIL} # visible in artefacts password: ${secret:E2E_LOGIN_PASSWORD} # scrubbed everywhere admin: email: ${env:E2E_ADMIN_EMAIL} password: ${secret:E2E_ADMIN_PASSWORD} teamId: team_42 # plain literal — committed

Three kinds of values, each chosen for what they actually are:

FormResolved at run time?In artefacts?Use for
Literal stringnoyes (visible)constants like account IDs, plan codes
${env:KEY}yesyes (visible)URLs, identifiers, regional toggles
${secret:KEY}yesno<redacted:KEY>passwords, tokens, API keys

The two sentinels are not interchangeable. The form encodes the security level, not just the source of the value. A token that happens to be in process.env is still a secret — write ${secret:TOKEN}, not ${env:TOKEN}.

Authoring a prose flow that uses a fixture

# flows/sign-out.flow.md Log in as {{.users.regular}}. Tap "Sign out", confirm the alert. The login screen reappears.

klera plan flows/sign-out.flow.md --snapshot snap.json substitutes {{.users.regular}} against the loaded fixtures before sending the prose to the LLM. The literal email is inlined into the cached IR; ${secret:E2E_LOGIN_PASSWORD} survives verbatim and gets resolved at run time.

_meta.fixturesUsed records which fixture paths were referenced so the report header can show “this flow uses users.regular” without re-parsing the prose.

Authoring a YAML flow that uses sentinels

The escape hatch — useful when you don’t want the LLM in your pipeline:

# flows/sign-out.flow.yaml name: Sign out steps: - type: into: { testID: login-email } value: ${env:E2E_LOGIN_EMAIL} - type: into: { testID: login-password } value: ${secret:E2E_LOGIN_PASSWORD} - tap: { testID: login-submit } - tap: Sign out - dismissAlert: Confirm - assert: { visible: { testID: login-screen } }

Same resolution machinery; the only thing missing is the prose-substitution layer.

Local development

# 1. Bootstrap from the committed placeholder. cp .env.example .env # 2. Fill in real values. The file is gitignored. $EDITOR .env # 3. Run. klera run flows/sign-out.flow.yaml # or klera plan flows/welcome.flow.md --snapshot snap.json klera run flows/welcome.flow.md

.env resolution wins over process.env — that’s deliberate. It lets you override CI-provided values locally without unsetting shell exports. CI environments don’t have a .env in the workspace (it’s gitignored at the project root), so they fall through to process.env cleanly.

CI

Add the env vars to your runner’s secret store, surface them as env vars to the klera run step:

- name: Run E2E run: pnpm exec klera run flows/ env: E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }} E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }}

klera ci <target> scaffolds a working CI config that wires this in. See CI integration.

Missing-env errors

When an env var is unset in both .env and process.env:

✗ secret 'E2E_LOGIN_PASSWORD' is not set. Define it in .env (local dev) or export the env var (CI). In CI, add it to your runner's secret store with the same name and surface as an env var.

The step fails fast with this message; subsequent steps skip. The flow exits non-zero so CI sees the failure.

What gets scrubbed (the redaction contract)

Every artefact the engine writes routes its string content through the per-run Redactor before emit. For a value resolved via ${secret:KEY}, every occurrence in:

  • Failure-evidence snapshots (__failure-evidence__/<flow>/step-N/*.snapshot.json)
  • Report JSON (klera run --report report.json)
  • HTML report (klera report --html)
  • Network log entries (header values, request/response body strings)
  • Recording-mode events (the streamed event log + generated .flow.md body)
  • Triage payloads sent to the LLM
  • Logger output (console.warn / console.error)
  • OpenTelemetry spans, metrics, and logs (every attribute, event body, and log record runs through the Redactor before egress)

…gets replaced with <redacted:KEY>. The marker is opaque to downstream consumers but obvious to a human reading a snapshot.

What does NOT get scrubbed (the honest limits)

  • PNG frames captured during failure-evidence flush. Visual leakage is the field designer’s responsibility — set secureTextEntry: true on iOS / secureTextEntry (or autoComplete="password") on Android for any field whose value resolves through ${secret:...}. Without that, the keyboard renders the typed character on screen and a screenshot captures it. klera does not OCR screenshots.
  • The runtime side of the WebSocket bridge. The runtime has to receive the resolved value to type it into the input field. Intercepting in-process JS memory is outside any test framework’s threat model.
  • Adopter-supplied custom error messages. If you write throw new Error('failed for ' + password) in a custom callback, that’s on you. The framework only scrubs strings it serialises itself.

A worked example: sign-in flow with both kinds of values

    • sign-in.flow.md
    • sign-in.flow.json
    • users.yaml
    • api.yaml
  • .env
  • .env.example
# fixtures/users.yaml users: pm: email: ${env:E2E_PM_EMAIL} password: ${secret:E2E_PM_PASSWORD} displayName: 'Pat Morgan' # public — committed literal
# fixtures/api.yaml api: base: ${env:E2E_API_BASE} # https://staging.example.com authToken: ${secret:E2E_API_TOKEN}
# .env (local; gitignored) E2E_PM_EMAIL=pm@example.com E2E_PM_PASSWORD=hunter2 E2E_API_BASE=https://staging.example.com E2E_API_TOKEN=sk_test_abc123
# flows/sign-in.flow.md Open the app on the login screen. Sign in as {{.users.pm}}. Wait for the welcome greeting; assert it says "Welcome, Pat Morgan".

When the flow runs, the report captures:

{ "step": 1, "kind": "type", "into": { "testID": "login-email" }, "value": "pm@example.com" // ${env:...} → visible }, { "step": 2, "kind": "type", "into": { "testID": "login-password" }, "value": "<redacted:E2E_PM_PASSWORD>" // ${secret:...} → scrubbed }

Even if step 2 fails and the failure-evidence flush dumps the entire network log, every header / body string passes through the Redactor first. The OTel span attributes egress with the same scrubbing.

Common patterns

Multiple users in one fixture file

users: admin: email: ${env:E2E_ADMIN_EMAIL} password: ${secret:E2E_ADMIN_PASSWORD} regular: email: ${env:E2E_LOGIN_EMAIL} password: ${secret:E2E_LOGIN_PASSWORD} guest: email: guest@example.com # public — committed literal

Reference one or another from prose: Log in as {{.users.admin}}.

One fixture file per domain

fixtures/ users.yaml # {{.users.*}} api.yaml # {{.api.base}}, {{.api.timeout}} test-data.yaml # {{.testData.products[0]}}

Top-level keys must be unique across files (the loader fails fast on collision). Each file’s contents merge into the same root namespace.

Sentinels in nested data

api: authHeader: 'Bearer ${secret:E2E_API_TOKEN}'

Embedded sentinels are not supported in v1 — the resolver only matches strings that are entirely a sentinel. To compose, keep the prefix in the fixture and the sentinel separate, then build the header at the call site.

Next steps

Last updated on