From 0ac224d9d9ff552f61042f593b3da6f49da76ede Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Sun, 16 Aug 2026 17:10:06 +0200 Subject: [PATCH] test(e2e): Files() declares multi-file projects as a txtar archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scenario could only declare a lone compose.yaml; anything needing a Dockerfile, an env file or a config file fell back to fixture directories, breaking the "self-contained in the test source" rule. Files() takes the project files as a txtar archive (golang.org/x/tools/txtar, already in the module graph): each file introduced by a `-- name --` line, extracted into the project directory. txtar is the format Go's own cmd/go tests are written in — diff-friendly, and one every human and coding agent already knows by heart, which is the point: e2e scenarios are the main reading surface between the two. TestUpBuildUnchangedContext demonstrates the need — compose.yaml + Dockerfile + a copied file — and locks a real invariant: an unchanged `up --build` hits the build cache and must not recreate the service. Project() is exposed so a scenario can Defer removal of the image it built. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- go.mod | 1 + pkg/e2e/SCENARIO.md | 18 +++++++++++++++++- pkg/e2e/build_test.go | 27 +++++++++++++++++++++++++++ pkg/e2e/scenario.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bd1f292cd..a4c579195 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/pkg/e2e/SCENARIO.md b/pkg/e2e/SCENARIO.md index 96a8d5f6c..3b5ac2465 100644 --- a/pkg/e2e/SCENARIO.md +++ b/pkg/e2e/SCENARIO.md @@ -42,7 +42,23 @@ Rules: 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`. + 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. diff --git a/pkg/e2e/build_test.go b/pkg/e2e/build_test.go index d917336ba..4ae48634c 100644 --- a/pkg/e2e/build_test.go +++ b/pkg/e2e/build_test.go @@ -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")) +} diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go index b38e6bf3c..8734407be 100644 --- a/pkg/e2e/scenario.go +++ b/pkg/e2e/scenario.go @@ -28,6 +28,7 @@ import ( "time" "unicode" + "golang.org/x/tools/txtar" "gotest.tools/v3/icmd" ) @@ -147,6 +148,10 @@ func projectNameFor(testName string) string { // declarative layer doesn't cover. func (s *Scenario) CLI() *CLI { return s.cli } +// Project returns the compose project name the scenario runs under, e.g. to +// Defer the removal of an image the project built. +func (s *Scenario) Project() string { return s.project } + // Compose declares the project's compose model, written to a temporary // directory so the whole scenario is self-contained in the test source. func (s *Scenario) Compose(yaml string) *Scenario { @@ -159,6 +164,33 @@ func (s *Scenario) Compose(yaml string) *Scenario { return s } +// Files declares the project's files — compose.yaml plus whatever it needs +// (Dockerfile, .env, config files) — as a txtar archive: each file introduced +// by a `-- name --` line, extracted into the project directory. The archive +// must contain a compose.yaml, which becomes the scenario's compose file. +// txtar is the format Go's own cmd/go tests are written in: diff-friendly, +// and trivial to read and write for humans and coding agents alike. +func (s *Scenario) Files(archive string) *Scenario { + s.t.Helper() + dir := s.t.TempDir() + for _, f := range txtar.Parse([]byte(archive)).Files { + path := filepath.Join(dir, f.Name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + s.t.Fatalf("failed to create directory for %s: %v", f.Name, err) + } + if err := os.WriteFile(path, f.Data, 0o644); err != nil { + s.t.Fatalf("failed to write %s: %v", f.Name, err) + } + if f.Name == "compose.yaml" { + s.file = path + } + } + if s.file == "" { + s.t.Fatal("Files archive must contain a compose.yaml") + } + return s +} + // Env sets environment variables applied to every subsequent step command // (and interpolated in the compose model). func (s *Scenario) Env(kv ...string) *Scenario {