From 11af71bd15b6a79f5bebe8322836751e3cf9e3fd Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Sat, 21 Mar 2026 20:56:10 +0200 Subject: [PATCH] Add feedback command --- CLAUDE.md | 2 + README.md | 6 +- cmd/feedback.go | 272 ++++++++++++++++++++++++++++++ cmd/feedback_escape_other.go | 9 + cmd/feedback_escape_unix.go | 27 +++ cmd/feedback_test.go | 31 ++++ cmd/root.go | 1 + internal/feedback/client.go | 124 ++++++++++++++ internal/feedback/client_test.go | 91 ++++++++++ internal/ui/run_feedback.go | 45 +++++ test/integration/env/env.go | 1 + test/integration/feedback_test.go | 97 +++++++++++ 12 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 cmd/feedback.go create mode 100644 cmd/feedback_escape_other.go create mode 100644 cmd/feedback_escape_unix.go create mode 100644 cmd/feedback_test.go create mode 100644 internal/feedback/client.go create mode 100644 internal/feedback/client_test.go create mode 100644 internal/ui/run_feedback.go create mode 100644 test/integration/feedback_test.go diff --git a/CLAUDE.md b/CLAUDE.md index cdde31cf..0dd5541d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for - `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering - `ui/` - Bubble Tea views for interactive output - `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction + - `feedback/` - Feedback API client and metadata helpers used by `lstk feedback` - `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that) - `iac/` - Wrappers for third-party infrastructure as code tools (Terraform, AWS CDK, AWS SAM CLI). @@ -114,6 +115,7 @@ The default `lstk az ` mode mirrors `lstk aws`: the Azure CLI has no `--en Environment variables: - `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set) +- `LSTK_API_ENDPOINT` - Override the LocalStack platform API endpoint (also used by `lstk feedback`) - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). # Infrastructure as Code Commands diff --git a/README.md b/README.md index 95bac133..ceeac9e3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac - **Terraform integration** — proxy Terraform commands to LocalStack with automatic AWS provider endpoint configuration - **CDK integration** — proxy AWS CDK commands to LocalStack with automatic endpoint configuration (requires AWS CDK >= 2.177.0) - **Self-update** — check for and install the latest `lstk` release with `lstk update` +- **Feedback submission** — send CLI feedback directly to the LocalStack team with `lstk feedback` - **Shell completions** — bash, zsh, and fish completions included ## Authentication @@ -346,6 +347,9 @@ lstk cdk deploy --require-approval never # Synthesize a CDK app (offline, no running emulator needed) lstk cdk synth +# Send feedback interactively +lstk feedback + ``` ## Snapshots @@ -437,4 +441,4 @@ See [docs/extensions-authoring.md](docs/extensions-authoring.md) for the extensi ## Reporting bugs -Feedback is welcome! Use the repository issue tracker for bug reports or feature requests. +Feedback is welcome! You can submit feedback from the CLI with the `feedback` command or use the repository issue tracker for bug reports and feature requests. diff --git a/cmd/feedback.go b/cmd/feedback.go new file mode 100644 index 00000000..fed8426e --- /dev/null +++ b/cmd/feedback.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/localstack/lstk/internal/auth" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/feedback" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/telemetry" + "github.com/localstack/lstk/internal/ui" + "github.com/localstack/lstk/internal/ui/styles" + "github.com/localstack/lstk/internal/version" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +func newFeedbackCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "feedback", + Short: "Send feedback", + Long: "Send feedback directly to the LocalStack team.", + RunE: func(cmd *cobra.Command, args []string) error { + sink := output.NewPlainSink(cmd.OutOrStdout()) + if !isInteractiveMode(cfg) { + return fmt.Errorf("feedback requires an interactive terminal") + } + message, confirmed, err := collectFeedbackInteractively(cmd, sink, cfg) + if err != nil { + return err + } + if !confirmed { + return nil + } + + if strings.TrimSpace(cfg.AuthToken) == "" { + return fmt.Errorf("feedback requires authentication") + } + client := feedback.NewClient(cfg.APIEndpoint) + submit := func(ctx context.Context, submitSink output.Sink) error { + submitSink.Emit(output.SpinnerStart("Submitting feedback")) + err := client.Submit(ctx, feedback.SubmitInput{ + Message: message, + AuthToken: cfg.AuthToken, + Context: buildFeedbackContext(cfg), + }) + submitSink.Emit(output.SpinnerStop()) + if err != nil { + return err + } + submitSink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: styles.Success.Render(output.SuccessMarker()) + " Thank you for your feedback!"}) + return nil + } + + err = ui.RunFeedback(cmd.Context(), submit) + if err != nil { + return err + } + return nil + }, + } + return cmd +} + +func collectFeedbackInteractively(cmd *cobra.Command, sink output.Sink, cfg *env.Env) (string, bool, error) { + file, ok := cmd.InOrStdin().(*os.File) + if !ok { + return "", false, fmt.Errorf("interactive feedback requires a terminal") + } + + sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "What's your feedback?"}) + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: styles.Secondary.Render("> Press enter to submit or esc to cancel")}) + + message, cancelled, err := readInteractiveLine(file, cmd.OutOrStdout()) + if err != nil { + return "", false, err + } + if cancelled { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: styles.Secondary.Render("Cancelled feedback submission")}) + return "", false, nil + } + if strings.TrimSpace(message) == "" { + return "", false, fmt.Errorf("feedback message cannot be empty") + } + + ctx := buildFeedbackContext(cfg) + emitInfo := func(text string) { + sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: text}) + } + emitInfo("") + emitInfo("This report will include:") + emitInfo("- Feedback: " + styles.Secondary.Render(message)) + emitInfo("- Version (lstk): " + styles.Secondary.Render(version.Version())) + emitInfo("- OS (arch): " + styles.Secondary.Render(fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH))) + emitInfo("- Installation: " + styles.Secondary.Render(orUnknown(ctx.InstallMethod))) + emitInfo("- Shell: " + styles.Secondary.Render(orUnknown(ctx.Shell))) + emitInfo("- Container runtime: " + styles.Secondary.Render(orUnknown(ctx.ContainerRuntime))) + emitInfo("- Auth: " + styles.Secondary.Render(authStatus(ctx.AuthConfigured))) + emitInfo("- Config: " + styles.Secondary.Render(orUnknown(ctx.ConfigPath))) + emitInfo("") + emitInfo(renderConfirmationPrompt("Confirm submitting this feedback?")) + + submit, err := readConfirmation(file, cmd.OutOrStdout()) + if err != nil { + return "", false, err + } + if !submit { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: styles.Secondary.Render("Cancelled feedback submission")}) + return "", false, nil + } + return message, true, nil +} + +func buildFeedbackContext(cfg *env.Env) feedback.Context { + configPath, _ := config.ConfigFilePath() + authConfigured := strings.TrimSpace(cfg.AuthToken) != "" + if !authConfigured { + if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, log.Nop()); err == nil { + if token, err := tokenStorage.GetAuthToken(); err == nil && strings.TrimSpace(token) != "" { + authConfigured = true + } + } + } + return feedback.Context{ + AuthConfigured: authConfigured, + InstallMethod: feedback.DetectInstallMethod(), + Shell: detectShell(), + ContainerRuntime: detectContainerRuntime(cfg), + ConfigPath: configPath, + } +} + +func detectShell() string { + shellPath := strings.TrimSpace(os.Getenv("SHELL")) + if shellPath == "" { + return "unknown" + } + return filepath.Base(shellPath) +} + +func authStatus(v bool) string { + if v { + return "Configured" + } + return "Not Configured" +} + +func detectContainerRuntime(cfg *env.Env) string { + if strings.TrimSpace(cfg.DockerHost) != "" { + return "docker" + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "docker" + } + + switch { + case fileExists(filepath.Join(homeDir, ".orbstack", "run", "docker.sock")): + return "orbstack" + case fileExists(filepath.Join(homeDir, ".colima", "default", "docker.sock")), + fileExists(filepath.Join(homeDir, ".colima", "docker.sock")): + return "colima" + default: + return "docker" + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func orUnknown(v string) string { + if strings.TrimSpace(v) == "" { + return "unknown" + } + return v +} + +func renderConfirmationPrompt(question string) string { + return styles.Secondary.Render("? ") + + styles.Message.Render(question) + + styles.Secondary.Render(" [Y/n]") +} + +func readInteractiveLine(in *os.File, out io.Writer) (string, bool, error) { + state, err := term.MakeRaw(int(in.Fd())) + if err != nil { + return "", false, err + } + defer func() { _ = term.Restore(int(in.Fd()), state) }() + + var buf []byte + scratch := make([]byte, 1) + for { + if _, err := in.Read(scratch); err != nil { + return "", false, err + } + switch scratch[0] { + case '\r', '\n': + _, _ = io.WriteString(out, "\r\n") + return strings.TrimSpace(string(buf)), false, nil + case 27: + cancelled, err := readEscapeSequence(in) + if err != nil { + return "", false, err + } + if !cancelled { + continue + } + _, _ = io.WriteString(out, "\r\n") + return "", true, nil + case 3: + _, _ = io.WriteString(out, "\r\n") + return "", true, nil + case 127, 8: + if len(buf) == 0 { + continue + } + buf = buf[:len(buf)-1] + _, _ = io.WriteString(out, "\b \b") + default: + if scratch[0] < 32 { + continue + } + buf = append(buf, scratch[0]) + _, _ = out.Write(scratch) + } + } +} + +func readConfirmation(in *os.File, out io.Writer) (bool, error) { + state, err := term.MakeRaw(int(in.Fd())) + if err != nil { + return false, err + } + defer func() { _ = term.Restore(int(in.Fd()), state) }() + + scratch := make([]byte, 1) + for { + if _, err := in.Read(scratch); err != nil { + return false, err + } + switch scratch[0] { + case '\r', '\n', 'y', 'Y': + _, _ = io.WriteString(out, "\r\n") + return true, nil + case 27: + cancelled, err := readEscapeSequence(in) + if err != nil { + return false, err + } + if !cancelled { + continue + } + _, _ = io.WriteString(out, "\r\n") + return false, nil + case 3, 'n', 'N': + _, _ = io.WriteString(out, "\r\n") + return false, nil + } + } +} diff --git a/cmd/feedback_escape_other.go b/cmd/feedback_escape_other.go new file mode 100644 index 00000000..e725ae7c --- /dev/null +++ b/cmd/feedback_escape_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux + +package cmd + +import "os" + +func readEscapeSequence(in *os.File) (bool, error) { + return true, nil +} diff --git a/cmd/feedback_escape_unix.go b/cmd/feedback_escape_unix.go new file mode 100644 index 00000000..55c99bcc --- /dev/null +++ b/cmd/feedback_escape_unix.go @@ -0,0 +1,27 @@ +//go:build darwin || linux + +package cmd + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func readEscapeSequence(in *os.File) (bool, error) { + fd := int(in.Fd()) + if err := unix.SetNonblock(fd, true); err != nil { + return false, err + } + defer func() { _ = unix.SetNonblock(fd, false) }() + + buf := make([]byte, 8) + n, err := unix.Read(fd, buf) + if err == unix.EAGAIN || err == unix.EWOULDBLOCK || n == 0 { + return true, nil + } + if err != nil { + return false, err + } + return false, nil +} diff --git a/cmd/feedback_test.go b/cmd/feedback_test.go new file mode 100644 index 00000000..3abc1efd --- /dev/null +++ b/cmd/feedback_test.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/telemetry" +) + +func TestFeedbackCommandAppearsInHelp(t *testing.T) { + out, err := executeWithArgs(t, "--help") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertContains(t, out, "feedback") +} + +func TestFeedbackCommandRequiresInteractiveTerminal(t *testing.T) { + root := NewRootCmd(&env.Env{ + NonInteractive: true, + AuthToken: "Bearer auth-token", + }, telemetry.New("", true), log.Nop()) + root.SetArgs([]string{"feedback"}) + + err := root.ExecuteContext(context.Background()) + if err == nil || err.Error() != "feedback requires an interactive terminal" { + t.Fatalf("expected interactive terminal error, got %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 0c156803..3e5a895e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -125,6 +125,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newResetCmd(cfg), newSaveCmd(cfg), newLoadCmd(cfg, tel, logger), + newFeedbackCmd(cfg, tel), } for _, c := range commands { c.GroupID = groupCommands diff --git a/internal/feedback/client.go b/internal/feedback/client.go new file mode 100644 index 00000000..bd891a58 --- /dev/null +++ b/internal/feedback/client.go @@ -0,0 +1,124 @@ +package feedback + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "runtime" + "strings" + "time" + + "github.com/localstack/lstk/internal/update" + "github.com/localstack/lstk/internal/version" +) + +const DefaultAPIEndpoint = "https://api.localstack.cloud" + +type Client struct { + httpClient *http.Client + endpoint string +} + +type SubmitInput struct { + Message string + AuthToken string + Context Context +} + +type Context struct { + AuthConfigured bool + InstallMethod string + Shell string + ContainerRuntime string + ConfigPath string +} + +type submitRequest struct { + Message string `json:"message"` + Metadata map[string]any `json:"metadata"` +} + +func NewClient(endpoint string) *Client { + if endpoint == "" { + endpoint = DefaultAPIEndpoint + } + return &Client{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + endpoint: strings.TrimRight(endpoint, "/"), + } +} + +func (c *Client) Submit(ctx context.Context, input SubmitInput) error { + body := submitRequest{ + Message: strings.TrimSpace(input.Message), + Metadata: buildMetadata(input.Context), + } + data, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/feedback", bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", basicAuthToken(input.AuthToken)) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusCreated { + detail := strings.TrimSpace(string(respBody)) + if detail == "" { + return fmt.Errorf("feedback API returned %s", resp.Status) + } + return fmt.Errorf("feedback API returned %s: %s", resp.Status, detail) + } + return nil +} + +func buildMetadata(ctx Context) map[string]any { + return map[string]any{ + "version (lstk)": version.Version(), + "os (arch)": runtime.GOOS + " (" + runtime.GOARCH + ")", + "installation": orUnknown(ctx.InstallMethod), + "shell": orUnknown(ctx.Shell), + "container runtime": orUnknown(ctx.ContainerRuntime), + "auth": authLabel(ctx.AuthConfigured), + "config": orUnknown(ctx.ConfigPath), + } +} + +func authLabel(v bool) string { + if v { + return "Configured" + } + return "Not Configured" +} + +func orUnknown(v string) string { + if strings.TrimSpace(v) == "" { + return "unknown" + } + return v +} + +func DetectInstallMethod() string { + return update.DetectInstallMethod().Method.String() +} + +func basicAuthToken(token string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+token)) +} diff --git a/internal/feedback/client_test.go b/internal/feedback/client_test.go new file mode 100644 index 00000000..4035de8b --- /dev/null +++ b/internal/feedback/client_test.go @@ -0,0 +1,91 @@ +package feedback + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/localstack/lstk/internal/version" +) + +func TestSubmitPostsFeedbackToPlatformAPI(t *testing.T) { + t.Parallel() + + var authHeader string + var payload submitRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/feedback" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + authHeader = r.Header.Get("Authorization") + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode body: %v", err) + } + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + client := NewClient(srv.URL) + err := client.Submit(context.Background(), SubmitInput{ + Message: "Something feels off when starting LocalStack", + AuthToken: "auth-token", + Context: Context{ + AuthConfigured: true, + InstallMethod: "homebrew", + Shell: "zsh", + ContainerRuntime: "orbstack", + ConfigPath: "/tmp/config.toml", + }, + }) + if err != nil { + t.Fatalf("Submit() error = %v", err) + } + + if authHeader != "Basic OmF1dGgtdG9rZW4=" { + t.Fatalf("expected basic auth token, got %q", authHeader) + } + if payload.Message != "Something feels off when starting LocalStack" { + t.Fatalf("unexpected message %q", payload.Message) + } + assertEqual(t, payload.Metadata["version (lstk)"], version.Version()) + assertEqual(t, payload.Metadata["os (arch)"], runtime.GOOS+" ("+runtime.GOARCH+")") + assertEqual(t, payload.Metadata["installation"], "homebrew") + assertEqual(t, payload.Metadata["shell"], "zsh") + assertEqual(t, payload.Metadata["container runtime"], "orbstack") + assertEqual(t, payload.Metadata["auth"], "Configured") + assertEqual(t, payload.Metadata["config"], "/tmp/config.toml") +} + +func TestSubmitReturnsPlatformError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":true,"message":"generic.bad_request"}`, http.StatusBadRequest) + })) + defer srv.Close() + + client := NewClient(srv.URL) + err := client.Submit(context.Background(), SubmitInput{ + Message: "hello", + AuthToken: "auth-token", + }) + if err == nil || err.Error() != `feedback API returned 400 Bad Request: {"error":true,"message":"generic.bad_request"}` { + t.Fatalf("expected platform error, got %v", err) + } +} + +func assertEqual(t *testing.T, got, want any) { + t.Helper() + if got != want { + t.Fatalf("expected %v, got %v", want, got) + } +} diff --git a/internal/ui/run_feedback.go b/internal/ui/run_feedback.go new file mode 100644 index 00000000..c3c45f4d --- /dev/null +++ b/internal/ui/run_feedback.go @@ -0,0 +1,45 @@ +package ui + +import ( + "context" + "errors" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/output" +) + +func RunFeedback(parentCtx context.Context, submit func(context.Context, output.Sink) error) error { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + app := NewApp("", "", "", cancel, withoutHeader()) + p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout)) + runErrCh := make(chan error, 1) + + go func() { + err := submit(ctx, output.NewTUISink(programSender{p: p})) + runErrCh <- err + if err != nil && !errors.Is(err, context.Canceled) { + p.Send(runErrMsg{err: err}) + return + } + p.Send(runDoneMsg{}) + }() + + model, err := p.Run() + if err != nil { + return err + } + + if app, ok := model.(App); ok && app.Err() != nil { + return output.NewSilentError(app.Err()) + } + + runErr := <-runErrCh + if runErr != nil && !errors.Is(runErr, context.Canceled) { + return runErr + } + + return nil +} diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 3aeb8b1d..1a61ea98 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -18,6 +18,7 @@ const ( DisableEvents Key = "LOCALSTACK_DISABLE_EVENTS" Home Key = "HOME" UserProfile Key = "USERPROFILE" + TermProgram Key = "TERM_PROGRAM" Persistence Key = "LOCALSTACK_PERSISTENCE" Otel Key = "LSTK_OTEL" OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT" diff --git a/test/integration/feedback_test.go b/test/integration/feedback_test.go new file mode 100644 index 00000000..af7ee456 --- /dev/null +++ b/test/integration/feedback_test.go @@ -0,0 +1,97 @@ +package integration_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/creack/pty" + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFeedbackCommandPromptsInInteractiveMode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/feedback", r.URL.Path) + require.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Basic ")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var payload struct { + Message string `json:"message"` + Metadata map[string]any `json:"metadata"` + } + require.NoError(t, json.Unmarshal(body, &payload)) + require.Equal(t, "The login flow should mention keyring precedence", payload.Message) + require.Equal(t, "Configured", payload.Metadata["auth"]) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + binPath, err := filepath.Abs(binaryPath()) + require.NoError(t, err) + + cmd := exec.CommandContext(ctx, binPath, "feedback") + cmd.Env = env.Without(env.APIEndpoint, env.AuthToken). + With(env.APIEndpoint, srv.URL). + With(env.AuthToken, "auth-token"). + With(env.TermProgram, "ghostty") + + ptmx, err := pty.Start(cmd) + require.NoError(t, err) + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + done := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(done) + }() + + require.Eventually(t, func() bool { + return strings.Contains(out.String(), "What's your feedback?") + }, 10*time.Second, 100*time.Millisecond) + + _, err = ptmx.Write([]byte("The login flow should mention keyring precedence\n")) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return strings.Contains(out.String(), "This report will include:") + }, 10*time.Second, 100*time.Millisecond) + + assert.Contains(t, out.String(), "Press enter to submit or esc to cancel") + assert.Contains(t, out.String(), "Feedback: The login flow should mention keyring precedence") + assert.Contains(t, out.String(), "Version (lstk):") + assert.Contains(t, out.String(), "OS (arch): darwin") + assert.Contains(t, out.String(), "Installation:") + assert.Contains(t, out.String(), "Shell:") + assert.Contains(t, out.String(), "Container runtime:") + assert.Contains(t, out.String(), "Auth:") + assert.Contains(t, out.String(), "Config:") + assert.Contains(t, out.String(), "Confirm submitting this feedback?") + assert.Contains(t, out.String(), "[Y/n]") + + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return strings.Contains(out.String(), "Thank you for your feedback!") + }, 10*time.Second, 100*time.Millisecond) + assert.Contains(t, out.String(), "Submitting feedback") + assert.NotContains(t, out.String(), "Feedback ID:") + + require.NoError(t, cmd.Wait()) + _ = ptmx.Close() + <-done +}