Skip to content
Merged
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
3 changes: 2 additions & 1 deletion cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"io"
"os"
"time"

"github.com/localstack/lstk/internal/awscli"
"github.com/localstack/lstk/internal/awsconfig"
Expand Down Expand Up @@ -85,7 +86,7 @@ Examples:

stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)
if terminal.IsTerminal(os.Stderr) {
s := terminal.NewSpinner(os.Stderr, "Loading...")
s := terminal.NewSpinner(os.Stderr, "Loading service...", 4*time.Second)
s.Start()
defer s.Stop()
stdout = &terminal.StopOnWriteWriter{W: os.Stdout, Spinner: s}
Expand Down
18 changes: 17 additions & 1 deletion internal/terminal/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,21 @@ const (
type Spinner struct {
out io.Writer
label string
delay time.Duration
stop chan struct{}
done chan struct{}
mu sync.Mutex
stopOnce sync.Once
}

func NewSpinner(out io.Writer, label string) *Spinner {
// NewSpinner returns a spinner that, when started, waits for delay before
// rendering its first frame. A zero delay renders immediately. If Stop is
// called before the delay elapses, no output is written.
func NewSpinner(out io.Writer, label string, delay time.Duration) *Spinner {
return &Spinner{
out: out,
label: label,
delay: delay,
stop: make(chan struct{}),
done: make(chan struct{}),
}
Expand All @@ -40,6 +45,17 @@ func NewSpinner(out io.Writer, label string) *Spinner {
func (s *Spinner) Start() {
go func() {
defer close(s.done)

if s.delay > 0 {
timer := time.NewTimer(s.delay)
select {
case <-s.stop:
timer.Stop()
return
case <-timer.C:
}
}

tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()

Expand Down
50 changes: 50 additions & 0 deletions internal/terminal/spinner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package terminal

import (
"bytes"
"strings"
"testing"
"time"
)

func TestSpinnerSilentWhenStoppedBeforeDelay(t *testing.T) {
t.Parallel()

var out bytes.Buffer
s := NewSpinner(&out, "loading", 200*time.Millisecond)
s.Start()
time.Sleep(20 * time.Millisecond)
s.Stop()

if got := out.String(); got != "" {
t.Fatalf("expected no output when stopped before delay, got %q", got)
}
}

func TestSpinnerRendersAfterDelay(t *testing.T) {
t.Parallel()

var out bytes.Buffer
s := NewSpinner(&out, "loading", 30*time.Millisecond)
s.Start()
time.Sleep(120 * time.Millisecond)
s.Stop()

if got := out.String(); !strings.Contains(got, "loading") {
t.Fatalf("expected label %q in output, got %q", "loading", got)
}
}

func TestSpinnerRendersImmediatelyWithZeroDelay(t *testing.T) {
t.Parallel()

var out bytes.Buffer
s := NewSpinner(&out, "loading", 0)
s.Start()
time.Sleep(20 * time.Millisecond)
s.Stop()

if got := out.String(); !strings.Contains(got, "loading") {
t.Fatalf("expected label %q in output, got %q", "loading", got)
}
}
62 changes: 62 additions & 0 deletions test/integration/aws_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,68 @@ func TestAWSCommandWorksWithExternalContainer(t *testing.T) {
assert.Contains(t, stdout, "ENDPOINT:http://")
}

// writeSlowFakeAWS creates a fake `aws` script that sleeps for the given duration
// before printing, so the spinner has time to render in PTY-based tests.
func writeSlowFakeAWS(t *testing.T, sleepSeconds int) string {
t.Helper()
dir := t.TempDir()

if runtime.GOOS == "windows" {
t.Skip("fake aws script not supported on Windows")
}

script := fmt.Sprintf(`#!/bin/sh
sleep %d
echo "ENDPOINT:$2"
shift 2
echo "ARGS:$@"
`, sleepSeconds)
path := filepath.Join(dir, "aws")
require.NoError(t, os.WriteFile(path, []byte(script), 0755))
return dir
}

func TestAWSCommandShowsSpinnerForSlowOperation(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)
ctx := testContext(t)
// A running emulator is required: without it, `lstk aws` exits before reaching the spinner.
startTestContainer(t, ctx)

fakeDir := writeSlowFakeAWS(t, 5)
homeDir := t.TempDir()
writeAWSProfile(t, homeDir)
// /bin and /usr/bin are needed so the fake script can invoke `sleep`.
e := env.With(env.DisableEvents, "1").With("PATH", fakeDir+":/bin:/usr/bin").With(env.Home, homeDir)

out, err := runLstkInPTY(t, ctx, e, "aws", "s3", "ls")
require.NoError(t, err, "lstk aws failed: %s", out)

assert.Contains(t, out, "Loading service")
assert.Contains(t, out, "ARGS:--profile localstack s3 ls")
}

func TestAWSCommandSuppressesSpinnerForFastOperation(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)
ctx := testContext(t)
// A running emulator is required: without it, `lstk aws` exits before reaching the spinner.
startTestContainer(t, ctx)

fakeDir := writeFakeAWS(t)
homeDir := t.TempDir()
writeAWSProfile(t, homeDir)
e := env.With(env.DisableEvents, "1").With("PATH", fakeDir).With(env.Home, homeDir)

out, err := runLstkInPTY(t, ctx, e, "aws", "s3", "ls")
require.NoError(t, err, "lstk aws failed: %s", out)

assert.NotContains(t, out, "Loading service")
assert.Contains(t, out, "ARGS:--profile localstack s3 ls")
}

func TestAWSCommandSuppressesHintWhenProfileExists(t *testing.T) {
requireDocker(t)
cleanup()
Expand Down
Loading