Repository: docker/compose (go)

Issue:
docker compose ps command does not show all exposed ports
<!--
If you are reporting a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.

If you suspect your issue is a bug, please edit your issue description to
include the BUG REPORT INFORMATION shown below. If you fail to provide this
information within 7 days, we cannot debug your issue and will close it. We
will, however, reopen it if you later provide the information.

For more information about reporting issues, see
https://github.com/docker/compose-cli/blob/master/CONTRIBUTING.md#reporting-other-issues

---------------------------------------------------
GENERAL SUPPORT INFORMATION
---------------------------------------------------

The GitHub issue tracker is for bug reports and feature requests.
General support can be found at the following locations:

- Docker Support Forums - https://forums.docker.com
- Docker Community Slack - https://dockr.ly/slack
- Post a question on StackOverflow, using the Docker tag

---------------------------------------------------
BUG REPORT INFORMATION
---------------------------------------------------
Use the commands below to provide key information from your environment:
You do NOT have to include this information if this is a FEATURE REQUEST
-->

**Description**

When running `docker compose ps`, the command only shows one port, even when multiple ports are exposed.

**Steps to reproduce the issue:**

1. Create the following compose.yaml file:

    ```yaml
    services:
        web:
            image: nginx:latest
            expose:
                - '80'
                - '443'
                - '8080'
    ```

2. Run `docker compose up -d`.

3. Run `docker compose ps`.

**Describe the results you received:**

Actual result of the command:

```
NAME              COMMAND                  SERVICE             STATUS              PORTS
docker-web-1      "/docker-entrypoint.…"   web                 running             8080/tcp
```

**Describe the results you expected:**

Expected result of the command:

```
NAME              COMMAND                  SERVICE             STATUS              PORTS
docker-web-1      "/docker-entrypoint.…"   web                 running             80/tcp, 443/tcp, 8080/tcp
```

**Additional information you deem important (e.g. issue happens only occasionally):**

Strangely, the command `docker compose ps --format json` displays all exposed ports:

```
[{"ID":"16ba007b938a15d30b76e1cac670cd6eee68c7290032db481e1aaeb84e7dda34","Name":"docker-web-1","Command":"/docker-entrypoint.sh nginx -g 'daemon off;'","Project":"docker","Service":"web","State":"running","Health":"","ExitCode":0,"Publishers":[{"URL":"","TargetPort":80,"PublishedPort":0,"Protocol":"tcp"},{"URL":"","TargetPort":443,"PublishedPort":0,"Protocol":"tcp"},{"URL":"","TargetPort":8080,"PublishedPort":0,"Protocol":"tcp"}]}]
```

So the issue only exists with the `pretty` format.

**Output of `docker compose version`:**

```
Docker Compose version v2.4.1
```

**Output of `docker info`:**

```
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  app: Docker App (Docker Inc., v0.9.1-beta3)
  buildx: Docker Buildx (Docker Inc., v0.8.2-docker)
  compose: Docker Compose (Docker Inc., v2.4.1)
  scan: Docker Scan (Docker Inc., v0.17.0)

Server:
 Containers: 12
  Running: 11
  Paused: 0
  Stopped: 1
 Images: 33
 Server Version: 20.10.16
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: cgroupfs
 Cgroup Version: 1
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runtime.v1.linux runc io.containerd.runc.v2
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 212e8b6fa2f44b9c21b2798135fc6fb7c53efc16
 runc version: v1.1.1-0-g52de29d
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: default
 Kernel Version: 5.13.0-44-generic
 Operating System: Ubuntu 20.04.4 LTS
 OSType: linux
 Architecture: x86_64
 CPUs: 4
 Total Memory: 31GiB
 Name: donatello
 ID: LHD4:KEEC:7CN4:AVK6:HGK5:A7EP:A6YQ:G23F:F7UA:GOAP:MLTM:Q62W
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Registry: https://index.docker.io/v1/
 Labels:
 Experimental: false
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
```

**Additional environment details:**

Ubuntu 20.04.

Pull request description:
Fix ps ports output; includes unit test
**What I did**

Change `ps` to use `DisplayablePorts` from docker/cli

- mocks: create mocks for compose api.Service
- ps: use DisplayablePorts from docker/cli

**Related issue**

Fixes #9527.

**(not mandatory) A picture of a cute animal, if possible in relation with what you did**
![Photo by Pixabay from Pexels](https://images.pexels.com/photos/33149/lemur-ring-tailed-lemur-primate-mammal.jpg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1)

Patch:
diff --git a/Makefile b/Makefile
index 836f3b178..0b36672c6 100644
--- a/Makefile
+++ b/Makefile
@@ -55,6 +55,7 @@ e2e-compose-standalone: ## Run End to end local tests in standalone mode. Set E2
 mocks:
 	mockgen -destination pkg/mocks/mock_docker_cli.go -package mocks github.com/docker/cli/cli/command Cli
 	mockgen -destination pkg/mocks/mock_docker_api.go -package mocks github.com/docker/docker/client APIClient
+	mockgen -destination pkg/mocks/mock_docker_compose_api.go -package mocks -source=./pkg/api/api.go Service
 
 .PHONY: e2e
 e2e: e2e-compose e2e-compose-standalone ## Run end to end local tests in both modes. Set E2E_TEST=TestName to run a single test
diff --git a/cmd/compose/ps.go b/cmd/compose/ps.go
index c395241ad..9eed19145 100644
--- a/cmd/compose/ps.go
+++ b/cmd/compose/ps.go
@@ -27,6 +27,7 @@ import (
 
 	"github.com/docker/compose/v2/cmd/formatter"
 	"github.com/docker/compose/v2/pkg/utils"
+	"github.com/docker/docker/api/types"
 
 	formatter2 "github.com/docker/cli/cli/command/formatter"
 	"github.com/pkg/errors"
@@ -146,7 +147,7 @@ SERVICES:
 func writter(containers []api.ContainerSummary) func(w io.Writer) {
 	return func(w io.Writer) {
 		for _, container := range containers {
-			ports := DisplayablePorts(container)
+			ports := displayablePorts(container)
 			status := container.State
 			if status == "running" && container.Health != "" {
 				status = fmt.Sprintf("%s (%s)", container.State, container.Health)
@@ -178,72 +179,20 @@ func hasStatus(c api.ContainerSummary, statuses []string) bool {
 	return false
 }
 
-type portRange struct {
-	pStart   int
-	pEnd     int
-	tStart   int
-	tEnd     int
-	IP       string
-	protocol string
-}
-
-func (pr portRange) String() string {
-	var (
-		pub string
-		tgt string
-	)
-
-	if pr.pEnd > pr.pStart {
-		pub = fmt.Sprintf("%s:%d-%d->", pr.IP, pr.pStart, pr.pEnd)
-	} else if pr.pStart > 0 {
-		pub = fmt.Sprintf("%s:%d->", pr.IP, pr.pStart)
-	}
-	if pr.tEnd > pr.tStart {
-		tgt = fmt.Sprintf("%d-%d", pr.tStart, pr.tEnd)
-	} else {
-		tgt = fmt.Sprintf("%d", pr.tStart)
-	}
-	return fmt.Sprintf("%s%s/%s", pub, tgt, pr.protocol)
-}
-
-// DisplayablePorts is copy pasted from https://github.com/docker/cli/pull/581/files
-func DisplayablePorts(c api.ContainerSummary) string {
+func displayablePorts(c api.ContainerSummary) string {
 	if c.Publishers == nil {
 		return ""
 	}
 
-	sort.Sort(c.Publishers)
-
-	pr := portRange{}
-	ports := []string{}
-	for _, p := range c.Publishers {
-		prIsRange := pr.tEnd != pr.tStart
-		tOverlaps := p.TargetPort <= pr.tEnd
-
-		// Start a new port-range if:
-		// - the protocol is different from the current port-range
-		// - published or target port are not consecutive to the current port-range
-		// - the current port-range is a _range_, and the target port overlaps with the current range's target-ports
-		if p.Protocol != pr.protocol || p.URL != pr.IP || p.PublishedPort-pr.pEnd > 1 || p.TargetPort-pr.tEnd > 1 || prIsRange && tOverlaps {
-			// start a new port-range, and print the previous port-range (if any)
-			if pr.pStart > 0 {
-				ports = append(ports, pr.String())
-			}
-			pr = portRange{
-				pStart:   p.PublishedPort,
-				pEnd:     p.PublishedPort,
-				tStart:   p.TargetPort,
-				tEnd:     p.TargetPort,
-				protocol: p.Protocol,
-				IP:       p.URL,
-			}
-			continue
+	ports := make([]types.Port, len(c.Publishers))
+	for i, pub := range c.Publishers {
+		ports[i] = types.Port{
+			IP:          pub.URL,
+			PrivatePort: uint16(pub.TargetPort),
+			PublicPort:  uint16(pub.PublishedPort),
+			Type:        pub.Protocol,
 		}
-		pr.pEnd = p.PublishedPort
-		pr.tEnd = p.TargetPort
-	}
-	if pr.tStart > 0 {
-		ports = append(ports, pr.String())
 	}
-	return strings.Join(ports, ", ")
+
+	return formatter2.DisplayablePorts(ports)
 }
diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go
new file mode 100644
index 000000000..c33c1140b
--- /dev/null
+++ b/pkg/mocks/mock_docker_compose_api.go
@@ -0,0 +1,441 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./pkg/api/api.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	context "context"
+	reflect "reflect"
+
+	types "github.com/compose-spec/compose-go/types"
+	api "github.com/docker/compose/v2/pkg/api"
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockService is a mock of Service interface.
+type MockService struct {
+	ctrl     *gomock.Controller
+	recorder *MockServiceMockRecorder
+}
+
+// MockServiceMockRecorder is the mock recorder for MockService.
+type MockServiceMockRecorder struct {
+	mock *MockService
+}
+
+// NewMockService creates a new mock instance.
+func NewMockService(ctrl *gomock.Controller) *MockService {
+	mock := &MockService{ctrl: ctrl}
+	mock.recorder = &MockServiceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockService) EXPECT() *MockServiceMockRecorder {
+	return m.recorder
+}
+
+// Build mocks base method.
+func (m *MockService) Build(ctx context.Context, project *types.Project, options api.BuildOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Build", ctx, project, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Build indicates an expected call of Build.
+func (mr *MockServiceMockRecorder) Build(ctx, project, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockService)(nil).Build), ctx, project, options)
+}
+
+// Convert mocks base method.
+func (m *MockService) Convert(ctx context.Context, project *types.Project, options api.ConvertOptions) ([]byte, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Convert", ctx, project, options)
+	ret0, _ := ret[0].([]byte)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Convert indicates an expected call of Convert.
+func (mr *MockServiceMockRecorder) Convert(ctx, project, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Convert", reflect.TypeOf((*MockService)(nil).Convert), ctx, project, options)
+}
+
+// Copy mocks base method.
+func (m *MockService) Copy(ctx context.Context, projectName string, options api.CopyOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Copy", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Copy indicates an expected call of Copy.
+func (mr *MockServiceMockRecorder) Copy(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Copy", reflect.TypeOf((*MockService)(nil).Copy), ctx, projectName, options)
+}
+
+// Create mocks base method.
+func (m *MockService) Create(ctx context.Context, project *types.Project, options api.CreateOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Create", ctx, project, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockServiceMockRecorder) Create(ctx, project, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockService)(nil).Create), ctx, project, options)
+}
+
+// Down mocks base method.
+func (m *MockService) Down(ctx context.Context, projectName string, options api.DownOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Down", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Down indicates an expected call of Down.
+func (mr *MockServiceMockRecorder) Down(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Down", reflect.TypeOf((*MockService)(nil).Down), ctx, projectName, options)
+}
+
+// Events mocks base method.
+func (m *MockService) Events(ctx context.Context, projectName string, options api.EventsOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Events", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Events indicates an expected call of Events.
+func (mr *MockServiceMockRecorder) Events(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Events", reflect.TypeOf((*MockService)(nil).Events), ctx, projectName, options)
+}
+
+// Exec mocks base method.
+func (m *MockService) Exec(ctx context.Context, projectName string, options api.RunOptions) (int, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Exec", ctx, projectName, options)
+	ret0, _ := ret[0].(int)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Exec indicates an expected call of Exec.
+func (mr *MockServiceMockRecorder) Exec(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockService)(nil).Exec), ctx, projectName, options)
+}
+
+// Images mocks base method.
+func (m *MockService) Images(ctx context.Context, projectName string, options api.ImagesOptions) ([]api.ImageSummary, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Images", ctx, projectName, options)
+	ret0, _ := ret[0].([]api.ImageSummary)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Images indicates an expected call of Images.
+func (mr *MockServiceMockRecorder) Images(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Images", reflect.TypeOf((*MockService)(nil).Images), ctx, projectName, options)
+}
+
+// Kill mocks base method.
+func (m *MockService) Kill(ctx context.Context, projectName string, options api.KillOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Kill", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Kill indicates an expected call of Kill.
+func (mr *MockServiceMockRecorder) Kill(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockService)(nil).Kill), ctx, projectName, options)
+}
+
+// List mocks base method.
+func (m *MockService) List(ctx context.Context, options api.ListOptions) ([]api.Stack, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "List", ctx, options)
+	ret0, _ := ret[0].([]api.Stack)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// List indicates an expected call of List.
+func (mr *MockServiceMockRecorder) List(ctx, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockService)(nil).List), ctx, options)
+}
+
+// Logs mocks base method.
+func (m *MockService) Logs(ctx context.Context, projectName string, consumer api.LogConsumer, options api.LogOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Logs", ctx, projectName, consumer, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Logs indicates an expected call of Logs.
+func (mr *MockServiceMockRecorder) Logs(ctx, projectName, consumer, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logs", reflect.TypeOf((*MockService)(nil).Logs), ctx, projectName, consumer, options)
+}
+
+// Pause mocks base method.
+func (m *MockService) Pause(ctx context.Context, projectName string, options api.PauseOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Pause", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Pause indicates an expected call of Pause.
+func (mr *MockServiceMockRecorder) Pause(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pause", reflect.TypeOf((*MockService)(nil).Pause), ctx, projectName, options)
+}
+
+// Port mocks base method.
+func (m *MockService) Port(ctx context.Context, projectName, service string, port int, options api.PortOptions) (string, int, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Port", ctx, projectName, service, port, options)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(int)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// Port indicates an expected call of Port.
+func (mr *MockServiceMockRecorder) Port(ctx, projectName, service, port, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Port", reflect.TypeOf((*MockService)(nil).Port), ctx, projectName, service, port, options)
+}
+
+// Ps mocks base method.
+func (m *MockService) Ps(ctx context.Context, projectName string, options api.PsOptions) ([]api.ContainerSummary, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Ps", ctx, projectName, options)
+	ret0, _ := ret[0].([]api.ContainerSummary)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Ps indicates an expected call of Ps.
+func (mr *MockServiceMockRecorder) Ps(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ps", reflect.TypeOf((*MockService)(nil).Ps), ctx, projectName, options)
+}
+
+// Pull mocks base method.
+func (m *MockService) Pull(ctx context.Context, project *types.Project, options api.PullOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Pull", ctx, project, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Pull indicates an expected call of Pull.
+func (mr *MockServiceMockRecorder) Pull(ctx, project, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pull", reflect.TypeOf((*MockService)(nil).Pull), ctx, project, options)
+}
+
+// Push mocks base method.
+func (m *MockService) Push(ctx context.Context, project *types.Project, options api.PushOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Push", ctx, project, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Push indicates an expected call of Push.
+func (mr *MockServiceMockRecorder) Push(ctx, project, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockService)(nil).Push), ctx, project, options)
+}
+
+// Remove mocks base method.
+func (m *MockService) Remove(ctx context.Context, projectName string, options api.RemoveOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Remove", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Remove indicates an expected call of Remove.
+func (mr *MockServiceMockRecorder) Remove(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockService)(nil).Remove), ctx, projectName, options)
+}
+
+// Restart mocks base method.
+func (m *MockService) Restart(ctx context.Context, projectName string, options api.RestartOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Restart", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Restart indicates an expected call of Restart.
+func (mr *MockServiceMockRecorder) Restart(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Restart", reflect.TypeOf((*MockService)(nil).Restart), ctx, projectName, options)
+}
+
+// RunOneOffContainer mocks base method.
+func (m *MockService) RunOneOffContainer(ctx context.Context, project *types.Project, opts api.RunOptions) (int, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "RunOneOffContainer", ctx, project, opts)
+	ret0, _ := ret[0].(int)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// RunOneOffContainer indicates an expected call of RunOneOffContainer.
+func (mr *MockServiceMockRecorder) RunOneOffContainer(ctx, project, opts interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunOneOffContainer", reflect.TypeOf((*MockService)(nil).RunOneOffContainer), ctx, project, opts)
+}
+
+// Start mocks base method.
+func (m *MockService) Start(ctx context.Context, projectName string, options api.StartOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Start", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Start indicates an expected call of Start.
+func (mr *MockServiceMockRecorder) Start(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockService)(nil).Start), ctx, projectName, options)
+}
+
+// Stop mocks base method.
+func (m *MockService) Stop(ctx context.Context, projectName string, options api.StopOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Stop", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Stop indicates an expected call of Stop.
+func (mr *MockServiceMockRecorder) Stop(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockService)(nil).Stop), ctx, projectName, options)
+}
+
+// Top mocks base method.
+func (m *MockService) Top(ctx context.Context, projectName string, services []string) ([]api.ContainerProcSummary, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Top", ctx, projectName, services)
+	ret0, _ := ret[0].([]api.ContainerProcSummary)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Top indicates an expected call of Top.
+func (mr *MockServiceMockRecorder) Top(ctx, projectName, services interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Top", reflect.TypeOf((*MockService)(nil).Top), ctx, projectName, services)
+}
+
+// UnPause mocks base method.
+func (m *MockService) UnPause(ctx context.Context, projectName string, options api.PauseOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UnPause", ctx, projectName, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UnPause indicates an expected call of UnPause.
+func (mr *MockServiceMockRecorder) UnPause(ctx, projectName, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnPause", reflect.TypeOf((*MockService)(nil).UnPause), ctx, projectName, options)
+}
+
+// Up mocks base method.
+func (m *MockService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Up", ctx, project, options)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Up indicates an expected call of Up.
+func (mr *MockServiceMockRecorder) Up(ctx, project, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockService)(nil).Up), ctx, project, options)
+}
+
+// MockLogConsumer is a mock of LogConsumer interface.
+type MockLogConsumer struct {
+	ctrl     *gomock.Controller
+	recorder *MockLogConsumerMockRecorder
+}
+
+// MockLogConsumerMockRecorder is the mock recorder for MockLogConsumer.
+type MockLogConsumerMockRecorder struct {
+	mock *MockLogConsumer
+}
+
+// NewMockLogConsumer creates a new mock instance.
+func NewMockLogConsumer(ctrl *gomock.Controller) *MockLogConsumer {
+	mock := &MockLogConsumer{ctrl: ctrl}
+	mock.recorder = &MockLogConsumerMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockLogConsumer) EXPECT() *MockLogConsumerMockRecorder {
+	return m.recorder
+}
+
+// Log mocks base method.
+func (m *MockLogConsumer) Log(service, container, message string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Log", service, container, message)
+}
+
+// Log indicates an expected call of Log.
+func (mr *MockLogConsumerMockRecorder) Log(service, container, message interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Log", reflect.TypeOf((*MockLogConsumer)(nil).Log), service, container, message)
+}
+
+// Register mocks base method.
+func (m *MockLogConsumer) Register(container string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Register", container)
+}
+
+// Register indicates an expected call of Register.
+func (mr *MockLogConsumerMockRecorder) Register(container interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Register", reflect.TypeOf((*MockLogConsumer)(nil).Register), container)
+}
+
+// Status mocks base method.
+func (m *MockLogConsumer) Status(container, msg string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Status", container, msg)
+}
+
+// Status indicates an expected call of Status.
+func (mr *MockLogConsumerMockRecorder) Status(container, msg interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockLogConsumer)(nil).Status), container, msg)
+}


Test patch:
diff --git a/cmd/compose/ps_test.go b/cmd/compose/ps_test.go
new file mode 100644
index 000000000..2a6d6bb6a
--- /dev/null
+++ b/cmd/compose/ps_test.go
@@ -0,0 +1,84 @@
+/*
+   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 (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/docker/compose/v2/pkg/mocks"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPsPretty(t *testing.T) {
+	ctx := context.Background()
+	origStdout := os.Stdout
+	t.Cleanup(func() {
+		os.Stdout = origStdout
+	})
+	dir := t.TempDir()
+	f, err := os.Create(filepath.Join(dir, "output.txt"))
+	if err != nil {
+		t.Fatal("could not create output file")
+	}
+	defer func() { _ = f.Close() }()
+
+	os.Stdout = f
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	backend := mocks.NewMockService(ctrl)
+	backend.EXPECT().
+		Ps(gomock.Eq(ctx), gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, projectName string, options api.PsOptions) ([]api.ContainerSummary, error) {
+			return []api.ContainerSummary{
+				{
+					ID:   "abc123",
+					Name: "ABC",
+					Publishers: api.PortPublishers{
+						{
+							TargetPort:    8080,
+							PublishedPort: 8080,
+							Protocol:      "tcp",
+						},
+						{
+							TargetPort:    8443,
+							PublishedPort: 8443,
+							Protocol:      "tcp",
+						},
+					},
+				},
+			}, nil
+		}).AnyTimes()
+
+	opts := psOptions{projectOptions: &projectOptions{ProjectName: "test"}}
+	err = runPs(ctx, backend, nil, opts)
+	assert.NoError(t, err)
+
+	_, err = f.Seek(0, 0)
+	assert.NoError(t, err)
+
+	output := make([]byte, 256)
+	_, err = f.Read(output)
+	assert.NoError(t, err)
+
+	assert.Contains(t, string(output), "8080/tcp, 8443/tcp")
+}