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
- 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. - Reference the data from prose via
{{.dotted.path}}(or directly from a YAML flow). - 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 — committedThree kinds of values, each chosen for what they actually are:
| Form | Resolved at run time? | In artefacts? | Use for |
|---|---|---|---|
| Literal string | no | yes (visible) | constants like account IDs, plan codes |
${env:KEY} | yes | yes (visible) | URLs, identifiers, regional toggles |
${secret:KEY} | yes | no — <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:
GitHub Actions
- 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.mdbody) - 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: trueon iOS /secureTextEntry(orautoComplete="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 literalReference 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
- Network mocking — fixture-backed responses for flaky endpoints.
- Prose flows — the full prose authoring story.
- CI integration — wiring secrets into your CI runner’s vault.