| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- /*
- 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 display
- import (
- "bytes"
- "strings"
- "sync"
- "testing"
- "time"
- "unicode/utf8"
- "gotest.tools/v3/assert"
- "github.com/docker/compose/v5/pkg/api"
- )
- func newTestWriter() (*ttyWriter, *bytes.Buffer) {
- var buf bytes.Buffer
- w := &ttyWriter{
- out: &buf,
- info: &buf,
- tasks: map[string]*task{},
- done: make(chan bool),
- mtx: &sync.Mutex{},
- operation: "pull",
- }
- return w, &buf
- }
- func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) {
- t := &task{
- ID: id,
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: text,
- details: details,
- status: status,
- spinner: NewSpinner(),
- }
- w.tasks[id] = t
- w.ids = append(w.ids, id)
- }
- // extractLines parses the output buffer and returns lines without ANSI control sequences
- func extractLines(buf *bytes.Buffer) []string {
- content := buf.String()
- // Split by newline
- rawLines := strings.Split(content, "\n")
- var lines []string
- for _, line := range rawLines {
- // Skip empty lines and lines that are just ANSI codes
- if lenAnsi(line) > 0 {
- lines = append(lines, line)
- }
- }
- return lines
- }
- func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) {
- testCases := []struct {
- name string
- taskID string
- status string
- details string
- terminalWidth int
- }{
- {
- name: "short task fits wide terminal",
- taskID: "Image foo",
- status: "Pulling",
- details: "layer abc123",
- terminalWidth: 100,
- },
- {
- name: "long details truncated to fit",
- taskID: "Image foo",
- status: "Pulling",
- details: "downloading layer sha256:abc123def456789xyz0123456789abcdef",
- terminalWidth: 50,
- },
- {
- name: "long taskID truncated to fit",
- taskID: "very-long-image-name-that-exceeds-terminal-width",
- status: "Pulling",
- details: "",
- terminalWidth: 40,
- },
- {
- name: "both long taskID and details",
- taskID: "my-very-long-service-name-here",
- status: "Downloading",
- details: "layer sha256:abc123def456789xyz0123456789",
- terminalWidth: 50,
- },
- {
- name: "narrow terminal",
- taskID: "service-name",
- status: "Pulling",
- details: "some details",
- terminalWidth: 35,
- },
- }
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- w, buf := newTestWriter()
- addTask(w, tc.taskID, tc.status, tc.details, api.Working)
- w.printWithDimensions(tc.terminalWidth, 24)
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= tc.terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, tc.terminalWidth, line)
- }
- })
- }
- }
- func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) {
- w, buf := newTestWriter()
- // Add multiple tasks with varying lengths
- addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working)
- addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working)
- addTask(w, "Image redis", "Pulled", "", api.Done)
- terminalWidth := 60
- w.printWithDimensions(terminalWidth, 24)
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, terminalWidth, line)
- }
- }
- func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) {
- w, buf := newTestWriter()
- addTask(w, "Image nginx", "Pulling", "details", api.Working)
- terminalWidth := 30
- w.printWithDimensions(terminalWidth, 24)
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, terminalWidth, line)
- }
- }
- func TestPrintWithDimensions_TaskWithProgress(t *testing.T) {
- w, buf := newTestWriter()
- // Create parent task
- parent := &task{
- ID: "Image nginx",
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: "Pulling",
- status: api.Working,
- spinner: NewSpinner(),
- }
- w.tasks["Image nginx"] = parent
- w.ids = append(w.ids, "Image nginx")
- // Create child tasks to trigger progress display
- for i := 0; i < 3; i++ {
- child := &task{
- ID: "layer" + string(rune('a'+i)),
- parents: map[string]struct{}{"Image nginx": {}},
- startTime: time.Now(),
- text: "Downloading",
- status: api.Working,
- total: 1000,
- current: 500,
- percent: 50,
- spinner: NewSpinner(),
- }
- w.tasks[child.ID] = child
- w.ids = append(w.ids, child.ID)
- }
- terminalWidth := 80
- w.printWithDimensions(terminalWidth, 24)
- lines := extractLines(buf)
- for i, line := range lines {
- lineLen := lenAnsi(line)
- assert.Assert(t, lineLen <= terminalWidth,
- "line %d has length %d which exceeds terminal width %d: %q",
- i, lineLen, terminalWidth, line)
- }
- }
- func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) {
- w := &ttyWriter{}
- lines := []lineData{
- {
- taskID: "Image foo",
- status: "Pulling",
- details: "downloading layer sha256:abc123def456789xyz",
- },
- }
- terminalWidth := 50
- timerLen := 5
- w.adjustLineWidth(lines, timerLen, terminalWidth)
- // Verify the line fits
- detailsLen := len(lines[0].details)
- if detailsLen > 0 {
- detailsLen++ // space before details
- }
- // widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26
- lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen
- assert.Assert(t, lineWidth <= terminalWidth,
- "line width %d should not exceed terminal width %d (taskID=%q, details=%q)",
- lineWidth, terminalWidth, lines[0].taskID, lines[0].details)
- // Verify details were truncated (not removed entirely)
- assert.Assert(t, lines[0].details != "", "details should be truncated, not removed")
- assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...")
- }
- func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) {
- w := &ttyWriter{}
- lines := []lineData{
- {
- taskID: "very-long-image-name-that-exceeds-minimum-length",
- status: "Pulling",
- details: "",
- },
- }
- terminalWidth := 40
- timerLen := 5
- w.adjustLineWidth(lines, timerLen, terminalWidth)
- lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen
- assert.Assert(t, lineWidth <= terminalWidth,
- "line width %d should not exceed terminal width %d (taskID=%q)",
- lineWidth, terminalWidth, lines[0].taskID)
- assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...")
- }
- func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) {
- w := &ttyWriter{}
- originalDetails := "short"
- originalTaskID := "Image foo"
- lines := []lineData{
- {
- taskID: originalTaskID,
- status: "Pulling",
- details: originalDetails,
- },
- }
- // Wide terminal, nothing should be truncated
- w.adjustLineWidth(lines, 5, 100)
- assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified")
- assert.Equal(t, originalDetails, lines[0].details, "details should not be modified")
- }
- func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) {
- w := &ttyWriter{}
- lines := []lineData{
- {
- taskID: "Image foo",
- status: "Pulling",
- details: "abc", // Very short, can't be meaningfully truncated
- },
- }
- // Terminal so narrow that even minimal details + "..." wouldn't help
- w.adjustLineWidth(lines, 5, 28)
- assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate")
- }
- // stripAnsi removes ANSI escape codes from a string
- func stripAnsi(s string) string {
- var result strings.Builder
- inAnsi := false
- for _, r := range s {
- if r == '\x1b' {
- inAnsi = true
- continue
- }
- if inAnsi {
- // ANSI sequences end with a letter (m, h, l, G, etc.)
- if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
- inAnsi = false
- }
- continue
- }
- result.WriteRune(r)
- }
- return result.String()
- }
- func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
- w, buf := newTestWriter()
- // Add a completed task with long ID
- completedTask := &task{
- ID: "Image docker.io/library/nginx-long-name",
- parents: make(map[string]struct{}),
- startTime: time.Now().Add(-2 * time.Second),
- endTime: time.Now(),
- text: "Pulled",
- status: api.Done,
- spinner: NewSpinner(),
- }
- completedTask.spinner.Stop()
- w.tasks[completedTask.ID] = completedTask
- w.ids = append(w.ids, completedTask.ID)
- // Add a pending task with long ID
- pendingTask := &task{
- ID: "Image docker.io/library/postgres-database",
- parents: make(map[string]struct{}),
- startTime: time.Now(),
- text: "Pulling",
- status: api.Working,
- spinner: NewSpinner(),
- }
- w.tasks[pendingTask.ID] = pendingTask
- w.ids = append(w.ids, pendingTask.ID)
- terminalWidth := 50
- w.printWithDimensions(terminalWidth, 24)
- // Strip all ANSI codes from output and split by newline
- stripped := stripAnsi(buf.String())
- lines := strings.Split(stripped, "\n")
- // Filter non-empty lines
- var nonEmptyLines []string
- for _, line := range lines {
- if strings.TrimSpace(line) != "" {
- nonEmptyLines = append(nonEmptyLines, line)
- }
- }
- // Expected output format (50 runes per task line)
- expected := `[+] pull 1/2
- ✔ Image docker.io/library/nginx-l... Pulled 2.0s
- ⠋ Image docker.io/library/postgre... Pulling 0.0s`
- expectedLines := strings.Split(expected, "\n")
- // Debug output
- t.Logf("Actual output:\n")
- for i, line := range nonEmptyLines {
- t.Logf(" line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line)
- }
- // Verify number of lines
- assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match")
- // Verify each line matches expected
- for i, line := range nonEmptyLines {
- if i < len(expectedLines) {
- assert.Equal(t, expectedLines[i], line,
- "line %d should match expected", i)
- }
- }
- // Verify task lines fit within terminal width (strict - no tolerance)
- for i, line := range nonEmptyLines {
- if i > 0 { // Skip header line
- runeCount := utf8.RuneCountInString(line)
- assert.Assert(t, runeCount <= terminalWidth,
- "line %d has %d runes which exceeds terminal width %d: %q",
- i, runeCount, terminalWidth, line)
- }
- }
- }
- func TestLenAnsi(t *testing.T) {
- testCases := []struct {
- input string
- expected int
- }{
- {"hello", 5},
- {"\x1b[32mhello\x1b[0m", 5},
- {"\x1b[1;32mgreen\x1b[0m text", 10},
- {"", 0},
- {"\x1b[0m", 0},
- }
- for _, tc := range testCases {
- t.Run(tc.input, func(t *testing.T) {
- result := lenAnsi(tc.input)
- assert.Equal(t, tc.expected, result)
- })
- }
- }
|