Tales is a single-binary integration and end-to-end testing tool. Scenarios are written in declarative HCL2 files with the .tales extension.
📚 Full documentation: taleslabs.org/
- Single Go binary — drop it in CI, no runtime, no plugins, no version manager.
- Declarative HCL2 — no JavaScript escape hatch, no glue code. What you write is what runs.
- Deterministic seeded data —
--seed 1234produces byte-identical generated values on every run. - Scenarios in parallel, steps sequential in file order. Chained captures stay deterministic.
- HTTP provider including ConnectRPC JSON over HTTP and multipart uploads.
- SQL provider (
step "sql") for PostgreSQL + MySQL preconditions and teardown. See docs/providers/sql/. - Native iOS UI automation via XCUITest (
step "mobile"), no Appium / no Maestro. The XCUITest driver is embedded in thetalesbinary and built on first use into~/Library/Caches/tales/apple-driver/, so a released binary runs iOS tests on any macOS+Xcode host.tales doctor(--jsonfor CI) inspects the cache, embedded source, Xcode, and simctl state in one place. See docs/providers/mobile-ios/. - Reports — human-readable console output, JUnit XML for CI dashboards, JSONL event stream for log pipelines, single-file visual HTML report with action-by-action screenshot replay.
This repository contains a pragmatic V1 focused on HTTP workflows:
scenario+stepexecution model.expectassertions.capturefor stable step outputs.teardownblock always executed after main steps.when = can(...)support in teardown.- executable
keywordblocks withinputsandoutputs. - Parallel scenario execution (
--parallel). - Deterministic generation via
--seed.
Requirements:
- Go
1.26(seego.mod) golangci-lintformake lint
Build:
go build -o build/tales ./cmd/talesOr:
make buildTagged releases (v*) are built by the release workflow
and published to GitHub Releases. Pre-built binaries are provided for
linux/{amd64,arm64} and darwin/{amd64,arm64}, with a checksums.txt
file alongside. See docs/operations/release/ for the release process and how to verify a downloaded binary.
Create a file like tests/blog.tales:
version = 1
config {
base_url = env("BASE_URL", "http://localhost:1337")
}
generator "email" "user_email" {
prefix = "test-"
domain = "example.com"
}
generator "password" "user_password" {
length = 16
min_upper = 1
min_lower = 1
min_digit = 1
min_special = 1
specials = "!@#$%^&*"
}
generator "timezone" "user_timezone" {}
generator "locale" "user_locale" {
separator = "-"
}
generator "person" "user_person" {
gender = "female"
}
generator "mac_address" "device_mac" {
prefix = "aa:bb"
separator = "-"
lowercase = true
}
generator "bytes" "trace_id" {
length = 8
encoding = "hex"
}
scenario "Create a blog post" {
tags = ["demo"]
step "http" "create_user" {
request {
method = "POST"
url = "${config.base_url}/users"
headers = {
Accept = "application/json"
Content-Type = "application/json"
}
body {
json = {
email = generate("user_email")
password = generate("user_password")
timezone = generate("user_timezone")
locale = generate("user_locale")
person = generate("user_person")
device = {
mac_address = generate("device_mac")
}
trace_id = generate("trace_id")
}
}
}
expect {
status = 201
json = {
id = is_string()
email = request.body.json.email
}
}
capture {
id = response.json.id
email = response.json.email
password = request.body.json.password
}
}
step "http" "auth_user" {
request {
method = "POST"
url = "${config.base_url}/auth"
headers = {
Content-Type = "application/json"
}
body {
json = {
email = result.create_user.email
password = result.create_user.password
}
}
}
expect {
status = 200
json = {
access_token = is_string()
}
}
capture {
token = response.json.access_token
}
}
teardown {
step "http" "delete_user" {
when = can(result.create_user.id)
request {
method = "DELETE"
url = "${config.base_url}/users/${result.create_user.id}"
headers = {
Authorization = "Bearer ${result.auth_user.token}"
}
}
expect {
status = one_of([200, 204, 404])
}
}
}
}Run:
BASE_URL=http://localhost:1337 ./build/tales test ./tests --seed 1234tales validate <path>Examples:
tales validate ./e2e
tales validate ./e2e/pass/blog.talestales test <path> [flags]Flags:
--seed <int>: deterministic seed (if omitted, current time is used).--parallel <int>: scenario-level parallelism.--tag <tag>: run only scenarios containing one of these tags (repeatable).--scenario <name>: run one scenario by exact name.--report-junit <path>: write JUnit XML.--report-jsonl <path>: write JSONL events.--report-html <path>: write a single-file visual HTML report (mobile screenshots replay).--capture-screenshots <mode>: mobile screenshot capture mode. One ofnone,failures,steps,actions. Defaults tofailures, oractionswhen--report-htmlis set.--timeout <duration>: global wall-clock budget for the whole run (e.g.30s,5m,1h). When it fires, in-flight steps see a canceled context, are reported as failed, the CLI lists the scenarios that were still running, and the run exits with1.0(the default) disables the budget — Tales then waits indefinitely.--verbose: emit a heartbeat on stderr every 30s listing every scenario still in flight along with its elapsed time. Off by default.--no-progress: silence two things — the streamingscenario X starting / PASS / FAILlines emitted on stderr while the suite is running, AND the per-step progress counters that the final stdout report adds when stdout is a TTY. The scenario/step pass-fail summary itself is always rendered.
Examples:
tales test ./e2e/pass --seed 1234 --parallel 4
tales test ./e2e/pass --tag demo
tales test ./e2e/pass --scenario "Create a blog post"
tales test ./e2e/pass --report-junit build/reports/e2e.junit.xml --report-jsonl build/reports/e2e.jsonl
tales test ./e2e/pass --timeout 5mExit codes:
0: all scenarios passed.1: at least one scenario failed.2: parse/validation failure.3: runtime/reporting fatal error.
scenario "..." { ... }step "http" "name" { ... }step "sql" "name" { connection = "<name>"; exec { ... } | query { ... } }— see docs/providers/sql/.request.body { json = ... }for JSON payloads.request.body { form = ... }forapplication/x-www-form-urlencodedpayloads.request.body { raw = ... }for raw string payloads.request.body { multipart { file { ... } field { ... } } }formultipart/form-datauploads. File parts read frompath(relative to the.talesfile) or from an inlinecontentexpression; theContent-Typeheader is set automatically with the generated boundary. See Multipart file upload below.request.auth.basicfor HTTP Basic Authentication.vars { ... }to declare step-local variables evaluated once before the provider runs — required for signing a request body (compute the body string and its HMAC once, send the same bytes). See Step-local vars below.expectassertions for status/headers/json.captureto expose a stable contract for next steps.result.<step_name>.<field>for cross-step references.generator "email",generator "password",generator "timezone",generator "locale",generator "person",generator "mac_address", andgenerator "bytes"for deterministic test data.teardown { ... }for deterministic cleanup.keyword \"...\" { ... }for reusable flows.skip_if { ... }/skip_unless { ... }on a scenario or step to gate execution on OS, architecture, env vars, or any HCL expression. See docs/writing-scenarios/conditional-execution/.
Backward-compatible aliases currently accepted:
caseas alias forstepresponseas alias forexpect
Request body examples:
request {
body {
form = {
grant_type = "password"
username = result.user.email
password = result.user.password
}
}
}body.form values are encoded with application/x-www-form-urlencoded semantics, so characters such as &, =, +, %, #, and spaces are safe in generated values.
request.body { multipart { ... } } builds a multipart/form-data
payload from an ordered list of file { ... } and field { ... }
children. The textual declaration order is preserved on the wire so
the sequence of parts is deterministic across runs. file parts read
their bytes from path (a string resolved relative to the .tales
file) or from an inline content expression. Tales always sets the
request Content-Type to the encoder's value (including the
boundary parameter generated by Go's mime/multipart package), even
if the caller supplied one — otherwise a stale Content-Type would
silently corrupt the boundary and break the request.
Note that the multipart boundary is random per request, so the exact bytes of the multipart body are not bit-stable across runs even though the parts within it are. Signing the multipart wire payload end-to-end is not supported by design; sign individual parts (or a canonical representation built outside the multipart envelope) and attach the signature as a header or a separate field instead.
generator "bytes" "attachment_blob" {
length = 32
encoding = "hex"
}
step "http" "upload" {
request {
method = "POST"
url = "${config.base_url}/upload"
body {
multipart {
file {
field = "avatar"
path = "./avatar.txt" # relative to this .tales
content_type = "text/plain"
}
file {
field = "attachment"
content = generate("attachment_blob")
filename = "attachment.bin"
content_type = "application/octet-stream"
}
field {
name = "description"
value = "fixture upload"
}
}
}
}
}Rules:
- Each
fileblock must declare exactly one ofpathorcontent. Bothfilenameandcontent_typeare optional; when omitted, the provider derivesfilenamefrompath(or falls back to the field name) and sniffscontent_typefrom the extension or payload. - Each
fieldblock requiresnameandvalue. - The
multipartblock cannot be combined withjson,form, orraw—bodymust declare exactly one transport. - Paths are resolved at runtime relative to the
.talesfile owning the step, so fixtures sit naturally next to the scenario. - Tales overwrites any user-set
Content-Typefor multipart bodies so the boundary parameter always matches the encoded payload.
A worked end-to-end example lives in
e2e/pass/file_upload.tales (uses the
/upload mockserver endpoint, which hashes each part with SHA-256 so
the scenario can pin the exact wire payload).
A vars { ... } block declared inside a step introduces variables that
are evaluated once, in declaration order, before the provider
runs. Later vars can read earlier ones via vars.<name>; the cumulative
value is then visible to the step's request, expect, and capture
expressions through the vars scope variable.
Use it whenever a value must be stable across multiple interpolation sites in the same step — most commonly when signing a request: the timestamp, the canonical JSON body, and the HMAC over both must be computed exactly once.
step "http" "send_webhook" {
vars {
ts = now_unix()
body = jsonencode({
id = "evt-${result.create.id}"
type = "notarization.completed"
})
sig = hmac_sha256_hex(config.webhook_secret, "${vars.ts}.${vars.body}")
}
request {
method = "POST"
url = "${config.api_base}/webhook"
headers = {
Content-Type = "application/json"
X-Signature = "t=${vars.ts},v1=${vars.sig}"
}
body {
raw = vars.body
}
}
}Rules:
- vars are step-local. They are not visible to other steps — use
captureto share a value with later steps. - vars are immutable after evaluation.
- A var can only reference vars declared earlier in the same block.
Forward references and self-references are rejected at load time
(exit code
2). whenandskip_if/skip_unlessare evaluated before the step body, so they cannot referencevars.<name>— that is rejected at load time with a clear error.- The same
vars.<name>must hold the same value at every interpolation site. This is guaranteed precisely becausevarsare evaluated once.
- Scenarios run in parallel, up to
--parallelat a time (default1). - Steps inside a scenario run sequentially, in the order they are defined
in the
.talesfile. There is no implicit parallelism between steps. - A step may reference (
result.<step>) ordepends_ononly steps defined earlier in the file. A forward reference or an unknown reference is rejected at load time —tales validatecatches it and the exit code is2. depends_onis optional: file order already determines execution order. Use it only as explicit documentation/validation of a relationship; it does not reorder steps.- When a step fails, the scenario stops: later steps are reported as skipped and are not executed.
teardownsteps run sequentially in file order, after the scenario's steps, even when a step failed.
General:
env(name)env(name, default)generate(name)jsonencode(value)— serializes any value to a canonical JSON string. Object keys are sorted alphabetically; sets are sorted by their JSON encoding; numbers preserve precision. The deterministic output is what makes it safe to sign — two calls on the same value produce the same bytes.url_encode(value)now_unix()— current Unix timestamp in seconds as a number. Uses the wall clock (non-deterministic). Capture in avars { ts = now_unix() }block to reuse the same value at every interpolation site.now_rfc3339()— current UTC time formatted per RFC3339, e.g."2026-05-26T15:42:31Z". Same caveats asnow_unix().sha1_hex(value),sha224_hex(value),sha256_hex(value),sha384_hex(value),sha512_hex(value),sha512_224_hex(value),sha512_256_hex(value)— single-argument hash functions returning the lowercase hex digest of the input string's UTF-8 bytes.hmac_sha1_hex(secret, message),hmac_sha224_hex,hmac_sha256_hex,hmac_sha384_hex,hmac_sha512_hex,hmac_sha512_224_hex,hmac_sha512_256_hex— keyed HMAC variants matching the hash set. Lowercase hex output; secrets and messages never appear in errors.hmac_sha256_hexis the recommended default for new signing code;hmac_sha1_hexexists because RFC 6238 TOTP needs it.base64url_encode(value)— RFC 4648 URL-safe Base64 of the input string's bytes, no padding. Note this encodes the input string, not a hex digest — do not compose withsha256_hexto build a PKCE S256 challenge; usepkce_challengeinstead.pkce_challenge(verifier, options?)— RFC 7636 PKCE challenge from a code verifier. Defaults to S256; supports{method = "plain"}. S256 encodes the raw 32 SHA-256 bytes, never the hex string.totp(secret_base32, options?)— RFC 6238 TOTP code from a Base32 secret.totp(secret)uses the documented defaults (period=30,digits=6,algorithm="SHA1",timestamp=now_unix()); pass an options object to override any of them. Capturenow_unix()into avarsblock and pass it astimestampwhen stability matters.
Top-level expression variables:
host.os—runtime.GOOS(e.g."darwin","linux","windows")host.arch—runtime.GOARCH(e.g."amd64","arm64")config.<key>,result.<step>.<field>, plusrequest,response,inputin step scopevars.<name>— step-local variables declared in the step'svars {}block. Only visible inside the step that declares them. See Step-local vars above.
Matchers:
contains(value)matches(regex)exists()not_exists()is_string()is_number()is_bool()is_array()is_object()one_of(values)can(expression)optional(value)— field-level matcher: passes when the key is absent, or when present and the inner expectation matches.required(value)— field-level matcher: explicit version of the default behavior. Fails when the key is absent, otherwise delegates to the inner expectation. Useful for readability when paired withoptional(...).any()— value matcher: matches any present value (null, string, number, bool, array, object). Does not make the field optional by itself — combine withoptional(any())to also accept omitted keys.
ConnectRPC and protobuf JSON often omit fields holding their default value
(unspecified enums, empty arrays, empty strings, ...). Tales remains
strict-by-default: a field declared in expect.json must be present.
Wrap the expected value with optional(...) to allow the response to omit it:
expect {
status = 200
json = {
id = required(is_string())
role = optional("ROLE_UNSPECIFIED") # absent or equal to default
permissions = optional([]) # absent or empty array
display_name = optional("") # absent or empty string
metadata = optional(any()) # absent or any value
}
}Semantics:
- Object matching stays partial: extra fields on the actual response are
ignored unless
strict = true. - Fields are required by default.
required(...)is a no-op wrapper that exists for readability when other fields in the same block useoptional(...). optional(expected)passes when the key is absent. When the key is present the actual value must matchexpected.optional("")does not match an actualnull. Useoptional(null)to accept eithernullor a missing key.any()alone still requires the key to be present. Useoptional(any())to also accept omitted keys.
Generation is deterministic with --seed and stable derivation inputs.
Running the same suite with the same seed produces the same generated values, even with parallel execution. Step retries reuse the same deterministic generation context, so generated values do not change between retry attempts.
Password generators default to a 16-character password with at least one uppercase letter, lowercase letter, digit, and special character. Supported password options are length, min_upper, min_lower, min_digit, min_special, and specials.
Locale generators support language, country, and separator. Timezone generators return IANA tzdb names or aliases.
Person generators return an object with first_name, last_name, gender, and name. MAC address generators support prefix, separator, lowercase, and uppercase. Bytes generators return deterministic encoded bytes and support length plus encoding (hex or base64).
Default output includes:
- Per-scenario status and duration.
- Per-step and teardown status.
- Failure details with request/response summaries.
- Replay command including seed.
Use --report-junit <path> for CI systems expecting JUnit.
Use --report-jsonl <path> for lightweight tooling and LLM pipelines.
Each line is one event (scenario, step, or action) with fields like:
type,phase,statusfile,scenario,step,providerduration_ms,seederror(when failing)
When per-action recording is on (any --capture-screenshots other than none), each step event is followed by one "type":"action" event per UI action. Action events carry index, kind, label, selector_id, secure, value, status, duration_ms, and optional screenshot / hierarchy paths. Secure values are masked to "***".
Use --report-html <path> to produce a single offline HTML file that replays the mobile test action by action — screenshot on the left, vertical action timeline on the right with the active action highlighted, plus playback controls (Space, ←/→, speed selector). The file is self-contained: vanilla CSS + JS are inlined; screenshots are referenced by relative paths next to the report.
Picking a capture mode:
none— no screenshots or hierarchy ever (failures included).driver_logartifact is still surfaced for non-starting drivers.failures— default. Step-level screenshot + hierarchy only when a step fails (legacy behavior).steps— one end-of-step screenshot per step.actions— one screenshot per UI action. Required for a usable visual replay; selected automatically when--report-htmlis set.
Per-action artifacts live under:
build/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-<n>/actions/NNNN-<kind>-<id>/
See docs/reports/visual/ for a full walk-through, security notes, and limitations.
The repository includes:
- Passing suites:
./e2e/pass/*.tales - Intentional failure suite:
./e2e/fail/*.tales - Real mock HTTP server:
./e2e/mockserver
Run all passing E2E suites:
make e2eRun intentional-failure E2E suite (expects CLI exit code 1):
make e2e-failuremake test
make lint
make e2ecmd/tales: CLI entrypoint.internal/cli: command wiring (test,validate,doctor).internal/parser: loading and HCL decoding.internal/model: suite/scenario/step models.internal/lang: expression evaluation and functions.internal/runtime: execution engine, sequential step runner, seed logic.internal/assertion: matcher and JSON assertion logic.internal/provider/http: HTTP execution provider (including multipart, Basic auth).internal/provider/sql: SQL provider (PostgreSQL + MySQL).internal/provider/mobile: iOS XCUITest provider with embedded driver.internal/provider/keyword: reusable-flow pseudo-provider.internal/report: console / JUnit / JSONL / visual HTML reporting.e2e/mockserver: in-memory test API used by E2E.website/: Astro + Starlight + Tailwind v4 source of taleslabs.org/.
- No browser provider yet (iOS mobile is supported via XCUITest).
- No external plugin system — providers are compiled in.
- No dedicated ConnectRPC provider (Connect JSON works through HTTP).
See LICENSE.md.