package main import ( "bytes" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "reflect" "sort" "strings" "syscall" "testing" "time" "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", ".") 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", ".") 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", ".") 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", ".") 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", ".") 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", ".") 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", ".") 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", ".") 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", ".") 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", ".") 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) { // set without arguments shows the variables, quoted for the shell t.Setenv("GOSH_SHOWN", "a b") got := runIn(t, "set > out") if !strings.Contains(got, "GOSH_SHOWN='a b'\n") { t.Errorf("set: GOSH_SHOWN missing in %q", got) } } 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 TestVersionInfo(t *testing.T) { origVersion, origCommit, origBranch := version, commit, branch defer func() { version, commit, branch = origVersion, origCommit, origBranch }() version, commit, branch = "1.2.3", "abc1234", "main" if got := versionInfo(); got != "1.2.3 (branch: main, commit: abc1234)" { t.Errorf("versionInfo() = %q", got) } branch = "" if got := versionInfo(); got != "1.2.3 (commit: abc1234)" { t.Errorf("versionInfo() without branch = %q", got) } } 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 ", "ontinue "}, 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}, // Commands are completed after |, ;, && and ||, files after redirections {"ls | gi", []string{"tty "}, 2}, {"ls|gi", []string{"tty "}, 2}, {"true; gi", []string{"tty "}, 2}, {"true && gi", []string{"tty "}, 2}, {"false || gi", []string{"tty "}, 2}, {"ls > ma", []string{"in.go"}, 2}, {"ls 2>ma", []string{"in.go"}, 2}, {"cat 5*time.Second { t.Errorf("child was not interrupted (took %v)", elapsed) } } func TestExecInput_PassesStdin(t *testing.T) { r, w, err := os.Pipe() if err != nil { t.Fatal(err) } origStdin := os.Stdin os.Stdin = r defer func() { os.Stdin = origStdin r.Close() }() if _, err := w.WriteString("hello\n"); err != nil { t.Fatal(err) } w.Close() out := filepath.Join(t.TempDir(), "out") t.Setenv("GOSH_TEST_OUT", out) if err := execInput(`sh -c 'read x; printf %s "$x" > "$GOSH_TEST_OUT"'`); err != nil { t.Fatal(err) } got, err := os.ReadFile(out) if err != nil { t.Fatal(err) } if string(got) != "hello" { t.Errorf("command read %q from stdin, want %q", got, "hello") } } func TestExitStatus(t *testing.T) { tests := []struct { input string want int }{ {"true", 0}, {"false", 1}, {`sh -c "exit 42"`, 42}, {`sh -c "kill -TERM \$\$"`, 128 + 15}, {"gosh-no-such-command", 127}, {"cd /gosh-no-such-dir", 127}, } for _, tc := range tests { if got := exitStatus(execInput(tc.input)); got != tc.want { t.Errorf("exitStatus(execInput(%q)) = %d, want %d", tc.input, got, tc.want) } } if got := exitStatus(errors.New("other")); got != 1 { t.Errorf("exitStatus(other error) = %d, want 1", got) } } func TestRunScript(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) dir := t.TempDir() script := "#!/usr/bin/gosh\n" + "# comment\n" + "\n" + "cd " + dir + "\n" + " # indented comment\n" + "touch first\n" + "false\n" + "touch second" // no trailing newline if got := runScript(strings.NewReader(script), ""); got != 0 { t.Errorf("runScript() = %d, want 0", got) } for _, name := range []string{"first", "second"} { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("expected %s to be created: %v", name, err) } } // The status of the last command is returned if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n"), ""); got != 3 { t.Errorf("runScript() = %d, want 3", got) } } func TestRun_Options(t *testing.T) { tests := []struct { args []string want int }{ {[]string{"-c", "true"}, 0}, {[]string{"-c", "false"}, 1}, {[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5}, {[]string{"-c"}, 2}, {[]string{"-z"}, 2}, {[]string{"/gosh/no/such/script"}, 127}, } for _, tc := range tests { if got := run(tc.args); got != tc.want { t.Errorf("run(%q) = %d, want %d", tc.args, got, tc.want) } } } func TestReadLine_DoesNotReadAhead(t *testing.T) { r := strings.NewReader("first\nsecond\n") line, err := readLine(r) if line != "first" || err != nil { t.Fatalf("readLine() = %q, %v", line, err) } if r.Len() != len("second\n") { t.Errorf("readLine() consumed more than one line, %d bytes left", r.Len()) } } // treeString formats a syntax tree for the tests: simple commands in // brackets, background commands with a trailing &. func treeString(l *listNode) string { var parts []string for _, e := range l.items { s := pipelineString(e.andOr.first) for _, r := range e.andOr.rest { s += " " + r.op + " " + pipelineString(r.pipeline) } if e.background { s += " &" } parts = append(parts, s) } return strings.Join(parts, "; ") } func pipelineString(pl *pipelineNode) string { s := "" if pl.negate { s = "! " } switch n := pl.cmd.(type) { case *simpleNode: s += "[" + n.text + "]" case *ifNode: s += "if" for i := range n.conds { if i > 0 { s += " elif" } s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i]) } if n.elseBody != nil { s += " else " + treeString(n.elseBody) } s += " fi" case *forNode: s += "for " + n.name if n.hasIn { s += " in (" + n.words + ")" } s += " do " + treeString(n.body) + " done" case *whileNode: if n.until { s += "until " } else { s += "while " } s += treeString(n.cond) + " do " + treeString(n.body) + " done" case *caseNode: s += "case " + n.word + " in" for _, c := range n.clauses { s += " (" + strings.Join(c.patterns, "|") + ") " + treeString(c.body) + " ;;" } s += " esac" case *groupNode: s += "{ " + treeString(n.body) + " }" case *subshellNode: s += "(" + n.text + ")" case *redirNode: s += pipelineString(&pipelineNode{cmd: n.cmd}) + " <" + n.redirs + ">" case *pipeNode: var elems []string for _, e := range n.elems { elems = append(elems, "<"+e.text+">") } s += strings.Join(elems, " | ") case *funcDefNode: s += n.def.name + "() " + pipelineString(&pipelineNode{cmd: n.def.body}) } return s } func TestParse_Lists(t *testing.T) { tests := []struct { input string want string }{ {"", ""}, {" ", ""}, {"# only a comment", ""}, {"ls", "[ls]"}, {"a; b", "[a]; [b]"}, {"a;", "[a]"}, {"a\nb\n", "[a]; [b]"}, {"a && b || c", "[a] && [b] || [c]"}, {"a &&\n b", "[a] && [b]"}, {"a | b|c", "[a | b|c]"}, {"a |\n b", "[a |\n b]"}, {`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`}, {`echo "x\"; y"`, `[echo "x\"; y"]`}, {"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"}, {"echo a # b; c", "[echo a]"}, {"echo a#b; c", "[echo a#b]; [c]"}, {"a & b", "[a] &; [b]"}, {"a&", "[a] &"}, {"a 2>&1 &", "[a 2>&1] &"}, {"a &> f &", "[a &> f] &"}, {"a >&2 && b", "[a >&2] && [b]"}, {`echo "a & b" \&`, `[echo "a & b" \&]`}, {"echo $(a; b && c | d) \"$(e || f)\" & g", `[echo $(a; b && c | d) "$(e || f)"] &; [g]`}, {"! false", "! [false]"}, {"echo a \\\n b", "[echo a b]"}, // Reserved words are only special as the first word of a command {"echo if then fi do done", "[echo if then fi do done]"}, } for _, tc := range tests { tree, err := parse(tc.input) if err != nil { t.Errorf("parse(%q) returned error: %v", tc.input, err) continue } if got := treeString(tree); got != tc.want { t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want) } } } func TestParse_ControlStructures(t *testing.T) { tests := []struct { input string want string }{ {"if a; then b; fi", "if [a] then [b] fi"}, {"if a\nthen\n b\n c\nfi", "if [a] then [b]; [c] fi"}, {"if a; then b; elif c; then d; else e; fi", "if [a] then [b] elif [c] then [d] else [e] fi"}, {"if a && ! b; then c; fi; echo x", "if [a] && ! [b] then [c] fi; [echo x]"}, {"for x in a b c; do echo $x; done", "for x in (a b c) do [echo $x] done"}, {"for x in *.go {1..3}\ndo\n echo $x\ndone", "for x in (*.go {1..3}) do [echo $x] done"}, {"for x; do echo; done", "for x do [echo] done"}, {"for x in; do echo; done", "for x in () do [echo] done"}, {"while a; do b; done", "while [a] do [b] done"}, {"until a; do b; done", "until [a] do [b] done"}, {"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi done"}, {"for i in 1 2; do for j in 3 4; do echo $i$j; done; done", "for i in (1 2) do for j in (3 4) do [echo $i$j] done done"}, {"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"}, // Compound commands in pipelines, with redirections, in the background {"for x in a; do b; done | cat", " | "}, {"a | while b; do c; done | d", " | | "}, {"if a; then b; fi > out 2>&1", "if [a] then [b] fi <> out 2>&1>"}, {"while read l; do b; done < in", "while [read l] do [b] done << in>"}, {"{ a; b; } 2> err", "{ [a]; [b] } <2> err>"}, {"for x in a; do b; done &", "for x in (a) do [b] done &"}, {"a && b &", "[a] && [b] &"}, // Subshells {"(a; b)", "(a; b)"}, {"( a ) | b", "<( a )> | "}, {"(a) > f", "(a) <> f>"}, {"(a; (b)) && c", "(a; (b)) && [c]"}, // case, groups and functions {"case $x in a|b) c;; (d) e; f;; esac", "case $x in (a|b) [c] ;; (d) [e]; [f] ;; esac"}, {"case x in\n a)\n b\n ;;\nesac", "case x in (a) [b] ;; esac"}, {"{ a; }", "{ [a] }"}, {"f() { a; }", "f() { [a] }"}, {"f ( ) { a; }", "f() { [a] }"}, {"function f { a; }", "f() { [a] }"}, {"f() (a)", "f() (a)"}, } for _, tc := range tests { tree, err := parse(tc.input) if err != nil { t.Errorf("parse(%q) returned error: %v", tc.input, err) continue } if got := treeString(tree); got != tc.want { t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want) } } } func TestParse_Incomplete(t *testing.T) { for _, input := range []string{ "if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c", "for x", "for x in a b", "for x in a; do", "for x in a; do b", "while a", "while a; do b", "until a; do", "(a", "(a; b", "{ a;", "case x in", "case x in a) b;;", "f() {", "f()", "cat < out": "redirections of function definitions are not supported", "if a; then b; fi c": "syntax error near unexpected token `c'", } { if _, err := parse(input); err == nil || err.Error() != want { t.Errorf("parse(%q) = %v, want %q", input, err, want) } } } func TestExecInput_Lists(t *testing.T) { dir := t.TempDir() t.Setenv("D", dir) tests := []struct { input string created []string missing []string status int }{ {"touch $D/a; touch $D/b", []string{"a", "b"}, nil, 0}, {"false; touch $D/c", []string{"c"}, nil, 0}, {"true && touch $D/d", []string{"d"}, nil, 0}, {"false && touch $D/e", nil, []string{"e"}, 1}, {"false || touch $D/f", []string{"f"}, nil, 0}, {"true || touch $D/g", nil, []string{"g"}, 0}, {"false && touch $D/h || touch $D/i", []string{"i"}, []string{"h"}, 0}, {"true && false || touch $D/j", []string{"j"}, nil, 0}, {"gosh-no-such-command || touch $D/k", []string{"k"}, nil, 0}, {"true && sh -c 'exit 4'", nil, nil, 4}, } for _, tc := range tests { execInput(tc.input) if lastStatus != tc.status { t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status) } for _, name := range tc.created { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("%q: expected %s to be created", tc.input, name) } } for _, name := range tc.missing { if _, err := os.Stat(filepath.Join(dir, name)); err == nil { t.Errorf("%q: %s must not be created", tc.input, name) } } } } func TestExecInput_ExpandsPerCommand(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) dir := t.TempDir() out := filepath.Join(dir, "out") t.Setenv("GOSH_TEST_OUT", out) os.Unsetenv("GOSH_TEST_LIST") defer os.Unsetenv("GOSH_TEST_LIST") // The variable is set by the first command and used by the second one err := execInput(`set GOSH_TEST_LIST=hello && sh -c 'printf %s "$GOSH_TEST_LIST" > "$GOSH_TEST_OUT"'`) if err != nil { t.Fatal(err) } if got, _ := os.ReadFile(out); string(got) != "hello" { t.Errorf("got %q, want %q", got, "hello") } } func TestExpandVar_LastStatus(t *testing.T) { execInput("sh -c 'exit 3'") for _, input := range []string{"$?", "${?}"} { words, err := splitWords("echo " + input) if err != nil { t.Fatal(err) } if words[1] != "3" { t.Errorf("%s = %q, want %q", input, words[1], "3") } } execInput("true") if words, _ := splitWords("echo $?"); words[1] != "0" { t.Errorf("$? = %q after true, want 0", words[1]) } } func TestExecInput_CommandNotFound(t *testing.T) { err := execInput("gosh-no-such-command") if err == nil || err.Error() != "gosh-no-such-command: command not found" { t.Errorf("got %v, want command not found", err) } if lastStatus != 127 { t.Errorf("$? = %d, want 127", lastStatus) } } func TestTokenize_Operators(t *testing.T) { tests := []struct { input string want []token }{ {"a | b", []token{{word: "a"}, {op: "|"}, {word: "b"}}}, {"a|b", []token{{word: "a"}, {op: "|"}, {word: "b"}}}, {"a > f", []token{{word: "a"}, {op: ">", fd: 1}, {word: "f"}}}, {"a >>f", []token{{word: "a"}, {op: ">>", fd: 1}, {word: "f"}}}, {"a < f", []token{{word: "a"}, {op: "<", fd: 0}, {word: "f"}}}, {"a 2>f", []token{{word: "a"}, {op: ">", fd: 2}, {word: "f"}}}, {"a 2>>f", []token{{word: "a"}, {op: ">>", fd: 2}, {word: "f"}}}, {"a 2>&1", []token{{word: "a"}, {op: ">&", fd: 2, dup: 1}}}, {"a >&2", []token{{word: "a"}, {op: ">&", fd: 1, dup: 2}}}, {"a 0<&2", []token{{word: "a"}, {op: "<&", fd: 0, dup: 2}}}, {"a &>f", []token{{word: "a"}, {op: "&>", fd: 1}, {word: "f"}}}, {"a &>>f", []token{{word: "a"}, {op: "&>>", fd: 1}, {word: "f"}}}, // Only a word of unquoted digits is a file descriptor {"a x2>f", []token{{word: "a"}, {word: "x2"}, {op: ">", fd: 1}, {word: "f"}}}, {`a "2">f`, []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}}, {"a 2 >f", []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}}, // Quoted and escaped operators are words {`echo "a|b" 'c>d' e\d"}, {word: "e", "echo a > | b", "echo a >&", "echo a 2>&x", "; a"} { err := execInput(input) if err == nil || !strings.Contains(err.Error(), "syntax error") { t.Errorf("execInput(%q) = %v, want syntax error", input, err) } if lastStatus != 2 { t.Errorf("execInput(%q): $? = %d, want 2", input, lastStatus) } } } func TestExecInput_PipesAndRedirections(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) dir := t.TempDir() os.Chdir(dir) read := func(name string) string { data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Errorf("reading %s: %v", name, err) } return string(data) } tests := []struct { input string file string want string status int }{ {`printf 'b\na\nc\n' | sort | head -n 2 > out`, "out", "a\nb\n", 0}, {"echo one > f1; echo two >> f1", "f1", "one\ntwo\n", 0}, {"echo one > f2; echo two > f2", "f2", "two\n", 0}, {"tr a-z A-Z < f1 > f3", "f3", "ONE\nTWO\n", 0}, {"sh -c 'echo out; echo err >&2' > f4 2>&1", "f4", "out\nerr\n", 0}, {"sh -c 'echo out; echo err >&2' &> f5", "f5", "out\nerr\n", 0}, {"sh -c 'echo err >&2' 2> f6", "f6", "err\n", 0}, // 2>&1 before >file sends stderr to the old stdout (the pipe) {"sh -c 'echo out; echo err >&2' 2>&1 > /dev/null | cat > f7", "f7", "err\n", 0}, {"sh -c 'echo err >&2' 2>&1 | tr a-z A-Z > f8", "f8", "ERR\n", 0}, // Builtins write to redirections and pipes {"pwd > f9", "f9", dir + "\n", 0}, {"pwd | cat > f10", "f10", dir + "\n", 0}, {"help | head -n 1 > f11", "f11", "gosh - Go Shell\n", 0}, // Errors of builtins go to the redirected stderr {"cd /gosh/no/such/dir 2> f12", "f12", "gosh: chdir /gosh/no/such/dir: no such file or directory\n", 1}, // The status of a pipeline is the one of the last command {"false | true > f13", "f13", "", 0}, {"true | false > f14", "f14", "", 1}, // A redirection without a command creates the file {"> f15", "f15", "", 0}, // SIGPIPE ends the writer when the reader exits {"yes | head -n 1 > f16", "f16", "y\n", 0}, // A command that is not found does not stop the pipeline {"gosh-no-such-command | echo after > f17", "f17", "after\n", 0}, {"echo before | gosh-no-such-command 2> f18", "f18", "", 127}, } for _, tc := range tests { execInput(tc.input) if lastStatus != tc.status { t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status) } if got := read(tc.file); got != tc.want { t.Errorf("%q: %s = %q, want %q", tc.input, tc.file, got, tc.want) } } } func TestExecInput_RedirectionErrors(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) os.Chdir(t.TempDir()) tests := []struct { input string wantErr string }{ {"cat < gosh-no-such-file", "gosh-no-such-file: no such file or directory"}, {"echo a > gosh-no-such-dir/f", "gosh-no-such-dir/f: no such file or directory"}, {"echo a 3> f", "3: bad file descriptor"}, {"echo a 2>&5", "5: bad file descriptor"}, {"echo a | cd /", "cd: cannot be used in a pipeline"}, {"echo a | exit 3", "exit: cannot be used in a pipeline"}, } for _, tc := range tests { err := execInput(tc.input) if err == nil || err.Error() != tc.wantErr { t.Errorf("execInput(%q) = %v, want %q", tc.input, err, tc.wantErr) } if lastStatus != 1 { t.Errorf("execInput(%q): $? = %d, want 1", tc.input, lastStatus) } } // The command is not run if a redirection fails if _, err := os.Stat("f"); err == nil { t.Error("file f must not be created for a bad file descriptor") } } func TestTokenize_Patterns(t *testing.T) { t.Setenv("GOSH_GLOB", "*.go") tests := []struct { input string pattern string glob bool }{ {"*.go", "*.go", true}, {"a?c", "a?c", true}, {"[ab].go", "[ab].go", true}, {"[!a].go", "[^a].go", true}, {"plain", "plain", false}, // Quoted, escaped and expanded characters are literal {`"*.go"`, `\*.go`, false}, {`'*'.go`, `\*.go`, false}, {`\*.go`, `\*.go`, false}, {`"x"*`, `x*`, true}, {"$GOSH_GLOB", `\*.go`, false}, {`'['!a].go`, `\[!a].go`, false}, } for _, tc := range tests { tokens, err := tokenize(tc.input) if err != nil || len(tokens) != 1 { t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err) continue } if tokens[0].pattern != tc.pattern || tokens[0].glob != tc.glob { t.Errorf("tokenize(%q): pattern %q, glob %v; want %q, %v", tc.input, tokens[0].pattern, tokens[0].glob, tc.pattern, tc.glob) } } } func TestExpandGlob(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) dir := t.TempDir() os.Chdir(dir) for _, d := range []string{"sub/deep", "other"} { os.MkdirAll(d, 0755) } for _, f := range []string{"a.go", "b.go", "c.txt", ".hidden.go", "with space.go", "[x].go", "sub/s1.go", "sub/deep/d.go", "other/o.go"} { os.WriteFile(f, nil, 0644) } tests := []struct { pattern string want []string }{ {"*.go", []string{"[x].go", "a.go", "b.go", "with space.go"}}, {".*.go", []string{".hidden.go"}}, {"?.go", []string{"a.go", "b.go"}}, {"[ab].go", []string{"a.go", "b.go"}}, {"[^a].go", []string{"b.go"}}, {"*/*.go", []string{"other/o.go", "sub/s1.go"}}, {"*/", []string{"other/", "sub/"}}, {"sub/*/*.go", []string{"sub/deep/d.go"}}, {"./c*", []string{"./c.txt"}}, {dir + "/c*", []string{dir + "/c.txt"}}, {`\[x\].go`, []string{"[x].go"}}, {"*.xyz", nil}, {"[", nil}, } for _, tc := range tests { if got := expandGlob(tc.pattern); !reflect.DeepEqual(got, tc.want) { t.Errorf("expandGlob(%q) = %q, want %q", tc.pattern, got, tc.want) } } } func TestExecInput_Wildcards(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) dir := t.TempDir() os.Chdir(dir) for _, f := range []string{"a.go", "b.go", "c.txt"} { os.WriteFile(f, []byte(f+"\n"), 0644) } tests := []struct { input string want string }{ {"echo *.go > out", "a.go b.go\n"}, {"cat *.go > out", "a.go\nb.go\n"}, // No match: the word is passed on unchanged, like in sh {"echo *.xyz > out", "*.xyz\n"}, {`echo "*.go" '?.go' \*.go > out`, "*.go ?.go *.go\n"}, // A redirection to a pattern with a single match uses that file {"echo hi > *.txt; cat c.txt > out", "hi\n"}, } for _, tc := range tests { if err := execInput(tc.input); err != nil { t.Errorf("execInput(%q) returned error: %v", tc.input, err) } if got, _ := os.ReadFile("out"); string(got) != tc.want { t.Errorf("%q: got %q, want %q", tc.input, got, tc.want) } } err := execInput("echo hi > *.go") if err == nil || err.Error() != "*.go: ambiguous redirect" { t.Errorf("expected ambiguous redirect error, got %v", err) } } func TestStartupFiles(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) sys := sysConfDir tests := []struct { login, interactive bool want []string }{ {false, false, nil}, {true, false, []string{sys + "/profile", home + "/.gosh_profile"}}, {false, true, []string{sys + "/goshrc", home + "/.goshrc"}}, {true, true, []string{sys + "/profile", home + "/.gosh_profile", sys + "/goshrc", home + "/.goshrc"}}, } for _, tc := range tests { if got := startupFiles(tc.login, tc.interactive); !reflect.DeepEqual(got, tc.want) { t.Errorf("startupFiles(%v, %v) = %q, want %q", tc.login, tc.interactive, got, tc.want) } } } func TestRun_LoginReadsProfile(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) os.Unsetenv("GOSH_TEST_PROFILE") defer os.Unsetenv("GOSH_TEST_PROFILE") os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=yes\n"), 0644) // .goshrc is only read by interactive shells os.WriteFile(filepath.Join(home, ".goshrc"), []byte("exit 99\n"), 0644) check := `sh -c 'test "$GOSH_TEST_PROFILE" = yes'` if got := run([]string{"-c", check}); got != 1 { t.Errorf("non-login shell: status %d, want 1 (profile must not be read)", got) } if got := run([]string{"-l", "--noprofile", "-c", check}); got != 1 { t.Errorf("--noprofile: status %d, want 1 (profile must not be read)", got) } if got := run([]string{"-l", "-c", check}); got != 0 { t.Errorf("login shell: status %d, want 0 (profile must be read)", got) } } func TestShell_InteractiveReadsRC(t *testing.T) { bin := buildShell(t) home := t.TempDir() os.WriteFile(filepath.Join(home, ".goshrc"), []byte("export GOSH_TEST_RC=from-rc\n"), 0644) os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=from-profile\n"), 0644) tests := []struct { args []string want string }{ {[]string{"-i"}, "from-rc -\n"}, {[]string{"-i", "--norc"}, "- -\n"}, {[]string{"-i", "-l"}, "from-rc from-profile\n"}, {[]string{"-i", "-l", "--norc", "--noprofile"}, "- -\n"}, } for _, tc := range tests { cmd := exec.Command(bin, tc.args...) cmd.Env = append(os.Environ(), "HOME="+home) cmd.Stdin = strings.NewReader("echo ${GOSH_TEST_RC:--} ${GOSH_TEST_PROFILE:--}\nexit\n") out, err := cmd.Output() if err != nil { t.Fatalf("%v: %v", tc.args, err) } if !strings.HasSuffix(string(out), tc.want) { t.Errorf("%v: output %q, want suffix %q", tc.args, out, tc.want) } } } func TestExecInput_Source(t *testing.T) { dir := t.TempDir() os.Unsetenv("GOSH_TEST_SOURCE") defer os.Unsetenv("GOSH_TEST_SOURCE") file := filepath.Join(dir, "vars") os.WriteFile(file, []byte("# comment\nexport GOSH_TEST_SOURCE=hello\nsh -c 'exit 4'\n"), 0644) for _, cmd := range []string{"source", "."} { os.Unsetenv("GOSH_TEST_SOURCE") execInput(cmd + " " + file) if os.Getenv("GOSH_TEST_SOURCE") != "hello" { t.Errorf("%s: variable was not set in the current shell", cmd) } if lastStatus != 4 { t.Errorf("%s: $? = %d, want 4 (status of the last command)", cmd, lastStatus) } } err := execInput("source " + filepath.Join(dir, "missing")) if err == nil || !strings.Contains(err.Error(), "source: "+dir+"/missing: no such file or directory") { t.Errorf("unexpected error for missing file: %v", err) } if lastStatus != 1 { t.Errorf("$? = %d for missing file, want 1", lastStatus) } // A file that sources itself is stopped loop := filepath.Join(dir, "loop") os.WriteFile(loop, []byte("source "+loop+"\n"), 0644) if err := execInput("source " + loop); err == nil { t.Error("expected an error for a file that sources itself") } } func TestGetHomeDir_UsesHOME(t *testing.T) { t.Setenv("HOME", "/gosh/test/home") if got := getHomeDir(); got != "/gosh/test/home" { t.Errorf("getHomeDir() = %q, want $HOME", got) } if words, _ := splitWords("echo ~/x"); words[1] != "/gosh/test/home/x" { t.Errorf("~/x = %q, want it based on $HOME", words[1]) } // Without $HOME the user database is used t.Setenv("HOME", "") if got := getHomeDir(); got == "" { t.Error("getHomeDir() is empty without $HOME") } } func TestExecInput_Background(t *testing.T) { origDir, _ := os.Getwd() defer os.Chdir(origDir) dir := t.TempDir() os.Chdir(dir) // The shell does not wait for a background job, wait does start := time.Now() if err := execInput("sh -c 'sleep 0.3; echo done > out' &"); err != nil { t.Fatal(err) } if lastStatus != 0 { t.Errorf("$? = %d after starting a background job, want 0", lastStatus) } if time.Since(start) > 200*time.Millisecond { t.Error("the shell waited for the background job") } if _, err := os.Stat("out"); err == nil { t.Error("background job finished too early") } if err := execInput("wait"); err != nil { t.Fatal(err) } if got, _ := os.ReadFile("out"); string(got) != "done\n" { t.Errorf("out = %q after wait", got) } if len(jobTable) != 0 { t.Errorf("job table not empty after wait: %d jobs", len(jobTable)) } // wait %n returns the status of the job execInput("sh -c 'exit 3' &") execInput("wait %1") if lastStatus != 3 { t.Errorf("$? = %d after wait %%1, want 3", lastStatus) } // Without job control, background jobs read from /dev/null execInput("cat > stdin.txt &") execInput("wait") if got, err := os.ReadFile("stdin.txt"); err != nil || len(got) != 0 { t.Errorf("background job read %q, %v from stdin", got, err) } // jobs lists running jobs, also into a redirection execInput("sleep 0.3 &") execInput("jobs > jobs.txt") if got, _ := os.ReadFile("jobs.txt"); !strings.Contains(string(got), "Running sleep 0.3 &") { t.Errorf("jobs output = %q", got) } execInput("wait") // Builtins that change the shell cannot run in the background if err := execInput("cd / &"); err != nil { t.Fatal(err) } execInput("wait %1") if wd, _ := os.Getwd(); wd != dir { t.Errorf("cd in the background changed the directory to %s", wd) } // fg and bg need job control for _, cmd := range []string{"fg", "bg"} { if err := execInput(cmd); err == nil || err.Error() != cmd+": no job control" { t.Errorf("%s without job control: %v", cmd, err) } } if err := execInput("wait %9"); err == nil || err.Error() != "wait: %9: no such job" { t.Errorf("wait %%9: %v", err) } }