diff --git a/cmd/aws.go b/cmd/aws.go index 0ec0be43..4dd7d247 100644 --- a/cmd/aws.go +++ b/cmd/aws.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "time" "github.com/localstack/lstk/internal/awscli" "github.com/localstack/lstk/internal/awsconfig" @@ -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} diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go index 76417b28..a833f626 100644 --- a/internal/terminal/spinner.go +++ b/internal/terminal/spinner.go @@ -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{}), } @@ -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() diff --git a/internal/terminal/spinner_test.go b/internal/terminal/spinner_test.go new file mode 100644 index 00000000..eb34c4aa --- /dev/null +++ b/internal/terminal/spinner_test.go @@ -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) + } +} \ No newline at end of file diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index 15efa411..319e11e3 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -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()