test(e2e): Files() declares multi-file projects as a txtar archive

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 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-18 11:18:22 +02:00
co-authored by Claude Fable 5
parent a9c0af4bab
commit 0ac224d9d9
4 changed files with 77 additions and 1 deletions
+1
View File
@@ -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
+17 -1
View File
@@ -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.
+27
View File
@@ -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"))
}
+32
View File
@@ -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 {