Skip to content
Merged
33 changes: 32 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,8 @@ EXIT 0
Variables are resolved from (in priority order):
1. `--var NAME=VALUE` CLI flags
2. `[captures]` from previous entries
3. Environment variables (via `$VAR` or `${VAR}` syntax)
3. `@var NAME=VALUE` file-level frontmatter directive
4. Environment variables (via `$VAR` or `${VAR}` syntax)

### Environment Variable Expansion

Expand Down Expand Up @@ -497,6 +498,7 @@ EXIT 0
| `@defer` | entry | `@defer` | Marks entry as cleanup — runs at file end (LIFO), always, not a test |
| `@workdir` | file, entry | `@workdir <path>` | Run command in specified directory |
| `@env` | file, entry | `@env KEY=VALUE` | Set environment variable for child process |
| `@var` | file | `@var KEY=VALUE` | Set a default for `{{KEY}}` template substitution, scoped to this file |

#### `@group`

Expand Down Expand Up @@ -621,6 +623,34 @@ EXIT 0
stdout == "false"
```

#### `@var`

Sets a file-scoped default value for `{{KEY}}` template substitution. File-level only (frontmatter) — placing `@var` after the frontmatter has no effect (unknown entry directives are silently ignored). One `KEY=VALUE` per directive. A duplicate key within the same file's frontmatter is a parse error.

**Precedence:** `--var` (CLI) > `[captures]` > `@var` (file) — see [Variables](#variables).

**Values:** The value is everything after the first `=`. Values may contain `=`, spaces, and special characters. Empty values (`@var KEY=`) are valid. Malformed directives without `=` are rejected at parse time, as is a repeated key.

**Scope:** `@var` substitution expands `{{KEY}}` in the entry's command line only — it does NOT expand in the entry's expected body or `[asserts]` values. `--var`, by contrast, expands everywhere (command, body, and asserts), since it runs as a raw text substitution over the whole file before parsing.

**Variable substitution:** `--var` placeholders (`{{name}}`) in `@var` values are expanded (since substitution runs before parsing).

**Use case:** sibling `.clitest` files that differ by exactly one value (a scenario directory, a tool version) can express that value once, instead of repeating it on every line that needs it:

```
---
@var SCENARIO=checkout/case-a
---

hurl --test tests/{{SCENARIO}}/stubs/_register.hurl
EXIT 0

php bin/console app:seed tests/{{SCENARIO}}/seller.json
EXIT 0
```

A sibling file for `case-b` differs from this one only in the frontmatter line.

#### Directive Validation

All directives are validated at parse time. If any directive has an invalid value, clitest reports the error(s) and does not execute the file. Multiple errors may be reported at once.
Expand All @@ -632,6 +662,7 @@ All directives are validated at parse time. If any directive has an invalid valu
| `@timeout` | Integer >= 0 (milliseconds) | `0` means no timeout (infinite wait). No duration suffixes. |
| `@poll` | Integer > 0 (milliseconds) | Must be positive. No duration suffixes. |
| `@env` | `KEY=VALUE` with non-empty key | Empty value after `=` is valid. Missing `=` is rejected. |
| `@var` | `KEY=VALUE` with non-empty key | Empty value after `=` is valid. Missing `=` is rejected. Duplicate key within one file is rejected. |

Unknown directives are silently ignored.

Expand Down
4 changes: 2 additions & 2 deletions cmd/clitest/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,13 @@ func processFile(cfg *runConfig, f resolve.ScanFile, idx int, pd *display.Progre
var buf bytes.Buffer
vd := display.NewVerboseDisplay(&buf, true)
vd.BeginFile(f.Display)
pass, fail, skip := runEntriesVerbose(cfg, vd, pd, idx, entries)
pass, fail, skip := runEntriesVerbose(cfg, vd, pd, idx, entries, parsed.Directives.Var)
vd.EndFile()
pd.FinishFile(idx, fail == 0, buf.String())
return fileResult{pass: pass, fail: fail, skip: skip, display: f.Display}
}

pass, fail, skip, details := runEntriesCompact(cfg, pd, idx, entries)
pass, fail, skip, details := runEntriesCompact(cfg, pd, idx, entries, parsed.Directives.Var)
pd.FinishFile(idx, fail == 0, "")
return fileResult{pass: pass, fail: fail, skip: skip, display: f.Display, failures: details}
}
Expand Down
12 changes: 6 additions & 6 deletions cmd/clitest/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ type entryOutcome struct {
// runEntries executes entries, reports progress, and returns counters + failure details.
// The onResult callback is invoked for each entry outcome (including skipped entries).
// In verbose mode, pass a callback that writes to VerboseDisplay; in compact mode, pass nil.
func runEntries(cfg *runConfig, pd *display.ProgressDisplay, fileIdx int, entries []types.Entry, onResult func(entryOutcome)) (pass, fail, skip int, details []display.CompactFailure) {
func runEntries(cfg *runConfig, pd *display.ProgressDisplay, fileIdx int, entries []types.Entry, fileVars map[string]string, onResult func(entryOutcome)) (pass, fail, skip int, details []display.CompactFailure) {
regular, defers := executor.SplitDeferEntries(entries)
pd.UpdateProgress(fileIdx, 0, len(regular))
captures := map[string]string{}
captures := vars.SeedCaptures(fileVars)
var backgrounds []*executor.BackgroundResult

for i, entry := range regular {
Expand Down Expand Up @@ -146,7 +146,7 @@ func runEntries(cfg *runConfig, pd *display.ProgressDisplay, fileIdx int, entrie
}

// runEntriesVerbose executes entries and reports to VerboseDisplay.
func runEntriesVerbose(cfg *runConfig, vd *display.VerboseDisplay, pd *display.ProgressDisplay, fileIdx int, entries []types.Entry) (pass, fail, skip int) {
func runEntriesVerbose(cfg *runConfig, vd *display.VerboseDisplay, pd *display.ProgressDisplay, fileIdx int, entries []types.Entry, fileVars map[string]string) (pass, fail, skip int) {
onResult := func(o entryOutcome) {
if o.SkipReason == "defer" {
vd.DeferResult(o.Command)
Expand All @@ -165,13 +165,13 @@ func runEntriesVerbose(cfg *runConfig, vd *display.VerboseDisplay, pd *display.P
Stderr: o.Stderr,
})
}
pass, fail, skip, _ = runEntries(cfg, pd, fileIdx, entries, onResult)
pass, fail, skip, _ = runEntries(cfg, pd, fileIdx, entries, fileVars, onResult)
return
}

// runEntriesCompact executes entries and reports progress to ProgressDisplay.
func runEntriesCompact(cfg *runConfig, pd *display.ProgressDisplay, fileIdx int, entries []types.Entry) (pass, fail, skip int, details []display.CompactFailure) {
return runEntries(cfg, pd, fileIdx, entries, nil)
func runEntriesCompact(cfg *runConfig, pd *display.ProgressDisplay, fileIdx int, entries []types.Entry, fileVars map[string]string) (pass, fail, skip int, details []display.CompactFailure) {
return runEntries(cfg, pd, fileIdx, entries, fileVars, nil)
}

// processBackgrounds evaluates later asserts and finally sections for all kept-alive background processes.
Expand Down
52 changes: 50 additions & 2 deletions cmd/clitest/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func TestRunEntries_FailFastCancels(t *testing.T) {
cfg := &runConfig{FailFast: true, Cancelled: cancelled}
pd := newTestProgressDisplay()

pass, fail, skip, _ := runEntries(cfg, pd, 0, entries, nil)
pass, fail, skip, _ := runEntries(cfg, pd, 0, entries, nil, nil)

if fail != 1 {
t.Errorf("expected 1 failure, got %d", fail)
Expand All @@ -94,7 +94,7 @@ func TestRunEntries_PreCancelledSkipsAll(t *testing.T) {
cfg := &runConfig{FailFast: false, Cancelled: cancelled}
pd := newTestProgressDisplay()

pass, fail, skip, _ := runEntries(cfg, pd, 0, entries, nil)
pass, fail, skip, _ := runEntries(cfg, pd, 0, entries, nil, nil)

if skip != 2 {
t.Errorf("expected 2 skipped, got %d", skip)
Expand All @@ -107,6 +107,54 @@ func TestRunEntries_PreCancelledSkipsAll(t *testing.T) {
}
}

func TestRunEntries_FileVarResolvesInCommand(t *testing.T) {
entries := []types.Entry{
{Command: "echo {{NAME}}", Asserts: []types.Assert{{Query: "stdout", Predicate: "contains", Value: "world"}}},
}
fileVars := map[string]string{"NAME": "world"}

cancelled := &atomic.Bool{}
cfg := &runConfig{FailFast: false, Cancelled: cancelled}
pd := newTestProgressDisplay()

pass, fail, _, _ := runEntries(cfg, pd, 0, entries, fileVars, nil)

if fail != 0 {
t.Errorf("expected 0 failures, got %d", fail)
}
if pass != 1 {
t.Errorf("expected 1 pass, got %d", pass)
}
}

func TestRunEntries_CaptureOverridesFileVar(t *testing.T) {
entries := []types.Entry{
{
Command: "echo captured_val",
Captures: []types.Capture{{Name: "NAME", Source: types.CaptureStdout}},
Asserts: []types.Assert{{Query: "exit", Predicate: "==", Value: "0"}},
},
{
Command: "echo {{NAME}}",
Asserts: []types.Assert{{Query: "stdout", Predicate: "contains", Value: "captured_val"}},
},
}
fileVars := map[string]string{"NAME": "default_val"}

cancelled := &atomic.Bool{}
cfg := &runConfig{FailFast: false, Cancelled: cancelled}
pd := newTestProgressDisplay()

pass, fail, _, _ := runEntries(cfg, pd, 0, entries, fileVars, nil)

if fail != 0 {
t.Errorf("expected 0 failures, got %d", fail)
}
if pass != 2 {
t.Errorf("expected 2 pass, got %d", pass)
}
}

func TestProcessBackgrounds_FailFastOnLaterFailure(t *testing.T) {
// We cannot easily construct a real BackgroundResult with a running process,
// so we test with an empty slice to verify no panic, and rely on E2E tests
Expand Down
Loading