mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 03:03:32 +01:00
fix: avoid stdin buffering bug in installer prompts
Prompt() created a new bufio.NewReader(os.Stdin) on each call, which buffered all piped input on the first call and left nothing for subsequent prompts. This made cheat un-scriptable (e.g., piping answers via printf). Fix by reading one byte at a time from os.Stdin directly. Also adds an end-to-end integration test for the first-run experience (regression test for #721, #771, #730) and bumps the Dockerfile to Go 1.26. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# NB: this image isn't used anywhere in the build pipeline. It exists to
|
# NB: this image isn't used anywhere in the build pipeline. It exists to
|
||||||
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
|
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
|
||||||
# during development.
|
# during development.
|
||||||
FROM golang:1.15-alpine
|
FROM golang:1.26-alpine
|
||||||
|
|
||||||
RUN apk add git less make
|
RUN apk add git less make
|
||||||
|
|
||||||
|
|||||||
180
cmd/cheat/first_run_integration_test.go
Normal file
180
cmd/cheat/first_run_integration_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFirstRunIntegration exercises the end-to-end first-run experience:
|
||||||
|
// no config exists, the binary creates one, and subsequent runs succeed.
|
||||||
|
// This is the regression test for issues #721, #771, and #730.
|
||||||
|
func TestFirstRunIntegration(t *testing.T) {
|
||||||
|
// Build the cheat binary
|
||||||
|
binName := "cheat_test"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
binName += ".exe"
|
||||||
|
}
|
||||||
|
binPath := filepath.Join(t.TempDir(), binName)
|
||||||
|
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("decline config creation", func(t *testing.T) {
|
||||||
|
testHome := t.TempDir()
|
||||||
|
env := firstRunEnv(testHome)
|
||||||
|
|
||||||
|
cmd := exec.Command(binPath)
|
||||||
|
cmd.Env = env
|
||||||
|
cmd.Stdin = strings.NewReader("n\n")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cheat exited with error: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no config was created
|
||||||
|
if firstRunConfigExists(testHome) {
|
||||||
|
t.Error("config file was created despite user declining")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("accept config decline community", func(t *testing.T) {
|
||||||
|
testHome := t.TempDir()
|
||||||
|
env := firstRunEnv(testHome)
|
||||||
|
|
||||||
|
// First run: yes to create config, no to community cheatsheets
|
||||||
|
cmd := exec.Command(binPath)
|
||||||
|
cmd.Env = env
|
||||||
|
cmd.Stdin = strings.NewReader("y\nn\n")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first run failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
outStr := string(output)
|
||||||
|
|
||||||
|
// Parse the config path from output
|
||||||
|
confpath := parseCreatedConfPath(t, outStr)
|
||||||
|
if confpath == "" {
|
||||||
|
t.Fatalf("could not find config path in output:\n%s", outStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify config file exists
|
||||||
|
if _, err := os.Stat(confpath); os.IsNotExist(err) {
|
||||||
|
t.Fatalf("config file not found at %s", confpath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify community cheatpath is commented out in config
|
||||||
|
content, err := os.ReadFile(confpath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read config: %v", err)
|
||||||
|
}
|
||||||
|
contentStr := string(content)
|
||||||
|
for _, line := range strings.Split(contentStr, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "- name: community" {
|
||||||
|
t.Error("community cheatpath should be commented out")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify personal and work directories were created
|
||||||
|
confdir := filepath.Dir(confpath)
|
||||||
|
for _, name := range []string{"personal", "work"} {
|
||||||
|
dir := filepath.Join(confdir, "cheatsheets", name)
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected %s directory at %s", name, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community directory should NOT exist
|
||||||
|
communityDir := filepath.Join(confdir, "cheatsheets", "community")
|
||||||
|
if _, err := os.Stat(communityDir); err == nil {
|
||||||
|
t.Error("community directory should not exist when declined")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Second run: verify the config loads successfully ---
|
||||||
|
// This is the core regression test for #721/#771/#730:
|
||||||
|
// previously, the second run would fail because config.New()
|
||||||
|
// hard-errored on the missing community cheatpath directory.
|
||||||
|
// Use --directories (not --list, which exits 2 when no sheets exist).
|
||||||
|
cmd2 := exec.Command(binPath, "--directories")
|
||||||
|
cmd2.Env = append(append([]string{}, env...), "CHEAT_CONFIG_PATH="+confpath)
|
||||||
|
output2, err := cmd2.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"second run failed (regression for #721/#771/#730): %v\nOutput: %s",
|
||||||
|
err, output2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the output lists the expected cheatpaths
|
||||||
|
outStr2 := string(output2)
|
||||||
|
if !strings.Contains(outStr2, "personal") {
|
||||||
|
t.Errorf("expected 'personal' cheatpath in --directories output:\n%s", outStr2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(outStr2, "work") {
|
||||||
|
t.Errorf("expected 'work' cheatpath in --directories output:\n%s", outStr2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstRunEnv returns a minimal environment for a clean first-run test.
|
||||||
|
func firstRunEnv(home string) []string {
|
||||||
|
env := []string{
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
env = append(env,
|
||||||
|
"APPDATA="+filepath.Join(home, "AppData", "Roaming"),
|
||||||
|
"USERPROFILE="+home,
|
||||||
|
"SystemRoot="+os.Getenv("SystemRoot"),
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
env = append(env,
|
||||||
|
"HOME="+home,
|
||||||
|
"EDITOR=vi",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCreatedConfPath extracts the config file path from the installer's
|
||||||
|
// "Created config file: <path>" output. The message may appear mid-line
|
||||||
|
// (after prompt text), so we search for the substring anywhere in the output.
|
||||||
|
func parseCreatedConfPath(t *testing.T, output string) string {
|
||||||
|
t.Helper()
|
||||||
|
const marker = "Created config file: "
|
||||||
|
idx := strings.Index(output, marker)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := output[idx+len(marker):]
|
||||||
|
// the path ends at the next newline
|
||||||
|
if nl := strings.IndexByte(rest, '\n'); nl >= 0 {
|
||||||
|
rest = rest[:nl]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstRunConfigExists checks whether a cheat config file exists under the
|
||||||
|
// given home directory at any of the standard locations.
|
||||||
|
func firstRunConfigExists(home string) bool {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(home, ".config", "cheat", "conf.yml"),
|
||||||
|
filepath.Join(home, ".cheat", "conf.yml"),
|
||||||
|
filepath.Join(home, "AppData", "Roaming", "cheat", "conf.yml"),
|
||||||
|
}
|
||||||
|
for _, p := range candidates {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
package installer
|
package installer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,20 +11,34 @@ import (
|
|||||||
// Prompt prompts the user for a answer
|
// Prompt prompts the user for a answer
|
||||||
func Prompt(prompt string, def bool) (bool, error) {
|
func Prompt(prompt string, def bool) (bool, error) {
|
||||||
|
|
||||||
// initialize a line reader
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
|
|
||||||
// display the prompt
|
// display the prompt
|
||||||
fmt.Printf("%s: ", prompt)
|
fmt.Printf("%s: ", prompt)
|
||||||
|
|
||||||
// read the answer
|
// read one byte at a time until newline to avoid buffering past the
|
||||||
ans, err := reader.ReadString('\n')
|
// end of the current line, which would consume input intended for
|
||||||
|
// subsequent Prompt calls on the same stdin
|
||||||
|
var line []byte
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if buf[0] == '\n' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if buf[0] != '\r' {
|
||||||
|
line = append(line, buf[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to parse input: %v", err)
|
if len(line) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to prompt: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize the answer
|
// normalize the answer
|
||||||
ans = strings.ToLower(strings.TrimSpace(ans))
|
ans := strings.ToLower(strings.TrimSpace(string(line)))
|
||||||
|
|
||||||
// return the appropriate response
|
// return the appropriate response
|
||||||
switch ans {
|
switch ans {
|
||||||
|
|||||||
@@ -154,8 +154,8 @@ func TestPromptError(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error when reading from closed stdin, got nil")
|
t.Error("expected error when reading from closed stdin, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "failed to parse input") {
|
if !strings.Contains(err.Error(), "failed to prompt") {
|
||||||
t.Errorf("expected 'failed to parse input' error, got: %v", err)
|
t.Errorf("expected 'failed to prompt' error, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user