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
21 changes: 20 additions & 1 deletion cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Examples:
DisableFlagParsing: true,
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
args, nonInteractive := stripNonInteractiveFlag(args)

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
Expand Down Expand Up @@ -85,7 +87,7 @@ Examples:
}

stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)
if terminal.IsTerminal(os.Stderr) {
if !nonInteractive && terminal.IsTerminal(os.Stderr) {
s := terminal.NewSpinner(os.Stderr, "Loading service...", 4*time.Second)
s.Start()
defer s.Stop()
Expand All @@ -97,3 +99,20 @@ Examples:
},
}
}

// stripNonInteractiveFlag pulls lstk's --non-interactive flag out of the AWS CLI
// passthrough args and reports whether it was set. The aws command uses
// DisableFlagParsing, so Cobra never parses the flag here — left in place it would
// be forwarded to the aws binary and rejected as an unknown option.
func stripNonInteractiveFlag(args []string) ([]string, bool) {
out := make([]string, 0, len(args))
nonInteractive := false
for _, a := range args {
if a == "--non-interactive" {
nonInteractive = true
continue
}
out = append(out, a)
}
return out, nonInteractive
}
52 changes: 52 additions & 0 deletions cmd/aws_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import (
"reflect"
"testing"
)

func TestStripNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
args []string
wantArgs []string
wantNonInteract bool
}{
{
name: "absent",
args: []string{"s3", "ls"},
wantArgs: []string{"s3", "ls"},
wantNonInteract: false,
},
{
name: "bare flag is stripped and enables non-interactive",
args: []string{"--non-interactive", "s3", "ls"},
wantArgs: []string{"s3", "ls"},
wantNonInteract: true,
},
{
name: "flag among aws args is stripped",
args: []string{"s3", "ls", "--non-interactive", "--recursive"},
wantArgs: []string{"s3", "ls", "--recursive"},
wantNonInteract: true,
},
{
name: "does not strip a similarly named aws flag",
args: []string{"s3", "ls", "--non-interactive-mode"},
wantArgs: []string{"s3", "ls", "--non-interactive-mode"},
wantNonInteract: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotArgs, gotNonInteract := stripNonInteractiveFlag(tt.args)
if gotNonInteract != tt.wantNonInteract {
t.Errorf("nonInteractive = %v, want %v", gotNonInteract, tt.wantNonInteract)
}
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
t.Errorf("args = %v, want %v", gotArgs, tt.wantArgs)
}
})
}
}
16 changes: 15 additions & 1 deletion internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func FormatEventLine(event Event) (string, bool) {
case ContainerStatusEvent:
return formatStatusLine(e)
case ProgressEvent:
return "", false
return formatProgressLine(e)
case UserInputRequestEvent:
return formatUserInputRequest(e), true
case LogLineEvent:
Expand Down Expand Up @@ -72,6 +72,20 @@ func formatStatusLine(e ContainerStatusEvent) (string, bool) {
}
}

// formatProgressLine renders image-pull progress for non-interactive output,
// mirroring `docker pull` on a non-TTY: it emits one line per discrete layer
// status transition and drops the high-frequency byte-progress ticks (which
// carry a Current/Total) so captured streams stay deterministic and unflooded.
func formatProgressLine(e ProgressEvent) (string, bool) {
if e.Status == "" || e.Current > 0 || e.Total > 0 {
return "", false
}
if e.LayerID == "" {
return e.Status, true
}
return e.LayerID + ": " + e.Status, true
}

func formatUserInputRequest(e UserInputRequestEvent) string {
return FormatPrompt(e.Prompt, e.Options)
}
Expand Down
26 changes: 25 additions & 1 deletion internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,35 @@ func TestFormatEventLine(t *testing.T) {
wantOK: true,
},
{
name: "progress suppressed",
name: "progress byte tick suppressed",
event: ProgressEvent{LayerID: "abc123", Status: "Downloading", Current: 50, Total: 100},
want: "",
wantOK: false,
},
{
name: "progress byte tick suppressed at start",
event: ProgressEvent{LayerID: "abc123", Status: "Downloading", Current: 0, Total: 100},
want: "",
wantOK: false,
},
{
name: "progress empty status suppressed",
event: ProgressEvent{LayerID: "abc123", Status: ""},
want: "",
wantOK: false,
},
{
name: "progress milestone with layer",
event: ProgressEvent{LayerID: "abc123", Status: "Pull complete"},
want: "abc123: Pull complete",
wantOK: true,
},
{
name: "progress milestone without layer",
event: ProgressEvent{Status: "Pulling from localstack/localstack"},
want: "Pulling from localstack/localstack",
wantOK: true,
},
{
name: "spinner event active",
event: SpinnerEvent{Active: true, Text: "Loading"},
Expand Down
14 changes: 13 additions & 1 deletion internal/output/plain_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func TestPlainSink_EmitsStatusEvent(t *testing.T) {
}
}

func TestPlainSink_SuppressesProgressEvent(t *testing.T) {
func TestPlainSink_SuppressesProgressByteTicks(t *testing.T) {
var out bytes.Buffer
sink := NewPlainSink(&out)

Expand All @@ -107,6 +107,18 @@ func TestPlainSink_SuppressesProgressEvent(t *testing.T) {
assert.Equal(t, "", out.String())
}

func TestPlainSink_EmitsProgressMilestones(t *testing.T) {
var out bytes.Buffer
sink := NewPlainSink(&out)

// Byte-progress ticks are dropped; only discrete status transitions surface.
sink.Emit(ProgressEvent{LayerID: "abc123", Status: "Downloading", Current: 10, Total: 100})
sink.Emit(ProgressEvent{LayerID: "abc123", Status: "Downloading", Current: 90, Total: 100})
sink.Emit(ProgressEvent{LayerID: "abc123", Status: "Pull complete"})

assert.Equal(t, "abc123: Pull complete\n", out.String())
}

func TestPlainSink_EmitsLogLineEvent(t *testing.T) {
var out bytes.Buffer
sink := NewPlainSink(&out)
Expand Down
22 changes: 22 additions & 0 deletions test/integration/aws_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,28 @@ func TestAWSCommandShowsSpinnerForSlowOperation(t *testing.T) {
assert.Contains(t, out, "ARGS:--profile localstack s3 ls")
}

func TestAWSCommandSuppressesSpinnerInNonInteractiveMode(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)

// A slow operation would normally render the spinner in a PTY; --non-interactive
// must suppress it so captured streams carry no ANSI control codes.
fakeDir := writeSlowFakeAWS(t, 5)
homeDir := t.TempDir()
writeAWSProfile(t, homeDir)
e := env.With(env.DisableEvents, "1").With("PATH", fakeDir+":/bin:/usr/bin").With(env.Home, homeDir)

out, err := runLstkInPTY(t, ctx, e, "--non-interactive", "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 TestAWSCommandSuppressesSpinnerForFastOperation(t *testing.T) {
requireDocker(t)
cleanup()
Expand Down
Loading