diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index ce019e77d0..afee5ff7da 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -495,40 +495,9 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C } detached, _ := cmd.Flags().GetBool("detach") - var ep api.EventProcessor - switch opts.Progress { - case "", display.ModeAuto: - switch { - case ansi == "never": - display.Mode = display.ModePlain - ep = display.Plain(dockerCli.Err()) - case dockerCli.Out().IsTerminal(): - ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached) - default: - ep = display.Plain(dockerCli.Err()) - } - case display.ModeTTY: - if ansi == "never" { - return fmt.Errorf("can't use --progress tty while ANSI support is disabled") - } - display.Mode = display.ModeTTY - ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached) - - case display.ModePlain: - if ansi == "always" { - return fmt.Errorf("can't use --progress plain while ANSI support is forced") - } - display.Mode = display.ModePlain - ep = display.Plain(dockerCli.Err()) - case display.ModeQuiet, "none": - display.Mode = display.ModeQuiet - ep = display.Quiet() - case display.ModeJSON: - display.Mode = display.ModeJSON - logrus.SetFormatter(&logrus.JSONFormatter{}) - ep = display.JSON(dockerCli.Err()) - default: - return fmt.Errorf("unsupported --progress value %q", opts.Progress) + ep, err := selectEventProcessor(dockerCli, opts.Progress, ansi, detached) + if err != nil { + return err } backendOptions.Add(compose.WithEventProcessor(ep)) @@ -666,6 +635,47 @@ func stdinfo(dockerCli command.Cli) io.Writer { return dockerCli.Err() } +// selectEventProcessor picks the EventProcessor for Compose progress rendering. +// +// In auto mode we probe Err() (not Out()) because the renderer writes to stderr; +// probing stdout would force plain mode whenever stdout is redirected (e.g. +// `docker compose up | tee log`) while stderr is still a terminal. +func selectEventProcessor(dockerCli command.Cli, progress, ansi string, detached bool) (api.EventProcessor, error) { + switch progress { + case "", display.ModeAuto: + switch { + case ansi == "never": + display.Mode = display.ModePlain + return display.Plain(dockerCli.Err()), nil + case dockerCli.Err().IsTerminal(): + return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil + default: + return display.Plain(dockerCli.Err()), nil + } + case display.ModeTTY: + if ansi == "never" { + return nil, fmt.Errorf("can't use --progress tty while ANSI support is disabled") + } + display.Mode = display.ModeTTY + return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil + case display.ModePlain: + if ansi == "always" { + return nil, fmt.Errorf("can't use --progress plain while ANSI support is forced") + } + display.Mode = display.ModePlain + return display.Plain(dockerCli.Err()), nil + case display.ModeQuiet, "none": + display.Mode = display.ModeQuiet + return display.Quiet(), nil + case display.ModeJSON: + display.Mode = display.ModeJSON + logrus.SetFormatter(&logrus.JSONFormatter{}) + return display.JSON(dockerCli.Err()), nil + default: + return nil, fmt.Errorf("unsupported --progress value %q", progress) + } +} + func setEnvWithDotEnv(opts ProjectOptions, dockerCli command.Cli) error { // Check if we're using a remote config (OCI or Git) // If so, skip env loading as remote loaders haven't been initialized yet diff --git a/cmd/compose/compose_progress_test.go b/cmd/compose/compose_progress_test.go new file mode 100644 index 0000000000..c94760ec66 --- /dev/null +++ b/cmd/compose/compose_progress_test.go @@ -0,0 +1,209 @@ +//go:build !windows + +/* + 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 compose + +import ( + "fmt" + "os" + "testing" + + "github.com/creack/pty" + "github.com/docker/cli/cli/streams" + "github.com/sirupsen/logrus" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/cmd/display" + "github.com/docker/compose/v5/pkg/mocks" +) + +// saveGlobalState snapshots package-level state that selectEventProcessor +// mutates (display.Mode and, in JSON mode, the logrus standard formatter) +// and restores it on test cleanup. +func saveGlobalState(t *testing.T) { + t.Helper() + originalMode := display.Mode + originalFormatter := logrus.StandardLogger().Formatter + t.Cleanup(func() { + display.Mode = originalMode + logrus.SetFormatter(originalFormatter) + }) +} + +// newStream returns a *streams.Out whose IsTerminal() matches tty. When tty is +// true it is backed by a pseudo-terminal slave; otherwise by an os.Pipe writer. +func newStream(t *testing.T, tty bool) *streams.Out { + t.Helper() + if tty { + ptmx, slave, err := pty.Open() + assert.NilError(t, err) + t.Cleanup(func() { + _ = ptmx.Close() + _ = slave.Close() + }) + return streams.NewOut(slave) + } + r, w, err := os.Pipe() + assert.NilError(t, err) + t.Cleanup(func() { + _ = r.Close() + _ = w.Close() + }) + return streams.NewOut(w) +} + +func newMockCli(t *testing.T, out, errStream *streams.Out) *mocks.MockCli { + t.Helper() + cli := mocks.NewMockCli(gomock.NewController(t)) + cli.EXPECT().Out().Return(out).AnyTimes() + cli.EXPECT().Err().Return(errStream).AnyTimes() + return cli +} + +// TestSelectEventProcessor_AutoMode covers the regression from docker/compose#13570: +// auto mode must probe Err() (not Out()) so `docker compose up | tee log` still +// renders the colorized UI on stderr. +func TestSelectEventProcessor_AutoMode(t *testing.T) { + tests := []struct { + name string + outIsTTY bool + errIsTTY bool + ansi string + wantType string + }{ + { + name: "stderr TTY, stdout piped -> Full", + errIsTTY: true, + ansi: "auto", + wantType: "*display.ttyWriter", + }, + { + name: "stderr piped, stdout TTY -> Plain (do not fall back to stdout)", + outIsTTY: true, + ansi: "auto", + wantType: "*display.plainWriter", + }, + { + name: "both TTY -> Full", + outIsTTY: true, + errIsTTY: true, + ansi: "auto", + wantType: "*display.ttyWriter", + }, + { + name: "both piped -> Plain", + ansi: "auto", + wantType: "*display.plainWriter", + }, + { + name: "ansi never forces Plain even when stderr is TTY", + outIsTTY: true, + errIsTTY: true, + ansi: "never", + wantType: "*display.plainWriter", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveGlobalState(t) + cli := newMockCli(t, newStream(t, tc.outIsTTY), newStream(t, tc.errIsTTY)) + + ep, err := selectEventProcessor(cli, "", tc.ansi, false) + assert.NilError(t, err) + assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType) + }) + } +} + +func TestSelectEventProcessor_ExplicitMode(t *testing.T) { + tests := []struct { + name string + progress string + ansi string + wantType string + wantErrText string + }{ + { + name: "progress=tty forces Full regardless of streams", + progress: display.ModeTTY, + ansi: "auto", + wantType: "*display.ttyWriter", + }, + { + name: "progress=tty with ansi=never is rejected", + progress: display.ModeTTY, + ansi: "never", + wantErrText: "can't use --progress tty while ANSI support is disabled", + }, + { + name: "progress=plain forces Plain", + progress: display.ModePlain, + ansi: "auto", + wantType: "*display.plainWriter", + }, + { + name: "progress=plain with ansi=always is rejected", + progress: display.ModePlain, + ansi: "always", + wantErrText: "can't use --progress plain while ANSI support is forced", + }, + { + name: "progress=quiet returns Quiet", + progress: display.ModeQuiet, + ansi: "auto", + wantType: "*display.quiet", + }, + { + name: `progress="none" aliases to Quiet`, + progress: "none", + ansi: "auto", + wantType: "*display.quiet", + }, + { + name: "progress=json returns JSON", + progress: display.ModeJSON, + ansi: "auto", + wantType: "*display.jsonWriter", + }, + { + name: "unknown progress value is rejected", + progress: "bogus", + ansi: "auto", + wantErrText: `unsupported --progress value "bogus"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saveGlobalState(t) + // Explicit modes don't probe IsTerminal; pipes are fine for both. + cli := newMockCli(t, newStream(t, false), newStream(t, false)) + + ep, err := selectEventProcessor(cli, tc.progress, tc.ansi, false) + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + assert.Assert(t, ep == nil) + return + } + assert.NilError(t, err) + assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType) + }) + } +}