Skip to content

Commit f87e49e

Browse files
committed
add tests, handle \b
1 parent a626ad1 commit f87e49e

File tree

3 files changed

+161
-28
lines changed

3 files changed

+161
-28
lines changed

components/gitpod-cli/cmd/validate.go

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -566,23 +566,27 @@ func pipeTask(ctx context.Context, task *api.TaskStatus, supervisor *supervisor.
566566
}
567567
}
568568

569-
func listenTerminal(ctx context.Context, task *api.TaskStatus, supervisor *supervisor.SupervisorClient, runLog *logrus.Entry) error {
570-
listen, err := supervisor.Terminal.Listen(ctx, &api.ListenTerminalRequest{Alias: task.Terminal})
571-
if err != nil {
572-
return err
573-
}
569+
// TerminalReader is an interface for anything that can receive terminal data (this is abstracted for use in testing)
570+
type TerminalReader interface {
571+
Recv() ([]byte, error)
572+
}
574573

574+
type LinePrinter func(string)
575+
576+
// processTerminalOutput reads from a TerminalReader, processes the output, and calls the provided LinePrinter for each complete line.
577+
// It handles UTF-8 decoding of characters split across chunks and control characters (\n \r \b).
578+
func processTerminalOutput(reader TerminalReader, printLine LinePrinter) error {
575579
var buffer, line bytes.Buffer
576580

577581
flushLine := func() {
578582
if line.Len() > 0 {
579-
runLog.Infof("%s: %s", task.Presentation.Name, line.String())
583+
printLine(line.String())
580584
line.Reset()
581585
}
582586
}
583587

584588
for {
585-
resp, err := listen.Recv()
589+
data, err := reader.Recv()
586590
if err != nil {
587591
if err == io.EOF {
588592
flushLine()
@@ -591,36 +595,58 @@ func listenTerminal(ctx context.Context, task *api.TaskStatus, supervisor *super
591595
return err
592596
}
593597

594-
if title := resp.GetTitle(); title != "" {
595-
task.Presentation.Name = title
596-
}
597-
598-
if exitCode := resp.GetExitCode(); exitCode != 0 {
599-
flushLine()
600-
runLog.Infof("%s: exited with code %d", task.Presentation.Name, exitCode)
601-
}
602-
603-
if data := resp.GetData(); len(data) > 0 {
604-
buffer.Write(data)
598+
buffer.Write(data)
605599

606-
for {
607-
r, size := utf8.DecodeRune(buffer.Bytes())
608-
if r == utf8.RuneError && size == 0 {
609-
break // incomplete character at the end
610-
}
600+
for {
601+
r, size := utf8.DecodeRune(buffer.Bytes())
602+
if r == utf8.RuneError && size == 0 {
603+
break // incomplete character at the end
604+
}
611605

612-
char := buffer.Next(size)
606+
char := buffer.Next(size)
613607

614-
if r == '\n' || r == '\r' {
615-
flushLine()
616-
} else {
617-
line.Write(char)
608+
switch r {
609+
case '\r':
610+
flushLine()
611+
case '\n':
612+
flushLine()
613+
case '\b':
614+
if line.Len() > 0 {
615+
line.Truncate(line.Len() - 1)
618616
}
617+
default:
618+
line.Write(char)
619619
}
620620
}
621621
}
622622
}
623623

624+
func listenTerminal(ctx context.Context, task *api.TaskStatus, supervisor *supervisor.SupervisorClient, runLog *logrus.Entry) error {
625+
listen, err := supervisor.Terminal.Listen(ctx, &api.ListenTerminalRequest{Alias: task.Terminal})
626+
if err != nil {
627+
return err
628+
}
629+
630+
terminalReader := &TerminalReaderAdapter{listen}
631+
printLine := func(line string) {
632+
runLog.Infof("%s: %s", task.Presentation.Name, line)
633+
}
634+
635+
return processTerminalOutput(terminalReader, printLine)
636+
}
637+
638+
type TerminalReaderAdapter struct {
639+
client api.TerminalService_ListenClient
640+
}
641+
642+
func (t *TerminalReaderAdapter) Recv() ([]byte, error) {
643+
resp, err := t.client.Recv()
644+
if err != nil {
645+
return nil, err
646+
}
647+
return resp.GetData(), nil
648+
}
649+
624650
var validateOpts struct {
625651
WorkspaceFolder string
626652
LogLevel string
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package cmd
6+
7+
import (
8+
"io"
9+
"testing"
10+
11+
"github.com/google/go-cmp/cmp"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
type MockTerminalReader struct {
16+
Data [][]byte
17+
Index int
18+
Errors []error
19+
}
20+
21+
func (m *MockTerminalReader) Recv() ([]byte, error) {
22+
if m.Index >= len(m.Data) {
23+
return nil, io.EOF
24+
}
25+
data := m.Data[m.Index]
26+
err := m.Errors[m.Index]
27+
m.Index++
28+
return data, err
29+
}
30+
31+
func TestProcessTerminalOutput(t *testing.T) {
32+
tests := []struct {
33+
name string
34+
input [][]byte
35+
expected []string
36+
}{
37+
{
38+
name: "Simple line",
39+
input: [][]byte{[]byte("Hello, World!\n")},
40+
expected: []string{"Hello, World!"},
41+
},
42+
{
43+
name: "Windows line ending",
44+
input: [][]byte{[]byte("Hello\r\nWorld\r\n")},
45+
expected: []string{"Hello", "World"},
46+
},
47+
{
48+
name: "Updating line",
49+
input: [][]byte{
50+
[]byte("Hello, World!\r"),
51+
[]byte("Hello, World 2!\r"),
52+
[]byte("Hello, World 3!\n"),
53+
},
54+
expected: []string{"Hello, World!", "Hello, World 2!", "Hello, World 3!"},
55+
},
56+
{
57+
name: "Backspace",
58+
input: [][]byte{[]byte("Helloo\bWorld\n")},
59+
expected: []string{"HelloWorld"},
60+
},
61+
{
62+
name: "Partial UTF-8",
63+
input: [][]byte{[]byte("Hello, 世"), []byte("界\n")},
64+
expected: []string{"Hello, 世界"},
65+
},
66+
{
67+
name: "Partial emoji",
68+
input: [][]byte{
69+
[]byte("Hello "),
70+
{240, 159},
71+
{145, 141},
72+
[]byte("!\n"),
73+
},
74+
expected: []string{"Hello 👍!"},
75+
},
76+
{
77+
name: "Multiple lines in one receive",
78+
input: [][]byte{[]byte("Line1\nLine2\nLine3\n")},
79+
expected: []string{"Line1", "Line2", "Line3"},
80+
},
81+
}
82+
83+
for _, test := range tests {
84+
t.Run(test.name, func(t *testing.T) {
85+
reader := &MockTerminalReader{
86+
Data: test.input,
87+
Errors: make([]error, len(test.input)),
88+
}
89+
90+
var actual []string
91+
printLine := func(line string) {
92+
actual = append(actual, line)
93+
}
94+
95+
err := processTerminalOutput(reader, printLine)
96+
assert.NoError(t, err)
97+
98+
if diff := cmp.Diff(test.expected, actual); diff != "" {
99+
t.Errorf("processTerminalOutput() mismatch (-want +got):\n%s", diff)
100+
}
101+
})
102+
}
103+
}

components/gitpod-cli/go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/sirupsen/logrus v1.9.3
2424
github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37
2525
github.com/spf13/cobra v1.6.1
26+
github.com/stretchr/testify v1.8.4
2627
golang.org/x/sync v0.2.0
2728
golang.org/x/term v0.15.0
2829
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f
@@ -33,6 +34,7 @@ require (
3334
require (
3435
github.com/beorn7/perks v1.0.1 // indirect
3536
github.com/cespare/xxhash/v2 v2.2.0 // indirect
37+
github.com/davecgh/go-spew v1.1.1 // indirect
3638
github.com/gitpod-io/gitpod/components/scrubber v0.0.0-00010101000000-000000000000 // indirect
3739
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
3840
github.com/hashicorp/golang-lru v1.0.2 // indirect
@@ -43,12 +45,14 @@ require (
4345
github.com/mitchellh/reflectwalk v1.0.2 // indirect
4446
github.com/onsi/ginkgo v1.16.5 // indirect
4547
github.com/onsi/gomega v1.24.2 // indirect
48+
github.com/pmezard/go-difflib v1.0.0 // indirect
4649
github.com/prometheus/client_golang v1.16.0 // indirect
4750
github.com/prometheus/client_model v0.3.0 // indirect
4851
github.com/prometheus/common v0.42.0 // indirect
4952
golang.org/x/crypto v0.16.0 // indirect
5053
golang.org/x/sys v0.15.0 // indirect
5154
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
55+
gopkg.in/yaml.v3 v3.0.1 // indirect
5256
)
5357

5458
require (

0 commit comments

Comments
 (0)