package main // input_test.go — the y/n prompt is the last thing between `init` and a wiped // server repository, so the terminal handling around it is pinned here. import ( "os" "strings" "sync" "testing" "time" ) // fakeTTY stands in for the terminal driver. It models the one detail that made // the skipped-question bug possible: MIN and TIME are not part of the // canonical/non-canonical switch, so they survive `stty icanon` and carry over // into the next prompt. type fakeTTY struct { mu sync.Mutex state map[string]string calls [][]string ready chan struct{} // signalled once single-key mode is in effect } func newFakeTTY() *fakeTTY { return &fakeTTY{ state: map[string]string{"icanon": "on", "echo": "on", "min": "1", "time": "0"}, ready: make(chan struct{}, 4), } } func (f *fakeTTY) run(args ...string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, args) switch { case len(args) == 1 && args[0] == "-g": return f.serializeLocked(), nil case len(args) == 1 && strings.HasPrefix(args[0], "saved:"): for _, kv := range strings.Split(strings.TrimPrefix(args[0], "saved:"), ",") { if k, v, ok := strings.Cut(kv, "="); ok { f.state[k] = v } } return "", nil } for i := 0; i < len(args); i++ { switch a := args[i]; a { case "min", "time": if i+1 < len(args) { f.state[a] = args[i+1] i++ } default: f.state[strings.TrimPrefix(a, "-")] = boolWord(!strings.HasPrefix(a, "-")) } } if strings.Join(args, " ") == strings.Join(keyModeArgs, " ") { f.ready <- struct{}{} // single-key mode is set; the read comes next } return "", nil } func boolWord(on bool) string { if on { return "on" } return "off" } // serializeLocked renders the settings as one token, the way `stty -g` does. func (f *fakeTTY) serializeLocked() string { return "saved:icanon=" + f.state["icanon"] + ",echo=" + f.state["echo"] + ",min=" + f.state["min"] + ",time=" + f.state["time"] } func (f *fakeTTY) get(k string) string { f.mu.Lock() defer f.mu.Unlock() return f.state[k] } func (f *fakeTTY) snapshot() string { f.mu.Lock() defer f.mu.Unlock() return f.serializeLocked() } // installFakeTTY points getkey at the fake terminal for the duration of a test. func installFakeTTY(t *testing.T) *fakeTTY { t.Helper() f := newFakeTTY() oldRun, oldIsTTY, oldStdin := sttyRun, stdinIsTTY, os.Stdin sttyRun = f.run stdinIsTTY = func() bool { return true } t.Cleanup(func() { sttyRun, stdinIsTTY, os.Stdin = oldRun, oldIsTTY, oldStdin }) return f } // askOnce runs one getkey against the fake terminal, answering with keys once // the terminal is actually in single-key mode. It reports the MIN in effect at // the moment of the read — the value the old code got wrong. func askOnce(t *testing.T, f *fakeTTY, keys string) (key string, ok bool, minAtRead string) { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatal(err) } os.Stdin = r done := make(chan struct{}) go func() { key, ok = getkey("question? ") close(done) }() select { case <-f.ready: case <-time.After(5 * time.Second): t.Fatal("the terminal was never put into single-key mode") } minAtRead = f.get("min") w.WriteString(keys) w.Close() // so the drain that follows the keypress sees EOF instead of blocking <-done r.Close() return key, ok, minAtRead } // TestEveryQuestionWaitsForAnAnswer is the regression test for the bug where the // second y/n question of a session was skipped: drainTTY left the terminal at // "min 0" ("return what is buffered, do not wait"), the restore only named // icanon and echo, and so the next read returned zero bytes and answered the // question with its default. func TestEveryQuestionWaitsForAnAnswer(t *testing.T) { f := installFakeTTY(t) for i, want := range []string{"y", "n", "y"} { key, ok, minAtRead := askOnce(t, f, want) if !ok || key != want { t.Fatalf("question %d: got (%q, %v), want (%q, true)", i+1, key, ok, want) } if minAtRead != "1" { t.Errorf("question %d read the terminal at min=%s, want min=1 — "+ "min 0 returns without waiting and answers the question by itself", i+1, minAtRead) } } } // TestPromptHandsTheTerminalBackUnchanged: mgsh must not leave the terminal in // a mode it chose. The old restore named only icanon and echo and left "min 0" // behind, which outlived mgsh itself and broke the next program's single-key // reads too. func TestPromptHandsTheTerminalBackUnchanged(t *testing.T) { f := installFakeTTY(t) initial := f.snapshot() if _, ok, _ := askOnce(t, f, "y"); !ok { t.Fatal("getkey did not read the key") } if got := f.snapshot(); got != initial { t.Errorf("terminal left as %s, want it back at %s", got, initial) } } // TestPromptRestoresFromTheSavedState pins *how* the terminal is restored: from // the state captured with `stty -g`, not by naming the flags we changed. func TestPromptRestoresFromTheSavedState(t *testing.T) { f := installFakeTTY(t) if _, ok, _ := askOnce(t, f, "y"); !ok { t.Fatal("getkey did not read the key") } f.mu.Lock() defer f.mu.Unlock() if len(f.calls) == 0 { t.Fatal("stty was never called") } if first := f.calls[0]; len(first) != 1 || first[0] != "-g" { t.Errorf("first stty call was %v, want [-g]: the state has to be captured "+ "before it is changed", first) } last := f.calls[len(f.calls)-1] if len(last) != 1 || !strings.HasPrefix(last[0], "saved:") { t.Errorf("last stty call was %v, want the saved state played back", last) } } // TestSingleKeyModeSetsMinExplicitly: MIN and TIME are inherited, so they have // to be named on the way in. Without that, a terminal left at "min 0" by an // earlier program (or an earlier mgsh) skips the question. func TestSingleKeyModeSetsMinExplicitly(t *testing.T) { args := strings.Join(keyModeArgs, " ") for _, want := range []string{"-icanon", "-echo", "min 1", "time 0"} { if !strings.Contains(args, want) { t.Errorf("single-key mode is %q, missing %q", args, want) } } } // TestQuestionSkippedByAPoisonedTerminalIsNotAYes: even if the terminal is // already in the broken state when mgsh starts, the answer must not be the // default — nobody saw the question, so nobody agreed to anything. func TestQuestionSkippedByAPoisonedTerminalIsNotAYes(t *testing.T) { oldRun, oldIsTTY, oldStdin := sttyRun, stdinIsTTY, os.Stdin defer func() { sttyRun, stdinIsTTY, os.Stdin = oldRun, oldIsTTY, oldStdin }() sttyRun = func(args ...string) (string, error) { return "", nil } stdinIsTTY = func() bool { return true } r, w, _ := os.Pipe() w.Close() // a terminal that returns nothing at all os.Stdin = r defer r.Close() if key, ok := getkey("question? "); ok { t.Fatalf("getkey reported a key %q from a terminal that gave none", key) } if yesno("destroy everything?", true) { t.Error("an unanswerable question was taken as yes") } }