Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
- Test single: `go test ./pkg/compose/ -run TestFunctionName`
- E2E tests: `go test -tags e2e ./pkg/e2e/ -run TestName`

## E2E tests

- **New e2e tests use the declarative `Scenario` DSL** (`NewScenario` in
`pkg/e2e/scenario.go`): intent, inline compose model, steps as
`(command → expected observables)`. Read `pkg/e2e/SCENARIO.md` before
writing or debugging one — it codifies the rules (state-based checks first,
`OutputContains` as last resort, new checks go in `pkg/e2e/checks.go`) and
how to exploit failure artifacts and `E2E_KEEP_FAILED=1`.

## Lint

- Linter: golangci-lint v2 (config in `.golangci.yml`)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/tools v0.47.0
google.golang.org/grpc v1.83.0
gotest.tools/v3 v3.5.2
tags.cncf.io/container-device-interface v1.1.0
Expand Down
107 changes: 107 additions & 0 deletions pkg/e2e/SCENARIO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# E2E scenarios: the contract

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`](scenario.go) (execution, actions,
requirements) and [`checks.go`](checks.go) (the vocabulary of observables).

## Writing a scenario

```go
func TestRestart(t *testing.T) {
NewScenario(t, "restart must bring an exited service back up, restarting the same container").
Compose(`
services:
app:
image: alpine
init: true
command: ash -c "if [[ -f /tmp/restart.lock ]] ; then sleep infinity; else touch /tmp/restart.lock; fi"
`).
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:

- **New e2e tests use `NewScenario`.** The legacy `NewCLI` style remains for
existing tests, converted opportunistically; don't add to it.
- **One intent = one invariant.** The intent is a one-line statement of the
behavior being locked, phrased as an obligation ("X must Y"). If you need
two intents, write two scenarios.
- **Step names are behavior sentences**, not command echoes: "an unchanged
create is a no-op", not "run create again". The transcript of step names
should read as the specification.
- **The compose model is inline.** A scenario is self-contained in the test
source; no fixture directories. Interpolate runtime values via `Env`. When
the project needs more than a `compose.yaml` (a Dockerfile, an env file, a
config file), declare all files with `Files` as a [txtar](https://pkg.go.dev/golang.org/x/tools/txtar)
archive — the format Go's own `cmd/go` tests are written in: diff-friendly,
and one every human and coding agent already knows by heart:

```go
s.Files(`
-- compose.yaml --
services:
app:
build: .
-- Dockerfile --
FROM alpine
CMD ["sleep", "infinity"]
`)
```
- **Regression tests link the issue** in a comment above the test, with a
sentence on the failure mode being locked.

## Checks: observe real state

Checks are the shared vocabulary between scenarios; their discipline is what
keeps the contract meaningful.

- **Prefer state-based checks** (`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").
- **Never poll by hand**: wrap a state check in `Eventually(check, timeout)`.
No `time.Sleep` in scenarios.
- **A new check must be generic** — no test-specific logic — **and named
after the observable it asserts**, not after the test that needed it. It
goes in `checks.go`, where the whole vocabulary is reviewed as one file.
Before adding one, verify the observable isn't already expressible.
- A check should also fail loudly on a broken precondition (e.g.
`NotRecreated` errors if the service had no container before the step)
rather than pass vacuously.

## When a scenario fails

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`.
- The inline report shows the transcript (every step, exit code, duration),
the failing step's output, project containers, engine events since the
scenario started, and container log tails — truncated for readability; the
artifacts have the full versions.

Run a single scenario with:

```sh
go test -tags e2e ./pkg/e2e/ -run TestRestart -v
```
27 changes: 27 additions & 0 deletions pkg/e2e/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,3 +673,30 @@ func TestBuildEscaped(t *testing.T) {
res = c.RunDockerComposeCmd(t, "--project-directory", "./fixtures/build-test/escaped", "build", "--no-cache", "arg")
res.Assert(t, icmd.Success)
}

// TestUpBuildUnchangedContext locks the invariant that rebuilding an
// unchanged build context hits the build cache and leaves the running
// service alone: same image, same config hash, same container.
func TestUpBuildUnchangedContext(t *testing.T) {
s := NewScenario(t, "an unchanged up --build must hit the build cache and not recreate the service")
s.Files(`
-- compose.yaml --
services:
app:
build: .
-- Dockerfile --
FROM alpine
COPY marker /marker
CMD ["sleep", "infinity"]
-- marker --
v1
`).
Defer(DockerCmd("image", "rm", "-f", s.Project()+"-app")).
Step("up builds the image and starts the service",
ComposeCmd("up", "-d", "--build"),
ServiceState("app", "running")).
Step("an unchanged up --build reuses the cached image and the container",
ComposeCmd("up", "-d", "--build"),
NotRecreated("app"),
LabelUnchanged("app", "com.docker.compose.config-hash"))
}
Loading
Loading