compose/pkg/e2e/compose_run_test.go
Nicolas De Loof 7af58e3626 test(e2e): gate the e2e suite behind the build tag the docs already promise
AGENTS.md documents 'Test unit: go test ./pkg/...' and 'E2E tests:
go test -tags e2e ./pkg/e2e/'. Both statements were false: no file in
pkg/e2e carried an e2e build constraint, so the -tags flag was a no-op
and the 'unit' command actually launched the full e2e suite — requiring
a Docker daemon and the locally built binary, creating and destroying
containers, for up to 20 minutes. CI only avoided this by grepping e2e
out of the package list (Dockerfile).

Every *_test.go in pkg/e2e now carries //go:build e2e (merged with the
existing !windows constraints), and the Makefile e2e targets pass the
tag. The documented commands become true:
- go test ./pkg/...          -> pkg/e2e reports [no test files]
- go test -tags e2e ./pkg/e2e -> runs the suite

AGENTS.md now also states the daemon/binary prerequisites explicitly.

Part of #14074 (A: the code misdescribes its own structure).

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-08-27 11:02:07 +02:00

212 lines
8.9 KiB
Go

//go:build e2e
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package e2e
import (
"strings"
"testing"
"time"
"gotest.tools/v3/icmd"
"gotest.tools/v3/poll"
)
func TestComposeRun(t *testing.T) {
s := NewScenario(t, "run must execute a one-off with the service command or an override, starting its dependencies")
s.Step("run executes the service's own command and starts its dependency",
ComposeCmd("run", "back"),
StdoutContains("Hello there!!"),
OutputNotContains("orphan"),
ServiceState("db", "running"),
OneOffState("back", "exited"),
ServiceNotCreated("front")).
Step("run with an override command warns about the previous one-off, now an orphan",
ComposeCmd("run", "back", "echo", "Hello one more time"),
StdoutContains("Hello one more time"),
OutputContains("orphan")).
Step("COMPOSE_IGNORE_ORPHANS silences the warning",
ComposeCmd("run", "back", "echo", "Hello again").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("Hello again"),
OutputNotContains("orphan")).
Step("run --rm leaves the earlier one-offs alone and removes its own container",
ComposeCmd("run", "--rm", "back", "echo", "Hello and gone").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("Hello and gone"),
OneOffsUntouched("back")).
Step("run --volumes bind-mounts the requested host path",
ComposeCmd("run", "--volumes", s.Dir()+":/foo", "back", "/bin/sh", "-c", "ls /foo").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("compose.yaml")).
Step("run --env-from-file injects the file's variables",
ComposeCmd("run", "--env-from-file", s.Dir()+"/run.env", "front", "env").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("FOO=BAR")).
Step("run --env injects the variable",
ComposeCmd("run", "--env", "FOO=BAR", "front", "env").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("FOO=BAR"))
}
func TestComposeRunPorts(t *testing.T) {
s := NewScenario(t, "run must only publish ports when asked: --publish for ad-hoc, --service-ports for the model's")
s.Step("run --publish maps the requested port, not the model's",
ComposeCmd("run", "--publish", "8081:80", "-d", "back", "/bin/sh", "-c", "sleep 30")).
Step("the ad-hoc mapping is live",
DockerCmd("ps", "--filter", "label=com.docker.compose.project="+s.Project()),
OutputContains("8081->80/tcp"),
OutputNotContains("8082->80/tcp")).
Step("run --service-ports maps the model's ports",
ComposeCmd("run", "--service-ports", "-d", "back", "/bin/sh", "-c", "sleep 30")).
Step("the model's mapping is live",
DockerCmd("ps", "--filter", "label=com.docker.compose.project="+s.Project()),
OutputContains("8082->80/tcp"))
}
func TestComposeRunDeps(t *testing.T) {
// Regression test for https://github.com/docker/compose/issues/9459
// run used to start other services of the project beyond the target's
// dependency chain.
NewScenario(t, "run must start the target's dependencies and nothing else, unless --no-deps").
Step("run starts the shared dependency but not the sibling service",
ComposeCmd("run", "service_a"),
OutputContains("shared_dep"),
OutputNotContains("service_b"),
ServiceNotCreated("service_b")).
Step("run --no-deps starts nothing but the one-off",
ComposeCmd("run", "--no-deps", "service_a").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
OutputNotContains("service_b"),
OutputNotContains("shared_dep"),
NotRecreated("shared_dep"),
ServiceNotCreated("service_b"))
}
func TestComposeRunNotRequiredDeps(t *testing.T) {
NewScenario(t, "run must skip a dependency marked required: false when its profile is inactive").
Step("run executes the service without materializing the optional dependency",
ComposeCmd("run", "foo"),
OutputContains("foo"),
ServiceNotCreated("bar"))
}
func TestComposeRunQuietPull(t *testing.T) {
NewScenario(t, "run --quiet-pull and COMPOSE_PROGRESS=quiet must silence pull progress at two levels").
Step("start without the image locally",
ComposeCmd("down", "--rmi", "all")).
Step("--quiet-pull keeps the decision but drops the layer progress",
ComposeCmd("run", "--quiet-pull", "backend"),
OutputNotContains("Pull complete"),
OutputContains("Pulled")).
Step("remove the image again",
ComposeCmd("down", "--rmi", "all")).
Step("COMPOSE_PROGRESS=quiet silences the pull entirely",
ComposeCmd("run", "backend").WithEnv("COMPOSE_PROGRESS=quiet"),
OutputNotContains("Pull complete"),
OutputNotContains("Pulled"))
}
func TestComposeRunPullAlways(t *testing.T) {
NewScenario(t, "run --pull always must pull the image even when present locally").
Step("run reports the pull it was asked to always perform",
ComposeCmd("run", "--pull", "always", "backend"),
OutputContains("Image nginx Pulling"),
OutputContains("Image nginx Pulled"))
}
func TestComposeRunBuild(t *testing.T) {
s := NewScenario(t, "run must build a service whose image comes from another service's build context")
s.Defer(
DockerCmd("image", "rm", "-f", s.Project()+"-build").MayFail(),
DockerCmd("image", "rm", "-f", s.Project()+"-build_base").MayFail()).
Step("run builds the chained images and executes the command",
ComposeCmd("run", "build", "echo", "hello world"),
StdoutContains("hello world"))
}
func TestComposeRunRmStopSignal(t *testing.T) {
c := NewParallelCLI(t)
projectName := "run-test"
t.Cleanup(func() {
c.cleanupWithDown(t, projectName)
})
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "-f", "./fixtures/ps-test/compose.yaml", "run", "--rm", "-d", "nginx")
res.Assert(t, icmd.Success)
res = c.RunDockerCmd(t, "ps", "--quiet", "--filter", "name=run-test-nginx")
containerID := strings.TrimSpace(res.Stdout())
res = c.RunDockerCmd(t, "stop", containerID)
res.Assert(t, icmd.Success)
// --rm auto-removal is async, wait for the container to be removed
poll.WaitOn(t, func(l poll.LogT) poll.Result {
res = c.RunDockerCmd(t, "ps", "--all", "--filter", "name=run-test-nginx", "--format", "'{{.Names}}'")
if strings.Contains(res.Stdout(), "run-test-nginx") {
return poll.Continue("container still present: %s", res.Stdout())
}
return poll.Success()
}, poll.WithTimeout(10*time.Second), poll.WithDelay(500*time.Millisecond))
}
func TestComposeRunPipedInput(t *testing.T) {
if composeStandaloneMode {
t.Skip("Skipping test compose with piped input detection in standalone mode")
}
c := NewParallelCLI(t)
defer c.cleanupWithDown(t, "run-piped-test")
t.Run("compose run with piped input detection", func(t *testing.T) {
// Test that piped input is properly detected and TTY is automatically disabled
// This tests the logic added in run.go that checks dockerCli.In().IsTerminal()
cmd := c.NewCmd("sh", "-c", "echo 'piped-content' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm piped-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{Out: "piped-content"})
res.Assert(t, icmd.Success)
})
t.Run("compose run piped input should not allocate TTY", func(t *testing.T) {
// Test that when stdin is piped, the container correctly detects no TTY
// This verifies that the automatic noTty=true setting works correctly
cmd := c.NewCmd("sh", "-c", "echo '' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm tty-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{Out: "No TTY detected"})
res.Assert(t, icmd.Success)
})
t.Run("compose run piped input with explicit --tty should fail", func(t *testing.T) {
// Test that explicitly requesting TTY with piped input fails with proper error message
// This should trigger the "input device is not a TTY" error
cmd := c.NewCmd("sh", "-c", "echo 'test' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm --tty piped-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{
ExitCode: 1,
Err: "cannot attach stdin to a TTY-enabled container because stdin is not a terminal",
})
})
t.Run("compose run piped input with --no-tty=false should fail", func(t *testing.T) {
// Test that explicitly disabling --no-tty (i.e., requesting TTY) with piped input fails
// This should also trigger the "input device is not a TTY" error
cmd := c.NewCmd("sh", "-c", "echo 'test' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm --no-tty=false piped-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{
ExitCode: 1,
Err: "cannot attach stdin to a TTY-enabled container because stdin is not a terminal",
})
})
}