mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
The TTY renderer accumulated display-corruption fixes (truncation of details, then progress sizes, then task ids; timer alignment; rune-based measurement) that each patched one symptom of the same structural gap: nothing guaranteed a rendered line fits the terminal, and once a line wraps, cursor arithmetic desyncs and the block corrupts. Replace it with three separable units: - tty_model.go: pure event reducer with an injected clock, preserving first-parent-wins updates, monotonic progress and header counters - tty_layout.go: pure (model, size, now) -> lines function; all widths are measured in terminal cells (go-runewidth, so CJK is correct) on plain text before coloring, and every line is clipped to the terminal width by construction, status text included - tty_screen.go: diff-based repaint; unchanged rows are skipped, identical frames write nothing, a frame is a single Write; a shrinking terminal abandons the block instead of moving the cursor over reflowed rows The writer coordinates them behind a mutex and stops the refresh goroutine through context cancellation, so Done cannot block when the operation context was cancelled first (Ctrl-C during pull). The spinner frame is derived from the clock instead of advancing on every call, and truncation can no longer split multi-byte runes. Visual output is unchanged: the snapshot test reproduces the previous renderer's golden output character for character. Co-Authored-By: Claude Fable 5 <[email protected]> Signed-off-by: Nicolas De Loof <[email protected]>
267 lines
8.5 KiB
Go
267 lines
8.5 KiB
Go
/*
|
|
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"
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
"gotest.tools/v3/assert"
|
|
|
|
"github.com/docker/compose/v5/pkg/api"
|
|
)
|
|
|
|
// 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()
|
|
}
|
|
|
|
// testClock returns a controllable clock starting at a fixed instant.
|
|
func testClock() (func() time.Time, *time.Time) {
|
|
now := time.Unix(1_700_000_000, 0)
|
|
return func() time.Time { return now }, &now
|
|
}
|
|
|
|
func newTermWriter(width, height int) (*termWriter, *bytes.Buffer, *time.Time) {
|
|
var buf bytes.Buffer
|
|
clock, now := testClock()
|
|
w := &termWriter{
|
|
out: &buf,
|
|
info: &buf,
|
|
tree: newTaskTree(),
|
|
scr: screen{out: &buf},
|
|
size: func() (int, int) { return width, height },
|
|
now: clock,
|
|
operation: "pull",
|
|
}
|
|
return w, &buf, now
|
|
}
|
|
|
|
// feed applies events at the writer's current clock.
|
|
func feed(w *termWriter, events ...api.Resource) {
|
|
for _, e := range events {
|
|
w.tree.apply(e, w.now())
|
|
}
|
|
}
|
|
|
|
// visualLines strips ANSI sequences from the painted frame and returns the
|
|
// visible rows.
|
|
func visualLines(buf *bytes.Buffer) []string {
|
|
var lines []string
|
|
for _, l := range strings.Split(stripAnsi(buf.String()), "\n") {
|
|
if strings.TrimSpace(l) != "" {
|
|
lines = append(lines, l)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// adversarialEvents exercises every historical overflow trigger at once:
|
|
// unbounded status text (skippedEvent), CJK ids, long error details, layers
|
|
// with and without totals.
|
|
func adversarialEvents() []api.Resource {
|
|
events := []api.Resource{
|
|
{ID: "app", Status: api.Warning, Text: "Skipped: current commandline does not match manifest, and image has no local build metadata"},
|
|
{ID: "Image 测试测试测试测试-with-a-very-long-tag:v1.2.3-alpha.4", Status: api.Working, Text: "Pulling"},
|
|
{ID: "db", Status: api.Error, Text: "Error", Details: "réseau « frontend » introuvable — vérifiez la configuration réseau du projet et les alias déclarés"},
|
|
}
|
|
for i := range 20 {
|
|
events = append(events, api.Resource{
|
|
ID: fmt.Sprintf("layer-%02d", i),
|
|
ParentID: "Image 测试测试测试测试-with-a-very-long-tag:v1.2.3-alpha.4",
|
|
Status: api.Working,
|
|
Text: "Downloading",
|
|
Current: int64(i) * 1_000_000,
|
|
Total: 50_000_000,
|
|
Percent: i * 5,
|
|
})
|
|
}
|
|
return events
|
|
}
|
|
|
|
func TestTerm_NoLineEverExceedsTerminalWidth(t *testing.T) {
|
|
for _, width := range []int{20, 30, 40, 60, 80, 120} {
|
|
t.Run(fmt.Sprintf("width=%d", width), func(t *testing.T) {
|
|
w, buf, _ := newTermWriter(width, 40)
|
|
feed(w, adversarialEvents()...)
|
|
w.repaint()
|
|
|
|
for i, line := range visualLines(buf) {
|
|
got := runewidth.StringWidth(strings.TrimRight(line, " "))
|
|
assert.Assert(t, got <= width,
|
|
"line %d is %d cells wide (> %d): %q", i, got, width, line)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerm_TruncationPreservesUTF8(t *testing.T) {
|
|
w, buf, _ := newTermWriter(40, 24)
|
|
feed(w, adversarialEvents()...)
|
|
w.repaint()
|
|
|
|
for i, line := range visualLines(buf) {
|
|
assert.Assert(t, utf8.ValidString(line),
|
|
"line %d is invalid UTF-8 after truncation: %q", i, line)
|
|
}
|
|
}
|
|
|
|
// Done must never block, even when the operation context was cancelled
|
|
// (Ctrl-C) before Done runs — the historical unbuffered-channel handshake
|
|
// deadlocked in that ordering.
|
|
func TestTerm_DoneReturnsAfterContextCancel(t *testing.T) {
|
|
w, _, _ := newTermWriter(80, 24)
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
w.Start(ctx, "pull")
|
|
w.On(api.Resource{ID: "Image foo", Text: "Pulling", Status: api.Working})
|
|
cancel()
|
|
time.Sleep(20 * time.Millisecond) // let the refresh goroutine exit
|
|
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
w.Done("pull", true)
|
|
close(finished)
|
|
}()
|
|
select {
|
|
case <-finished:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Done() blocked after context cancellation")
|
|
}
|
|
}
|
|
|
|
// The spinner animation must be a function of time, not of how often the
|
|
// layout runs.
|
|
func TestTerm_SpinnerIsTimeBased(t *testing.T) {
|
|
clock, now := testClock()
|
|
n := &node{status: api.Working, startedAt: clock()}
|
|
|
|
a := spinGlyph(n, *now)
|
|
b := spinGlyph(n, *now)
|
|
assert.Equal(t, a.text, b.text, "same instant must yield the same frame")
|
|
|
|
*now = now.Add(tickInterval)
|
|
c := spinGlyph(n, *now)
|
|
assert.Assert(t, a.text != c.text, "advancing the clock must advance the frame")
|
|
}
|
|
|
|
func TestTerm_DiffRepaintSkipsUnchangedRows(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := screen{out: &buf}
|
|
s.paint([]string{"header", "row a", "row b"}, 80)
|
|
|
|
buf.Reset()
|
|
s.paint([]string{"header", "row a CHANGED", "row b"}, 80)
|
|
out := buf.String()
|
|
erases := strings.Count(out, "\x1b[2K")
|
|
assert.Equal(t, 1, erases, "only the changed row should be erased and rewritten, got %d in %q", erases, out)
|
|
assert.Assert(t, !strings.Contains(out, "header"), "unchanged header must not be rewritten")
|
|
assert.Assert(t, strings.Contains(out, "row a CHANGED"))
|
|
|
|
buf.Reset()
|
|
s.paint([]string{"header", "row a CHANGED", "row b"}, 80)
|
|
assert.Equal(t, "", buf.String(), "identical frame must write nothing")
|
|
}
|
|
|
|
func TestTerm_ShrinkingFrameBlanksLeftoverRows(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := screen{out: &buf}
|
|
s.paint([]string{"header", "row a", "row b"}, 80)
|
|
|
|
buf.Reset()
|
|
s.paint([]string{"header", "row a"}, 80)
|
|
assert.Equal(t, 1, strings.Count(buf.String(), "\x1b[2K"),
|
|
"the leftover row must be blanked")
|
|
}
|
|
|
|
func TestTerm_HeightCapAddsMoreMarker(t *testing.T) {
|
|
w, buf, _ := newTermWriter(80, 10)
|
|
var events []api.Resource
|
|
for i := range 30 {
|
|
events = append(events, api.Resource{ID: fmt.Sprintf("service-%02d", i), Text: "Creating", Status: api.Working})
|
|
}
|
|
feed(w, events...)
|
|
w.repaint()
|
|
|
|
lines := visualLines(buf)
|
|
assert.Assert(t, len(lines) <= 10, "must not paint more rows than the terminal height, got %d", len(lines))
|
|
assert.Assert(t, strings.Contains(lines[len(lines)-1], "more"),
|
|
"last line should advertise the hidden rows: %q", lines[len(lines)-1])
|
|
}
|
|
|
|
// Child tasks (image layers) are not rendered as rows: they only feed the
|
|
// parent's aggregated strip and size counters.
|
|
func TestTerm_ChildTasksAggregateIntoParentRow(t *testing.T) {
|
|
w, buf, _ := newTermWriter(100, 40)
|
|
feed(w,
|
|
api.Resource{ID: "Image nginx", Text: "Pulling", Status: api.Working},
|
|
api.Resource{ID: "sha256:aaaa", ParentID: "Image nginx", Text: "Downloading", Status: api.Working, Current: 25_000_000, Total: 100_000_000, Percent: 25},
|
|
)
|
|
w.repaint()
|
|
|
|
lines := visualLines(buf)
|
|
assert.Equal(t, 2, len(lines), "header + image only, got %v", lines)
|
|
image := lines[1]
|
|
assert.Assert(t, !strings.Contains(buf.String(), "sha256:aaaa"), "layers must not get their own row")
|
|
assert.Assert(t, strings.Contains(image, "25MB / 100MB"), "image row should aggregate layer sizes: %q", image)
|
|
}
|
|
|
|
// Deterministic visual check with a fixed clock: overall shape, id
|
|
// truncation and right-aligned timers.
|
|
func TestTerm_VisualSnapshot(t *testing.T) {
|
|
w, buf, now := newTermWriter(50, 24)
|
|
feed(w, api.Resource{ID: "Image docker.io/library/nginx-long-name", Text: "Pulling", Status: api.Working})
|
|
*now = now.Add(2 * time.Second)
|
|
feed(w, api.Resource{ID: "Image docker.io/library/nginx-long-name", Text: "Pulled", Status: api.Done})
|
|
feed(w, api.Resource{ID: "Image docker.io/library/postgres-database", Text: "Pulling", Status: api.Working})
|
|
w.repaint()
|
|
|
|
lines := visualLines(buf)
|
|
// identical, character for character, to the legacy renderer's golden
|
|
// output in TestPrintWithDimensions_PulledAndPullingWithLongIDs
|
|
expected := []string{
|
|
"[+] pull 1/2",
|
|
" ✔ Image docker.io/library/nginx-l... Pulled 2.0s",
|
|
" ⠋ Image docker.io/library/postgre... Pulling 0.0s",
|
|
}
|
|
assert.Equal(t, len(expected), len(lines))
|
|
for i := range expected {
|
|
assert.Equal(t, expected[i], strings.TrimRight(lines[i], " "), "line %d", i)
|
|
}
|
|
}
|