pkg/e2e/SCENARIO.md
With coding agents writing most of the production code, e2e tests are the
document humans actually read and review. A scenario must state an intent, a
compose model, and a sequence of steps (command → expected observables) —
and nothing else. Everything operational (project naming, cleanup, failure
diagnostics) belongs to the framework, not to the test.
The DSL lives in scenario.go (execution, actions,
requirements) and checks.go (the vocabulary of observables).
The project files live in testdata/<TestName>/ — standalone compose files,
directly runnable with docker compose -f testdata/TestRestart/compose.yaml:
# testdata/TestRestart/compose.yaml
services:
app:
image: alpine
init: true
command: ash -c "if [[ -f /tmp/restart.lock ]] ; then sleep infinity; else touch /tmp/restart.lock; fi"
func TestRestart(t *testing.T) {
NewScenario(t, "restart must bring an exited service back up, restarting the same container").
Step("up starts the service, whose first run exits at once",
ComposeCmd("up", "-d"),
Eventually(ServiceState("app", "exited"), 10*time.Second)).
Step("restart brings the service back up, reusing the container",
ComposeCmd("restart"),
Eventually(ServiceState("app", "running"), 10*time.Second),
NotRecreated("app"))
}
Rules:
NewScenario. The legacy NewCLI style remains for
existing tests, converted opportunistically; don't add to it.testdata/<TestName>/. The scenario resolves
that directory by convention (subtests map to nested directories, following
t.Name()) and copies it to a temporary directory, so the committed files
are never mutated. The directory holds a compose.yaml plus whatever the
project needs (Dockerfile, env or config files) in their native format,
directly runnable outside the test. Ownership is strictly one test per
directory — no shared fixtures — and a check fails the suite on any
testdata directory no test owns. Interpolate runtime values via Env.Checks are the shared vocabulary between scenarios; their discipline is what keeps the contract meaningful.
ServiceState, NotRecreated, LabelSet,
RunsOnPlatform, …): they observe containers, labels and image manifests —
what the user actually gets — not what the CLI printed.OutputContains is a last resort, legitimate only when the CLI's
reported decision is itself the observable (e.g. "Skipped" vs "Pulled").Eventually(check, timeout).
No time.Sleep in scenarios.checks.go, where the whole vocabulary is reviewed as one file.
Before adding one, verify the observable isn't already expressible.NotRecreated errors if the service had no container before the step)
rather than pass vacuously.The report opens with everything needed to diagnose without re-running:
artifacts: <dir> — a stable per-project directory holding the
untruncated material: compose.yaml, failure.txt, each step's full
command and output (step-NN-*.txt), containers.txt, events.txt, full
container logs (logs-*.txt) and the per-step state snapshots
(snapshots.json). Read these before re-running anything.E2E_KEEP_FAILED=1 — rerun with this set to skip teardown of failed
scenarios: containers, volumes and networks stay alive for docker inspect/exec. Clean up afterwards with
docker compose --project-name <project> down -v --remove-orphans.Run a single scenario with:
go test -tags e2e ./pkg/e2e/ -run TestRestart -v