package main import ( "bytes" "os" "os/exec" "path/filepath" "reflect" "sort" "strings" "testing" "github.com/google/shlex" ) // ============================================================================= // Direct tests for shlex parsing (the Phase 1.1 improvement) // ============================================================================= func TestShlexParsing_SingleQuotes(t *testing.T) { args, err := shlex.Split("echo 'hello world'") if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 2 { t.Fatalf("expected 2 args, got %d: %v", len(args), args) } if args[0] != "echo" { t.Errorf("expected 'echo', got '%s'", args[0]) } if args[1] != "hello world" { t.Errorf("expected 'hello world', got '%s'", args[1]) } } func TestShlexParsing_DoubleQuotes(t *testing.T) { args, err := shlex.Split(`echo "hello world"`) if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 2 { t.Fatalf("expected 2 args, got %d: %v", len(args), args) } if args[0] != "echo" { t.Errorf("expected 'echo', got '%s'", args[0]) } if args[1] != "hello world" { t.Errorf("expected 'hello world', got '%s'", args[1]) } } func TestShlexParsing_MixedQuotes(t *testing.T) { args, err := shlex.Split(`echo 'single' "double" unquoted`) if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 4 { t.Fatalf("expected 4 args, got %d: %v", len(args), args) } if args[0] != "echo" { t.Errorf("expected 'echo', got '%s'", args[0]) } if args[1] != "single" { t.Errorf("expected 'single', got '%s'", args[1]) } if args[2] != "double" { t.Errorf("expected 'double', got '%s'", args[2]) } if args[3] != "unquoted" { t.Errorf("expected 'unquoted', got '%s'", args[3]) } } func TestShlexParsing_EscapedSpaces(t *testing.T) { args, err := shlex.Split("echo file\\ with\\ spaces") if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 2 { t.Fatalf("expected 2 args, got %d: %v", len(args), args) } if args[0] != "echo" { t.Errorf("expected 'echo', got '%s'", args[0]) } if args[1] != "file with spaces" { t.Errorf("expected 'file with spaces', got '%s'", args[1]) } } func TestShlexParsing_NestedQuotes(t *testing.T) { args, err := shlex.Split(`echo "hello \"world\""`) if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 2 { t.Fatalf("expected 2 args, got %d: %v", len(args), args) } if args[0] != "echo" { t.Errorf("expected 'echo', got '%s'", args[0]) } if args[1] != `hello "world"` { t.Errorf("expected 'hello \"world\"', got '%s'", args[1]) } } func TestShlexParsing_MultipleSpaces(t *testing.T) { args, err := shlex.Split("echo arg1 arg2") if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 3 { t.Fatalf("expected 3 args, got %d: %v", len(args), args) } if args[0] != "echo" { t.Errorf("expected 'echo', got '%s'", args[0]) } if args[1] != "arg1" { t.Errorf("expected 'arg1', got '%s'", args[1]) } if args[2] != "arg2" { t.Errorf("expected 'arg2', got '%s'", args[2]) } } func TestShlexParsing_Empty(t *testing.T) { args, err := shlex.Split("") if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 0 { t.Fatalf("expected 0 args for empty string, got %d: %v", len(args), args) } } func TestShlexParsing_WhitespaceOnly(t *testing.T) { args, err := shlex.Split(" ") if err != nil { t.Fatalf("shlex.Split failed: %v", err) } if len(args) != 0 { t.Fatalf("expected 0 args for whitespace, got %d: %v", len(args), args) } } func TestShlexParsing_InvalidUnclosedQuote(t *testing.T) { _, err := shlex.Split("echo 'unclosed") if err == nil { t.Error("expected error for unclosed quote, got nil") } } func TestShlexParsing_InvalidUnclosedDoubleQuote(t *testing.T) { _, err := shlex.Split(`echo "unclosed`) if err == nil { t.Error("expected error for unclosed double quote, got nil") } } // ============================================================================= // Tests for execInput with controlled environment // ============================================================================= func TestExecInput_CdNoArgs(t *testing.T) { // cd with no args now goes to home directory // Save current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) err := execInput("cd") if err != nil { t.Errorf("cd with no args should go to home, got error: %v", err) } // Verify we changed to home directory home := getHomeDir() newDir, _ := os.Getwd() if newDir != home { t.Errorf("expected to be in home dir %s, but in %s", home, newDir) } } func TestExecInput_EmptyInput(t *testing.T) { err := execInput("") if err != nil { t.Errorf("expected nil for empty input, got %v", err) } } func TestExecInput_WhitespaceInput(t *testing.T) { err := execInput(" ") if err != nil { t.Errorf("expected nil for whitespace input, got %v", err) } } func TestErrNoPath(t *testing.T) { if ErrNoPath.Error() != "path required" { t.Errorf("expected 'path required', got '%s'", ErrNoPath.Error()) } } // ============================================================================= // Integration tests using subprocess (minimal, run sequentially) // ============================================================================= func TestShell_EndToEnd(t *testing.T) { // Test basic shell interaction tests := []struct { name string input string expectOutput string expectError bool }{ {"echo simple", "echo hello\nquit\n", "hello", false}, {"echo quoted", "echo 'hello world'\nquit\n", "hello world", false}, {"pwd", "pwd\nquit\n", "", false}, // pwd output depends on current dir {"cd to home", "cd\npwd\nquit\n", "", false}, {"cd with tilde", "cd ~\npwd\nquit\n", "", false}, {"quit", "quit\n", "", false}, {"exit", "exit\n", "", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader(tc.input) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr _ = cmd.Run() output := stdout.String() if tc.expectOutput != "" && !strings.Contains(output, tc.expectOutput) { t.Errorf("%s: expected output to contain '%s', got: %s", tc.name, tc.expectOutput, output) } if tc.expectError { combined := output + stderr.String() if !strings.Contains(combined, "path required") && !strings.Contains(combined, "error") { t.Errorf("%s: expected error, got stdout: %s, stderr: %s", tc.name, output, stderr.String()) } } }) } } // ============================================================================= // Tests for Phase 1.2: Graceful Ctrl+D and Ctrl+C handling // ============================================================================= func TestShell_EOF_ExitsGracefully(t *testing.T) { // Simulate EOF by sending empty input // Note: When using piped input, readline doesn't print prompts // So we just verify the shell exits cleanly cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() // Shell should exit cleanly on EOF if err != nil { t.Logf("Shell exited with: %v", err) } // When using piped input, readline doesn't print prompts // So we just check that it exited cleanly _ = stdout.String() } func TestShell_EOF_AfterCommand(t *testing.T) { // Test that EOF after a command works // Note: When using piped input, readline doesn't print prompts cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("echo test\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("Shell exited with: %v", err) } output := stdout.String() // Should contain the echo output if !strings.Contains(output, "test") { t.Errorf("expected 'test' in output, got: %s", output) } // Note: With piped input, readline doesn't print prompts // In a real TTY, the prompt would be shown } func TestShell_SIGINT_HandlerSetup(t *testing.T) { // This test verifies that the shell doesn't panic when receiving signals // We can't easily test SIGINT handling from a test, but we can verify // the shell runs without crashing cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("echo test\nquit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String()) } output := stdout.String() if !strings.Contains(output, "test") { t.Errorf("expected 'test' in output, got: %s", output) } } // ============================================================================= // Tests for Phase 1.3: cd to home, ~ expansion, cd - // ============================================================================= func TestExecInput_CdToHomeWithTilde(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) // Test cd ~ err := execInput("cd ~") if err != nil { t.Errorf("cd ~ failed: %v", err) } home := getHomeDir() newDir, _ := os.Getwd() if newDir != home { t.Errorf("expected to be in home dir %s, but in %s", home, newDir) } } func TestExecInput_CdWithTildePath(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) // Test cd ~/ (this should go to home) // But we can't easily test this without knowing the home structure // So let's just test that tilde expansion doesn't cause errors err := execInput("cd ~") if err != nil { t.Errorf("cd ~ failed: %v", err) } } func TestExecInput_CdDash(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) // First, cd to a temp dir tmpDir := t.TempDir() err := execInput("cd " + tmpDir) if err != nil { t.Fatalf("cd to tmpDir failed: %v", err) } // Now cd - should go back err = execInput("cd -") if err != nil { t.Fatalf("cd - failed: %v", err) } // Verify we're back in origDir newDir, _ := os.Getwd() if newDir != origDir { t.Errorf("cd -: expected to be in %s, but in %s", origDir, newDir) } } func TestExecInput_CdDashNoPrevious(t *testing.T) { // Reset previousDir for this test origPrevDir := previousDir defer func() { previousDir = origPrevDir }() previousDir = "" // cd - with no previous directory should return an error err := execInput("cd -") if err == nil { t.Error("cd - with no previous directory: expected error, got nil") } if !strings.Contains(err.Error(), "no previous directory") { t.Errorf("expected 'no previous directory' error, got: %v", err) } } // ============================================================================= // Tests for Phase 2.1: Dynamic Prompt // ============================================================================= func TestGetPrompt_Format(t *testing.T) { prompt := getPrompt() // Should contain username@hostname:path $ pattern if !strings.Contains(prompt, "@") { t.Errorf("expected @ in prompt, got: %s", prompt) } if !strings.Contains(prompt, ":") { t.Errorf("expected : in prompt, got: %s", prompt) } if !strings.Contains(prompt, "$") { t.Errorf("expected $ in prompt, got: %s", prompt) } } func TestGetPrompt_IncludesHome(t *testing.T) { // Save and restore current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) // Change to home directory os.Chdir(getHomeDir()) prompt := getPrompt() // When in home, should show ~ if !strings.Contains(prompt, "~") { t.Errorf("expected ~ in prompt when in home dir, got: %s", prompt) } } // ============================================================================= // Tests for Phase 2.2: Exit Status Code // ============================================================================= func TestExecInput_ExitWithStatusCode(t *testing.T) { // We can't easily test os.Exit from a unit test // So we just verify the function doesn't panic with a status code // The actual exit behavior is tested in integration tests // This test just documents the expected behavior // Actual testing requires subprocess _ = true // Placeholder - integration tests handle this } func TestShell_ExitWithStatusCode(t *testing.T) { // Test exit with custom status code // Note: We build the binary first because 'go run' doesn't preserve exit codes properly tests := []struct { input string expected int }{ {"exit\n", 0}, {"exit 0\n", 0}, {"exit 1\n", 1}, {"exit 42\n", 42}, {"quit\n", 0}, {"quit 0\n", 0}, {"quit 5\n", 5}, } // Build the binary once build := exec.Command("go", "build", "-o", "gosh_test", "main.go") if err := build.Run(); err != nil { t.Fatalf("failed to build: %v", err) } defer os.Remove("gosh_test") for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { cmd := exec.Command("./gosh_test") cmd.Stdin = strings.NewReader(tc.input) err := cmd.Run() if err == nil && tc.expected != 0 { t.Errorf("expected non-zero exit code %d, got 0", tc.expected) } if err != nil { // Check the exit code if exitErr, ok := err.(*exec.ExitError); ok { actualCode := exitErr.ExitCode() if actualCode != tc.expected { t.Errorf("expected exit code %d, got %d", tc.expected, actualCode) } } else { // Not an ExitError, might be another error t.Logf("got error: %v", err) } } }) } } // ============================================================================= // Tests for Phase 2.3: Environment Variable Expansion // ============================================================================= func TestShell_EnvExpansion_Home(t *testing.T) { cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("echo $HOME\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("shell exited with: %v, stderr: %s", err, stderr.String()) } output := stdout.String() home := getHomeDir() if !strings.Contains(output, home) { t.Errorf("expected home dir %s in output, got: %s", home, output) } } func TestShell_EnvExpansion_Path(t *testing.T) { cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("echo $PATH\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("shell exited with: %v, stderr: %s", err, stderr.String()) } output := stdout.String() // PATH should contain : (path separator) if !strings.Contains(output, ":") { t.Errorf("expected PATH with : separator in output, got: %s", output) } } func TestShell_EnvExpansion_Braces(t *testing.T) { cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("echo ${HOME}\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("shell exited with: %v, stderr: %s", err, stderr.String()) } output := stdout.String() home := getHomeDir() if !strings.Contains(output, home) { t.Errorf("expected home dir %s in output with ${HOME}, got: %s", home, output) } } func TestShell_EnvExpansion_CdWithVar(t *testing.T) { cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("cd $HOME\npwd\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("shell exited with: %v, stderr: %s", err, stderr.String()) } output := stdout.String() home := getHomeDir() if !strings.Contains(output, home) { t.Errorf("expected home dir %s in pwd output, got: %s", home, output) } } func TestShell_TildeExpansion_Ls(t *testing.T) { cmd := exec.Command("go", "run", "main.go") cmd.Stdin = strings.NewReader("ls ~\nexit\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Logf("shell exited with: %v, stderr: %s", err, stderr.String()) } output := stdout.String() // Should contain files from home directory // We can't predict exact files, but it should have some output if strings.TrimSpace(output) == "" { t.Errorf("expected output from ls ~, got empty") } } // ============================================================================= // Tests for Phase 2.5, 2.6, 2.7: set/export commands, help // ============================================================================= func TestExecInput_SetEnvVar(t *testing.T) { // Save original value orig := os.Getenv("GOSH_TEST_VAR") defer os.Setenv("GOSH_TEST_VAR", orig) // Set a new variable err := execInput("set GOSH_TEST_VAR=test_value") if err != nil { t.Errorf("set failed: %v", err) } // Verify it was set val := os.Getenv("GOSH_TEST_VAR") if val != "test_value" { t.Errorf("expected 'test_value', got '%s'", val) } } func TestExecInput_ExportEnvVar(t *testing.T) { // Save original value orig := os.Getenv("GOSH_TEST_VAR2") defer os.Setenv("GOSH_TEST_VAR2", orig) // Export a new variable err := execInput("export GOSH_TEST_VAR2=exported_value") if err != nil { t.Errorf("export failed: %v", err) } // Verify it was set val := os.Getenv("GOSH_TEST_VAR2") if val != "exported_value" { t.Errorf("expected 'exported_value', got '%s'", val) } } func TestExecInput_SetNoArgs(t *testing.T) { err := execInput("set") if err == nil { t.Error("expected error for set with no args, got nil") } if !strings.Contains(err.Error(), "usage: set VAR=value") { t.Errorf("expected usage error, got: %v", err) } } func TestExecInput_ExportNoArgs(t *testing.T) { err := execInput("export") if err == nil { t.Error("expected error for export with no args, got nil") } if !strings.Contains(err.Error(), "usage: export VAR=value") { t.Errorf("expected usage error, got: %v", err) } } func TestExecInput_Help(t *testing.T) { // Just verify help doesn't return an error // We can't easily capture stdout in a unit test without a pipe // but we can verify it doesn't crash err := execInput("help") if err != nil { t.Errorf("help failed: %v", err) } // If we get here without panicking, help command works } // ============================================================================= // Tests for Command Completion (Phase 3: Tab Completion) // ============================================================================= func TestFilePathCompleter_CurrentDirectory(t *testing.T) { // Save and restore current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) // Create a temp directory with known files tmpDir := t.TempDir() os.Chdir(tmpDir) // Create test files and directories os.Create("file_a.txt") os.Create("file_b.txt") os.Create("file_c.txt") os.Mkdir("dir_a", 0755) os.Mkdir("dir_b", 0755) // Test completion for "ls file_" // Extract the prefix (last word after space) line := "ls file_" prefix := "file_" matches := completeFilePath(line, prefix) if len(matches) == 0 { t.Error("expected matches for 'ls file_', got none") } // All matches should contain "file_" for _, m := range matches { if !strings.Contains(m, "file_") { t.Errorf("match %s doesn't contain 'file_'", m) } } } func TestFilePathCompleter_AbsolutePath(t *testing.T) { // Create a temp directory with known structure tmpDir := t.TempDir() os.Mkdir(filepath.Join(tmpDir, "testdir"), 0755) os.Create(filepath.Join(tmpDir, "testdir", "file1.txt")) os.Create(filepath.Join(tmpDir, "testdir", "file2.txt")) // Test completion for absolute path line := "ls " + tmpDir + "/te" prefix := tmpDir + "/te" matches := completeFilePath(line, prefix) if len(matches) == 0 { t.Error("expected matches for absolute path, got none") } // All matches should contain "testdir" found := false for _, m := range matches { if strings.Contains(m, "testdir") { found = true break } } if !found { t.Errorf("expected match containing 'testdir', got: %v", matches) } } func TestFilePathCompleter_TildeExpansion(t *testing.T) { // Save and restore current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) home := getHomeDir() if home == "" { t.Skip("skipping: HOME directory not available") } // Create a test file in home if possible // (we can't always write to home, so we'll just test the completer logic) matches := completeFilePath("ls ~/", "~/") // Just verify it doesn't panic - actual matches depend on home directory contents _ = matches } func TestFilePathCompleter_DirectoriesWithTrailingSlash(t *testing.T) { // Save and restore current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) tmpDir := t.TempDir() os.Chdir(tmpDir) // Create a subdirectory os.Mkdir("subdir", 0755) os.Create("subdir/file.txt") // Test completion when the prefix already has a trailing slash matches := completeFilePath("ls subdir/", "subdir/") if len(matches) == 0 { t.Error("expected matches for 'ls subdir/', got none") } // Matches should include the file in the subdirectory found := false for _, m := range matches { if strings.Contains(m, "file.txt") { found = true break } } if !found { t.Errorf("expected match containing 'file.txt', got: %v", matches) } } func TestFilePathCompleter_NoMatches(t *testing.T) { // Save and restore current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) tmpDir := t.TempDir() os.Chdir(tmpDir) // Test completion for a prefix that won't match anything matches := completeFilePath("ls xyz_nonexistent_", "xyz_nonexistent_") if len(matches) != 0 { t.Errorf("expected no matches for non-existent prefix, got: %v", matches) } } // completeLine runs the completer on line (cursor at the end) and returns the // candidates as strings together with the reported length. func completeLine(line string) ([]string, int) { cands, length := newShellCompleter().Do([]rune(line), len([]rune(line))) var out []string for _, c := range cands { out = append(out, string(c)) } sort.Strings(out) return out, length } func TestShellCompleter_Do(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) tmpDir := t.TempDir() os.Chdir(tmpDir) os.Create("main.go") os.Create("my file.txt") os.Create(".hidden") os.Mkdir("subdir", 0755) os.Create("subdir/inner.txt") // Controlled PATH with one executable, one non-executable and a // duplicate of a builtin binDir := t.TempDir() os.WriteFile(filepath.Join(binDir, "gitty"), nil, 0755) os.WriteFile(filepath.Join(binDir, "girl"), nil, 0644) os.WriteFile(filepath.Join(binDir, "echo"), nil, 0755) os.Mkdir(filepath.Join(binDir, "gidir"), 0755) t.Setenv("PATH", binDir) tests := []struct { line string want []string length int }{ {"c", []string{"d "}, 1}, {"e", []string{"cho ", "xit ", "xport "}, 1}, {"gi", []string{"tty "}, 2}, {" gi", []string{"tty "}, 2}, {"ls gi", nil, 2}, {"ls ma", []string{"in.go"}, 2}, {"ls su", []string{"bdir/"}, 2}, {"ls subdir/", []string{"inner.txt"}, 0}, {"ls subdir/in", []string{"ner.txt"}, 2}, {"ls my", []string{`\ file.txt`}, 2}, {`ls my\ f`, []string{"ile.txt"}, 4}, {`ls "my f`, []string{"ile.txt"}, 4}, {"ls ./ma", []string{"in.go"}, 2}, {"ls .h", []string{"idden"}, 2}, {"ls ", []string{"main.go", `my\ file.txt`, "subdir/"}, 0}, {"ls " + tmpDir + "/ma", []string{"in.go"}, 2}, {"ls xyz", nil, 3}, {"ls ~", []string{"/"}, 1}, } for _, tt := range tests { got, length := completeLine(tt.line) if !reflect.DeepEqual(got, tt.want) || length != tt.length { t.Errorf("Do(%q) = %q, %d; want %q, %d", tt.line, got, length, tt.want, tt.length) } } }