Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 28 additions & 24 deletions cmd/oras/internal/display/status/console/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ package console
import (
"os"

containerd "github.com/containerd/console"
"github.com/creack/pty"
"github.com/morikuni/aec"
"golang.org/x/term"
)

const (
Expand All @@ -33,9 +34,8 @@ const (
Restore = "\0338"
)

// Console is a wrapper around containerd's Console and ANSI escape codes.
// Console is a wrapper around PTY and ANSI escape codes.
type Console interface {
containerd.Console
GetHeightWidth() (height, width int)
Save()
NewRow()
Expand All @@ -44,59 +44,63 @@ type Console interface {
}

type console struct {
containerd.Console
file *os.File
}

// NewConsole generates a console from a file.
func NewConsole(f *os.File) (Console, error) {
c, err := containerd.ConsoleFromFile(f)
if err != nil {
return nil, err
if !term.IsTerminal(int(f.Fd())) {
return nil, os.ErrInvalid
}
return &console{c}, nil
return &console{file: f}, nil
}

// write writes data to the console.
func (c *console) write(p []byte) (n int, err error) {
return c.file.Write(p)
}

// GetHeightWidth returns the width and height of the console.
// If the console size cannot be determined, returns a default value of 80x10.
func (c *console) GetHeightWidth() (height, width int) {
windowSize, err := c.Size()
rows, cols, err := pty.Getsize(c.file)
if err != nil {
return MinHeight, MinWidth
}
if windowSize.Height < MinHeight {
windowSize.Height = MinHeight
if rows < MinHeight {
rows = MinHeight
}
if windowSize.Width < MinWidth {
windowSize.Width = MinWidth
if cols < MinWidth {
cols = MinWidth
}
return int(windowSize.Height), int(windowSize.Width)
return rows, cols
}

// Save saves the current cursor position.
func (c *console) Save() {
_, _ = c.Write([]byte(aec.Hide.Apply(Save)))
_, _ = c.write([]byte(aec.Hide.Apply(Save)))
}

// NewRow allocates a horizontal space to the output area with scroll if needed.
func (c *console) NewRow() {
_, _ = c.Write([]byte(Restore))
_, _ = c.Write([]byte("\n"))
_, _ = c.Write([]byte(Save))
_, _ = c.write([]byte(Restore))
_, _ = c.write([]byte("\n"))
_, _ = c.write([]byte(Save))
}

// OutputTo outputs a string to a specific line.
func (c *console) OutputTo(upCnt uint, str string) {
_, _ = c.Write([]byte(Restore))
_, _ = c.Write([]byte(aec.PreviousLine(upCnt).Apply(str)))
_, _ = c.Write([]byte("\n"))
_, _ = c.Write([]byte(aec.EraseLine(aec.EraseModes.Tail).String()))
_, _ = c.write([]byte(Restore))
_, _ = c.write([]byte(aec.PreviousLine(upCnt).Apply(str)))
_, _ = c.write([]byte("\n"))
_, _ = c.write([]byte(aec.EraseLine(aec.EraseModes.Tail).String()))
}

// Restore restores the saved cursor position.
func (c *console) Restore() {
// cannot use aec.Restore since DEC has better compatibility than SCO
_, _ = c.Write([]byte(Restore))
_, _ = c.Write([]byte(aec.Column(0).
_, _ = c.write([]byte(Restore))
_, _ = c.write([]byte(aec.Column(0).
With(aec.EraseLine(aec.EraseModes.All)).
With(aec.Show).String()))
}
114 changes: 92 additions & 22 deletions cmd/oras/internal/display/status/console/console_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build !windows && !darwin
//go:build !windows

/*
Copyright The ORAS Authors.
Expand All @@ -21,29 +21,32 @@ import (
"os"
"testing"

containerd "github.com/containerd/console"
"github.com/creack/pty"
"oras.land/oras/internal/testutils"
)

func givenConsole(t *testing.T) (c Console, pty containerd.Console) {
pty, _, err := containerd.NewPty()
func givenConsole(t *testing.T) (c Console, ptmx *os.File) {
t.Helper()
ptmx, pts, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer func() { _ = pts.Close() }()

c = &console{
Console: pty,
file: ptmx,
}
return c, pty
return c, ptmx
}

func givenTestConsole(t *testing.T) (c Console, pty containerd.Console, tty *os.File) {
func givenTestConsole(t *testing.T) (c Console, ptmx *os.File, pts *os.File) {
t.Helper()
var err error
pty, tty, err = testutils.NewPty()
ptmx, pts, err = testutils.NewPty()
if err != nil {
t.Fatal(err)
}
c, err = NewConsole(tty)
c, err = NewConsole(pts)
if err != nil {
t.Fatal(err)
}
Expand All @@ -68,69 +71,136 @@ func TestNewConsole(t *testing.T) {
}

func TestConsole_GetHeightWidth(t *testing.T) {
c, pty := givenConsole(t)
c, ptmx := givenConsole(t)
defer func() { _ = ptmx.Close() }()

// minimal width and height
gotHeight, gotWidth := c.GetHeightWidth()
validateSize(t, gotWidth, gotHeight, MinWidth, MinHeight)

// zero width
_ = pty.Resize(containerd.WinSize{Width: 0, Height: MinHeight})
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: MinHeight, Cols: 0})
gotHeight, gotWidth = c.GetHeightWidth()
validateSize(t, gotWidth, gotHeight, MinWidth, MinHeight)

// zero height
_ = pty.Resize(containerd.WinSize{Width: MinWidth, Height: 0})
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: 0, Cols: MinWidth})
gotHeight, gotWidth = c.GetHeightWidth()
validateSize(t, gotWidth, gotHeight, MinWidth, MinHeight)

// valid zero and height
_ = pty.Resize(containerd.WinSize{Width: 200, Height: 100})
// valid width and height
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: 100, Cols: 200})
gotHeight, gotWidth = c.GetHeightWidth()
validateSize(t, gotWidth, gotHeight, 200, 100)

}

func TestConsole_NewRow(t *testing.T) {
c, pty, tty := givenTestConsole(t)
c, ptmx, pts := givenTestConsole(t)
defer func() { _ = ptmx.Close() }()

c.NewRow()

err := testutils.MatchPty(pty, tty, "\x1b8\r\n\x1b7")
err := testutils.MatchPty(ptmx, pts, "\x1b8\r\n\x1b7")
if err != nil {
t.Fatalf("NewRow output error: %v", err)
}
}

func TestConsole_OutputTo(t *testing.T) {
c, pty, tty := givenTestConsole(t)
c, ptmx, pts := givenTestConsole(t)
defer func() { _ = ptmx.Close() }()

c.OutputTo(1, "test string")

err := testutils.MatchPty(pty, tty, "\x1b8\x1b[1Ftest string\x1b[0m\r\n\x1b[0K")
err := testutils.MatchPty(ptmx, pts, "\x1b8\x1b[1Ftest string\x1b[0m\r\n\x1b[0K")
if err != nil {
t.Fatalf("OutputTo output error: %v", err)
}
}

func TestConsole_Restore(t *testing.T) {
c, pty, tty := givenTestConsole(t)
c, ptmx, pts := givenTestConsole(t)
defer func() { _ = ptmx.Close() }()

c.Restore()

err := testutils.MatchPty(pty, tty, "\x1b8\x1b[0G\x1b[2K\x1b[?25h")
err := testutils.MatchPty(ptmx, pts, "\x1b8\x1b[0G\x1b[2K\x1b[?25h")
if err != nil {
t.Fatalf("Restore output error: %v", err)
}
}

func TestConsole_Save(t *testing.T) {
c, pty, tty := givenTestConsole(t)
c, ptmx, pts := givenTestConsole(t)
defer func() { _ = ptmx.Close() }()

c.Save()

err := testutils.MatchPty(pty, tty, "\x1b[?25l\x1b7\x1b[0m")
err := testutils.MatchPty(ptmx, pts, "\x1b[?25l\x1b7\x1b[0m")
if err != nil {
t.Fatalf("Save output error: %v", err)
}
}

func TestConsole_Write(t *testing.T) {
ptmx, pts, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer func() {
_ = ptmx.Close()
_ = pts.Close()
}()

c := &console{file: ptmx}
testData := []byte("test data")
n, err := c.write(testData)
if err != nil {
t.Fatalf("write() error = %v, want nil", err)
}
if n != len(testData) {
t.Errorf("write() returned %d bytes, want %d", n, len(testData))
}
}

func TestConsole_GetHeightWidth_Error(t *testing.T) {
// Create a console with a file that will cause pty.Getsize to fail
ptmx, pts, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer func() {
_ = ptmx.Close()
_ = pts.Close()
}()

// Close ptmx to make Getsize fail
_ = ptmx.Close()

c := &console{file: ptmx}
height, width := c.GetHeightWidth()

// Should return default values when Getsize fails
if height != MinHeight {
t.Errorf("GetHeightWidth() height = %d, want %d", height, MinHeight)
}
if width != MinWidth {
t.Errorf("GetHeightWidth() width = %d, want %d", width, MinWidth)
}
}

func TestConstants(t *testing.T) {
if MinWidth != 80 {
t.Errorf("MinWidth = %d, want 80", MinWidth)
}
if MinHeight != 10 {
t.Errorf("MinHeight = %d, want 10", MinHeight)
}
if Save != "\0337" {
t.Errorf("Save = %q, want \\0337", Save)
}
if Restore != "\0338" {
t.Errorf("Restore = %q, want \\0338", Restore)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.0

require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/containerd/console v1.0.5
github.com/creack/pty v1.1.24
github.com/morikuni/aec v1.0.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
Expand Down
5 changes: 2 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -60,7 +60,6 @@ golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+Zdx
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
Expand Down
24 changes: 8 additions & 16 deletions internal/testutils/console.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build !windows && !darwin
//go:build !windows

/*
Copyright The ORAS Authors.
Expand All @@ -25,34 +25,26 @@ import (
"strings"
"sync"

containerd "github.com/containerd/console"
"github.com/creack/pty"
)

// NewPty creates a new pty pair for testing, caller is responsible for closing
// the returned device file if err is not nil.
func NewPty() (containerd.Console, *os.File, error) {
pty, devicePath, err := containerd.NewPty()
if err != nil {
return nil, nil, err
}
device, err := os.OpenFile(devicePath, os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
return pty, device, nil
// the returned files if err is not nil.
func NewPty() (*os.File, *os.File, error) {
return pty.Open()
}

// MatchPty checks that the output matches the expected strings in specified
// order.
func MatchPty(pty containerd.Console, device *os.File, expected ...string) error {
func MatchPty(ptmx *os.File, pts *os.File, expected ...string) error {
var wg sync.WaitGroup
wg.Add(1)
var buffer bytes.Buffer
go func() {
defer wg.Done()
_, _ = io.Copy(&buffer, pty)
_, _ = io.Copy(&buffer, ptmx)
}()
_ = device.Close()
_ = pts.Close()
wg.Wait()

return OrderedMatch(buffer.String(), expected...)
Expand Down
Loading