tty_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package display
  14. import (
  15. "bytes"
  16. "context"
  17. "strings"
  18. "sync"
  19. "testing"
  20. "time"
  21. "unicode/utf8"
  22. "gotest.tools/v3/assert"
  23. "github.com/docker/compose/v5/pkg/api"
  24. )
  25. func newTestWriter() (*ttyWriter, *bytes.Buffer) {
  26. var buf bytes.Buffer
  27. w := &ttyWriter{
  28. out: &buf,
  29. info: &buf,
  30. tasks: map[string]*task{},
  31. done: make(chan bool),
  32. mtx: &sync.Mutex{},
  33. operation: "pull",
  34. }
  35. return w, &buf
  36. }
  37. func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) {
  38. t := &task{
  39. ID: id,
  40. parents: make(map[string]struct{}),
  41. startTime: time.Now(),
  42. text: text,
  43. details: details,
  44. status: status,
  45. spinner: NewSpinner(),
  46. }
  47. w.tasks[id] = t
  48. w.ids = append(w.ids, id)
  49. }
  50. // extractLines parses the output buffer and returns lines without ANSI control sequences
  51. func extractLines(buf *bytes.Buffer) []string {
  52. content := buf.String()
  53. // Split by newline
  54. rawLines := strings.Split(content, "\n")
  55. var lines []string
  56. for _, line := range rawLines {
  57. // Skip empty lines and lines that are just ANSI codes
  58. if lenAnsi(line) > 0 {
  59. lines = append(lines, line)
  60. }
  61. }
  62. return lines
  63. }
  64. func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) {
  65. testCases := []struct {
  66. name string
  67. taskID string
  68. status string
  69. details string
  70. terminalWidth int
  71. }{
  72. {
  73. name: "short task fits wide terminal",
  74. taskID: "Image foo",
  75. status: "Pulling",
  76. details: "layer abc123",
  77. terminalWidth: 100,
  78. },
  79. {
  80. name: "long details truncated to fit",
  81. taskID: "Image foo",
  82. status: "Pulling",
  83. details: "downloading layer sha256:abc123def456789xyz0123456789abcdef",
  84. terminalWidth: 50,
  85. },
  86. {
  87. name: "long taskID truncated to fit",
  88. taskID: "very-long-image-name-that-exceeds-terminal-width",
  89. status: "Pulling",
  90. details: "",
  91. terminalWidth: 40,
  92. },
  93. {
  94. name: "both long taskID and details",
  95. taskID: "my-very-long-service-name-here",
  96. status: "Downloading",
  97. details: "layer sha256:abc123def456789xyz0123456789",
  98. terminalWidth: 50,
  99. },
  100. {
  101. name: "narrow terminal",
  102. taskID: "service-name",
  103. status: "Pulling",
  104. details: "some details",
  105. terminalWidth: 35,
  106. },
  107. }
  108. for _, tc := range testCases {
  109. t.Run(tc.name, func(t *testing.T) {
  110. w, buf := newTestWriter()
  111. addTask(w, tc.taskID, tc.status, tc.details, api.Working)
  112. w.printWithDimensions(tc.terminalWidth, 24)
  113. lines := extractLines(buf)
  114. for i, line := range lines {
  115. lineLen := lenAnsi(line)
  116. assert.Assert(t, lineLen <= tc.terminalWidth,
  117. "line %d has length %d which exceeds terminal width %d: %q",
  118. i, lineLen, tc.terminalWidth, line)
  119. }
  120. })
  121. }
  122. }
  123. func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) {
  124. w, buf := newTestWriter()
  125. // Add multiple tasks with varying lengths
  126. addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working)
  127. addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working)
  128. addTask(w, "Image redis", "Pulled", "", api.Done)
  129. terminalWidth := 60
  130. w.printWithDimensions(terminalWidth, 24)
  131. lines := extractLines(buf)
  132. for i, line := range lines {
  133. lineLen := lenAnsi(line)
  134. assert.Assert(t, lineLen <= terminalWidth,
  135. "line %d has length %d which exceeds terminal width %d: %q",
  136. i, lineLen, terminalWidth, line)
  137. }
  138. }
  139. func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) {
  140. w, buf := newTestWriter()
  141. addTask(w, "Image nginx", "Pulling", "details", api.Working)
  142. terminalWidth := 30
  143. w.printWithDimensions(terminalWidth, 24)
  144. lines := extractLines(buf)
  145. for i, line := range lines {
  146. lineLen := lenAnsi(line)
  147. assert.Assert(t, lineLen <= terminalWidth,
  148. "line %d has length %d which exceeds terminal width %d: %q",
  149. i, lineLen, terminalWidth, line)
  150. }
  151. }
  152. func TestPrintWithDimensions_TaskWithProgress(t *testing.T) {
  153. w, buf := newTestWriter()
  154. // Create parent task
  155. parent := &task{
  156. ID: "Image nginx",
  157. parents: make(map[string]struct{}),
  158. startTime: time.Now(),
  159. text: "Pulling",
  160. status: api.Working,
  161. spinner: NewSpinner(),
  162. }
  163. w.tasks["Image nginx"] = parent
  164. w.ids = append(w.ids, "Image nginx")
  165. // Create child tasks to trigger progress display
  166. for i := range 3 {
  167. child := &task{
  168. ID: "layer" + string(rune('a'+i)),
  169. parents: map[string]struct{}{"Image nginx": {}},
  170. startTime: time.Now(),
  171. text: "Downloading",
  172. status: api.Working,
  173. total: 1000,
  174. current: 500,
  175. percent: 50,
  176. spinner: NewSpinner(),
  177. }
  178. w.tasks[child.ID] = child
  179. w.ids = append(w.ids, child.ID)
  180. }
  181. terminalWidth := 80
  182. w.printWithDimensions(terminalWidth, 24)
  183. lines := extractLines(buf)
  184. for i, line := range lines {
  185. lineLen := lenAnsi(line)
  186. assert.Assert(t, lineLen <= terminalWidth,
  187. "line %d has length %d which exceeds terminal width %d: %q",
  188. i, lineLen, terminalWidth, line)
  189. }
  190. }
  191. func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) {
  192. w := &ttyWriter{}
  193. lines := []lineData{
  194. {
  195. taskID: "Image foo",
  196. status: "Pulling",
  197. details: "downloading layer sha256:abc123def456789xyz",
  198. },
  199. }
  200. terminalWidth := 50
  201. timerLen := 5
  202. w.adjustLineWidth(lines, timerLen, terminalWidth)
  203. // Verify the line fits
  204. detailsLen := len(lines[0].details)
  205. if detailsLen > 0 {
  206. detailsLen++ // space before details
  207. }
  208. // widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26
  209. lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen
  210. assert.Assert(t, lineWidth <= terminalWidth,
  211. "line width %d should not exceed terminal width %d (taskID=%q, details=%q)",
  212. lineWidth, terminalWidth, lines[0].taskID, lines[0].details)
  213. // Verify details were truncated (not removed entirely)
  214. assert.Assert(t, lines[0].details != "", "details should be truncated, not removed")
  215. assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...")
  216. }
  217. func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) {
  218. w := &ttyWriter{}
  219. lines := []lineData{
  220. {
  221. taskID: "very-long-image-name-that-exceeds-minimum-length",
  222. status: "Pulling",
  223. details: "",
  224. },
  225. }
  226. terminalWidth := 40
  227. timerLen := 5
  228. w.adjustLineWidth(lines, timerLen, terminalWidth)
  229. lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen
  230. assert.Assert(t, lineWidth <= terminalWidth,
  231. "line width %d should not exceed terminal width %d (taskID=%q)",
  232. lineWidth, terminalWidth, lines[0].taskID)
  233. assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...")
  234. }
  235. func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) {
  236. w := &ttyWriter{}
  237. originalDetails := "short"
  238. originalTaskID := "Image foo"
  239. lines := []lineData{
  240. {
  241. taskID: originalTaskID,
  242. status: "Pulling",
  243. details: originalDetails,
  244. },
  245. }
  246. // Wide terminal, nothing should be truncated
  247. w.adjustLineWidth(lines, 5, 100)
  248. assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified")
  249. assert.Equal(t, originalDetails, lines[0].details, "details should not be modified")
  250. }
  251. func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) {
  252. w := &ttyWriter{}
  253. lines := []lineData{
  254. {
  255. taskID: "Image foo",
  256. status: "Pulling",
  257. details: "abc", // Very short, can't be meaningfully truncated
  258. },
  259. }
  260. // Terminal so narrow that even minimal details + "..." wouldn't help
  261. w.adjustLineWidth(lines, 5, 28)
  262. assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate")
  263. }
  264. // stripAnsi removes ANSI escape codes from a string
  265. func stripAnsi(s string) string {
  266. var result strings.Builder
  267. inAnsi := false
  268. for _, r := range s {
  269. if r == '\x1b' {
  270. inAnsi = true
  271. continue
  272. }
  273. if inAnsi {
  274. // ANSI sequences end with a letter (m, h, l, G, etc.)
  275. if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
  276. inAnsi = false
  277. }
  278. continue
  279. }
  280. result.WriteRune(r)
  281. }
  282. return result.String()
  283. }
  284. func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
  285. w, buf := newTestWriter()
  286. // Add a completed task with long ID
  287. completedTask := &task{
  288. ID: "Image docker.io/library/nginx-long-name",
  289. parents: make(map[string]struct{}),
  290. startTime: time.Now().Add(-2 * time.Second),
  291. endTime: time.Now(),
  292. text: "Pulled",
  293. status: api.Done,
  294. spinner: NewSpinner(),
  295. }
  296. completedTask.spinner.Stop()
  297. w.tasks[completedTask.ID] = completedTask
  298. w.ids = append(w.ids, completedTask.ID)
  299. // Add a pending task with long ID
  300. pendingTask := &task{
  301. ID: "Image docker.io/library/postgres-database",
  302. parents: make(map[string]struct{}),
  303. startTime: time.Now(),
  304. text: "Pulling",
  305. status: api.Working,
  306. spinner: NewSpinner(),
  307. }
  308. w.tasks[pendingTask.ID] = pendingTask
  309. w.ids = append(w.ids, pendingTask.ID)
  310. terminalWidth := 50
  311. w.printWithDimensions(terminalWidth, 24)
  312. // Strip all ANSI codes from output and split by newline
  313. stripped := stripAnsi(buf.String())
  314. lines := strings.Split(stripped, "\n")
  315. // Filter non-empty lines
  316. var nonEmptyLines []string
  317. for _, line := range lines {
  318. if strings.TrimSpace(line) != "" {
  319. nonEmptyLines = append(nonEmptyLines, line)
  320. }
  321. }
  322. // Expected output format (50 runes per task line)
  323. expected := `[+] pull 1/2
  324. ✔ Image docker.io/library/nginx-l... Pulled 2.0s
  325. ⠋ Image docker.io/library/postgre... Pulling 0.0s`
  326. expectedLines := strings.Split(expected, "\n")
  327. // Debug output
  328. t.Logf("Actual output:\n")
  329. for i, line := range nonEmptyLines {
  330. t.Logf(" line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line)
  331. }
  332. // Verify number of lines
  333. assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match")
  334. // Verify each line matches expected
  335. for i, line := range nonEmptyLines {
  336. if i < len(expectedLines) {
  337. assert.Equal(t, expectedLines[i], line,
  338. "line %d should match expected", i)
  339. }
  340. }
  341. // Verify task lines fit within terminal width (strict - no tolerance)
  342. for i, line := range nonEmptyLines {
  343. if i > 0 { // Skip header line
  344. runeCount := utf8.RuneCountInString(line)
  345. assert.Assert(t, runeCount <= terminalWidth,
  346. "line %d has %d runes which exceeds terminal width %d: %q",
  347. i, runeCount, terminalWidth, line)
  348. }
  349. }
  350. }
  351. func TestLenAnsi(t *testing.T) {
  352. testCases := []struct {
  353. input string
  354. expected int
  355. }{
  356. {"hello", 5},
  357. {"\x1b[32mhello\x1b[0m", 5},
  358. {"\x1b[1;32mgreen\x1b[0m text", 10},
  359. {"", 0},
  360. {"\x1b[0m", 0},
  361. }
  362. for _, tc := range testCases {
  363. t.Run(tc.input, func(t *testing.T) {
  364. result := lenAnsi(tc.input)
  365. assert.Equal(t, tc.expected, result)
  366. })
  367. }
  368. }
  369. func TestDoneDeadlockFix(t *testing.T) {
  370. w, _ := newTestWriter()
  371. addTask(w, "test-task", "Working", "details", api.Working)
  372. ctx, cancel := context.WithCancel(t.Context())
  373. defer cancel()
  374. w.Start(ctx, "test")
  375. done := make(chan bool)
  376. go func() {
  377. w.Done("test", true)
  378. done <- true
  379. }()
  380. select {
  381. case <-done:
  382. case <-time.After(5 * time.Second):
  383. t.Fatal("Deadlock detected: Done() did not complete within 5 seconds")
  384. }
  385. }