The shell manager used os.Pipe() for stdin/stdout/stderr, so bash ran without a tty. Programs that check isatty() (python, htop, less, vim-style REPLs) would drop echo, ignore line-editing, and leak literal 0x1b bytes into the program — Python's REPL reported 'invalid non-printable character U+001B' on input. Switch to github.com/creack/pty:StartWithSize with an 80x24 default and TERM=xterm-256color in cmd.Env. The PTY file replaces both the stdin and stdout pipes; sess.Stdin is the pty *os.File and the output capture goroutine reads from it directly. waitExit no longer needs to close sess.Stdin — the capture goroutine's defer handles it when the process dies and the pty returns EOF. - go.mod / go.sum: add creack/pty v1.1.24. - internal/shell/manager.go: rewrite Start to use pty.StartWithSize; captureOutput reads from the pty; waitExit simplified; race-clean exited map to avoid the cmd.ProcessState data race. - internal/shell/manager_test.go (new): TestShellPTYIsRealTerminal (echo with echo + output interleaved proves PTY echo is on) and TestShellPTYTermEnv (TERM=xterm-256color propagates). E2E against the real bash: 'python3 -q' returns the >>> prompt, print(2+2) returns 4, exit() returns to bash, 0 ESC bytes leak. Conversation: 019f35fa-601b-73c1-bd9e-221971c31a56
87 lines
2.0 KiB
Go
87 lines
2.0 KiB
Go
package shell
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func startTestShell(t *testing.T) (*LocalManager, io.WriteCloser, Subscription) {
|
|
t.Helper()
|
|
mgr := NewManager("bash", []string{"-i"})
|
|
root := t.TempDir()
|
|
if err := mgr.Start("test-ws", root); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
stdin, err := mgr.Stdin("test-ws")
|
|
if err != nil {
|
|
t.Fatalf("Stdin failed: %v", err)
|
|
}
|
|
|
|
sub, err := mgr.Subscribe("test-ws")
|
|
if err != nil {
|
|
t.Fatalf("Subscribe failed: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
sub.Close()
|
|
_ = mgr.Stop("test-ws")
|
|
})
|
|
|
|
return mgr, stdin, sub
|
|
}
|
|
|
|
func waitForMarker(t *testing.T, sub Subscription, stdin io.Writer, input, marker string) string {
|
|
t.Helper()
|
|
|
|
if _, err := stdin.Write([]byte(input)); err != nil {
|
|
t.Fatalf("failed to write to stdin: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var out bytes.Buffer
|
|
for {
|
|
select {
|
|
case chunk, ok := <-sub.Output():
|
|
if !ok {
|
|
t.Fatalf("subscription closed before seeing %q; output so far:\n%s", marker, out.String())
|
|
}
|
|
out.Write(chunk)
|
|
if strings.Contains(out.String(), marker) {
|
|
return out.String()
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("timed out waiting for %q; output so far:\n%s", marker, out.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellPTYIsRealTerminal(t *testing.T) {
|
|
mgr, stdin, sub := startTestShell(t)
|
|
_ = mgr
|
|
|
|
out := waitForMarker(t, sub, stdin, "echo HELLO_PTY_TEST\n", "HELLO_PTY_TEST")
|
|
|
|
if !strings.Contains(out, "echo HELLO_PTY_TEST") {
|
|
t.Errorf("expected command echo in PTY output, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "HELLO_PTY_TEST") {
|
|
t.Errorf("expected command output in PTY output, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestShellPTYTermEnv(t *testing.T) {
|
|
_, stdin, sub := startTestShell(t)
|
|
|
|
out := waitForMarker(t, sub, stdin, "echo \"$TERM\"\n", "xterm-256color")
|
|
if !strings.Contains(out, "xterm-256color") {
|
|
t.Errorf("expected TERM=xterm-256color in output, got:\n%s", out)
|
|
}
|
|
}
|