From b982e2ff0d80e8224b8c5e73e338ce4e9315aee7 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 19 May 2026 17:32:36 +0300 Subject: [PATCH] Make start header more compact --- internal/container/start.go | 16 +++++++++++++--- internal/output/plain_format.go | 4 ++-- internal/output/plain_format_test.go | 2 +- internal/output/plain_sink_test.go | 4 ++-- internal/ui/app.go | 8 ++++++-- internal/ui/components/spinner.go | 9 +++++++++ test/integration/awsconfig_test.go | 2 +- test/integration/version_resolution_test.go | 4 ++-- 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index e7062c6e..466ebf31 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -366,9 +366,10 @@ func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink ou func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error { for _, c := range containers { startTime := time.Now() - sink.Emit(output.ContainerStatusEvent{Phase: "starting", Container: c.Name}) + sink.Emit(output.SpinnerStart("Starting LocalStack")) containerID, err := rt.Start(ctx, c) if err != nil { + sink.Emit(output.SpinnerStop()) tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ EventType: telemetry.LifecycleStartError, Emulator: c.EmulatorType, @@ -379,9 +380,9 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, return fmt.Errorf("failed to start LocalStack: %w", err) } - sink.Emit(output.ContainerStatusEvent{Phase: "waiting", Container: c.Name}) healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath) if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL); err != nil { + sink.Emit(output.SpinnerStop()) errCode := telemetry.ErrCodeStartFailed var licErr *licenseNotCoveredError if errors.As(err, &licErr) && c.EmulatorType == config.EmulatorSnowflake { @@ -404,6 +405,7 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, }) return err } + sink.Emit(output.SpinnerStop()) sink.Emit(output.ContainerStatusEvent{Phase: "ready", Container: c.Name, Detail: fmt.Sprintf("containerId: %s", containerID[:12])}) @@ -549,7 +551,7 @@ func emitPortInUseError(sink output.Sink, port string) { func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, containerConfig runtime.ContainerConfig, token, licenseFilePath string) error { version := containerConfig.Tag - sink.Emit(output.ContainerStatusEvent{Phase: "validating license", Container: containerConfig.Name}) + sink.Emit(output.SpinnerStart("Checking license")) hostname, _ := os.Hostname() licenseReq := &api.LicenseRequest{ @@ -569,6 +571,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c licenseResp, err := opts.PlatformClient.GetLicense(ctx, licenseReq) if err != nil { + sink.Emit(output.SpinnerStop()) var licErr *api.LicenseError if errors.As(err, &licErr) && licErr.Detail != "" { opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail) @@ -582,6 +585,13 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c }) return fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err) } + sink.Emit(output.SpinnerStop()) + + validMsg := "Valid license" + if plan := licenseResp.PlanDisplayName(); plan != "" { + validMsg = fmt.Sprintf("Valid license (%s)", plan) + } + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: validMsg}) if licenseResp != nil && len(licenseResp.RawBytes) > 0 { if err := os.MkdirAll(filepath.Dir(licenseFilePath), 0755); err != nil { diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index ec34a9a6..42023022 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -51,9 +51,9 @@ func formatStatusLine(e ContainerStatusEvent) (string, bool) { return "Waiting for LocalStack to be ready...", true case "ready": if e.Detail != "" { - return fmt.Sprintf("%s LocalStack ready (%s)", SuccessMarker(), e.Detail), true + return fmt.Sprintf("%s LocalStack is running (%s)", SuccessMarker(), e.Detail), true } - return SuccessMarker() + " LocalStack ready", true + return SuccessMarker() + " LocalStack is running", true default: if e.Detail != "" { return fmt.Sprintf("LocalStack: %s (%s)", e.Phase, e.Detail), true diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 3c052e37..2d7eb70b 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -60,7 +60,7 @@ func TestFormatEventLine(t *testing.T) { { name: "status ready with detail", event: ContainerStatusEvent{Phase: "ready", Container: "localstack-aws", Detail: "abc123"}, - want: SuccessMarker() + " LocalStack ready (abc123)", + want: SuccessMarker() + " LocalStack is running (abc123)", wantOK: true, }, { diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index c0b3026e..fb167b72 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -61,12 +61,12 @@ func TestPlainSink_EmitsStatusEvent(t *testing.T) { { name: "ready phase with detail", event: ContainerStatusEvent{Phase: "ready", Container: "localstack-aws", Detail: "abc123"}, - expected: fmt.Sprintf("%s LocalStack ready (abc123)\n", SuccessMarker()), + expected: fmt.Sprintf("%s LocalStack is running (abc123)\n", SuccessMarker()), }, { name: "ready phase without detail", event: ContainerStatusEvent{Phase: "ready", Container: "localstack-aws"}, - expected: fmt.Sprintf("%s LocalStack ready\n", SuccessMarker()), + expected: fmt.Sprintf("%s LocalStack is running\n", SuccessMarker()), }, { name: "unknown phase with detail", diff --git a/internal/ui/app.go b/internal/ui/app.go index bcb7d4dc..924cc7fe 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -187,6 +187,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.spinner = a.spinner.Start(msg.Text, msg.MinDuration) return a, a.spinner.Tick() } + if a.pullProgress.Visible() { + a.pullProgress = a.pullProgress.Hide() + } var cmd tea.Cmd a.spinner, cmd = a.spinner.Stop() if !a.spinner.PendingStop() { @@ -209,7 +212,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.headerLoading = false } a.errorDisplay = a.errorDisplay.Show(msg) - a.spinner, _ = a.spinner.Stop() + a.spinner = a.spinner.ForceStop() + a.flushBufferedLines() return a, nil case output.MessageEvent: msgCopy := msg @@ -267,7 +271,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit case runErrMsg: a.err = msg.err - a.spinner, _ = a.spinner.Stop() + a.spinner = a.spinner.ForceStop() a.flushBufferedLines() if !output.IsSilent(msg.err) { a.errorDisplay = a.errorDisplay.Show(output.ErrorEvent{Title: msg.err.Error()}) diff --git a/internal/ui/components/spinner.go b/internal/ui/components/spinner.go index e0c17dcf..c2e13dea 100644 --- a/internal/ui/components/spinner.go +++ b/internal/ui/components/spinner.go @@ -51,6 +51,15 @@ func (s Spinner) Stop() (Spinner, tea.Cmd) { }) } +// ForceStop clears the spinner immediately, ignoring the min-duration smoothing +// that Stop applies. Use this on error or terminal paths where a soft stop would +// leave a stale frame on the final render. +func (s Spinner) ForceStop() Spinner { + s.visible = false + s.pendingStop = false + return s +} + func (s Spinner) PendingStop() bool { return s.pendingStop } diff --git a/test/integration/awsconfig_test.go b/test/integration/awsconfig_test.go index 5bd1e000..fd7415de 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -128,7 +128,7 @@ func TestStartSkipsAWSProfilePromptWhenAlreadyConfigured(t *testing.T) { // Wait until the container is ready — that's the point at which post-start setup // runs, so if the prompt were going to appear it would already be in the output. require.Eventually(t, func() bool { - return bytes.Contains(out.Bytes(), []byte(" ready")) + return bytes.Contains(out.Bytes(), []byte("LocalStack is running")) }, 2*time.Minute, 200*time.Millisecond, "container should become ready") _ = cmd.Process.Kill() diff --git a/test/integration/version_resolution_test.go b/test/integration/version_resolution_test.go index c5160f5e..d90c8803 100644 --- a/test/integration/version_resolution_test.go +++ b/test/integration/version_resolution_test.go @@ -73,8 +73,8 @@ func TestVersionResolvedViaCatalog(t *testing.T) { "license request should carry the version returned by the catalog API") assert.NotEqual(t, "latest", *capturedVersion, "license request should not use the unresolved 'latest' tag") - assert.Contains(t, stdout, "LocalStack: validating license") - assert.NotContains(t, stdout, "validating license (4.14.0)") + assert.Contains(t, stdout, "Checking license") + assert.NotContains(t, stdout, "(4.14.0)") } // Verifies that when the catalog endpoint is unavailable, the version is resolved