// Full-stack integration tests: a real HTTP server wrapping the real // registry, real PTYs and the real emulator, driven through the CLI command // functions — so one test crosses CLI parsing → REST client → routing → // handlers → session locking → keymap → vt encoding → PTY → probe → emulator // → screen snapshot. // // The only fake is the "external service": the TUI application inside the // PTY. That's the probe — this same test binary re-executed with // TERMD_TEST_PROBE=1 — which sets its tty raw, enables the DEC modes listed // in PROBE_MODES, and echoes every byte it receives back as hex on screen, // so tests can assert both directions of the pipe exactly. package server_test import ( "bytes" "context" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "sync" "syscall" "testing" "time" "golang.org/x/term" "termd/internal/api" "termd/internal/cli" "termd/internal/client" "termd/internal/server" "termd/internal/session" ) func TestMain(m *testing.M) { if os.Getenv("TERMD_TEST_PROBE") == "1" { probeMain() return } os.Exit(m.Run()) } // probeMain is the deterministic fake TUI. func probeMain() { oldState, err := term.MakeRaw(int(os.Stdin.Fd())) if err != nil { fmt.Printf("PROBE ERROR: MakeRaw: %v\r\n", err) os.Exit(1) } defer term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck for _, mode := range strings.Split(os.Getenv("PROBE_MODES"), ",") { if mode != "" { fmt.Printf("\x1b[?%sh", mode) } } fmt.Print("PROBE READY\r\n") buf := make([]byte, 4096) for { n, err := os.Stdin.Read(buf) if n > 0 { var line strings.Builder for i, b := range buf[:n] { if i > 0 { line.WriteByte(' ') } fmt.Fprintf(&line, "%02x", b) } fmt.Print(line.String() + "\r\n") // Control bytes for tests: R disables mouse reporting, Q leaves // the alt screen and exits cleanly. if bytes.ContainsRune(buf[:n], 'R') { fmt.Print("\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l") } if bytes.ContainsRune(buf[:n], 'Q') { fmt.Print("\x1b[?1049l") os.Exit(0) } } if err != nil { os.Exit(0) } } } // env is one test's daemon: real registry, real HTTP server, real client. type env struct { t *testing.T c *client.Client reg *session.Registry srv *httptest.Server } func newEnv(t *testing.T) *env { t.Helper() reg := session.NewRegistry() srv := httptest.NewServer(server.New(reg, "")) t.Cleanup(func() { for _, s := range reg.List() { _ = s.Kill(syscall.SIGKILL) } srv.Close() }) return &env{t: t, c: client.New(srv.URL, ""), reg: reg, srv: srv} } func ctx() context.Context { return context.Background() } // run executes a CLI command function and returns its output. func (e *env) run(fn func(context.Context, *client.Client, []string, io.Writer) error, args ...string) string { e.t.Helper() var buf bytes.Buffer if err := fn(ctx(), e.c, args, &buf); err != nil { e.t.Fatalf("cli command %v: %v", args, err) } return buf.String() } // newSession creates a session through the CLI and returns its id. func (e *env) newSession(args ...string) string { e.t.Helper() return strings.TrimSpace(e.run(cli.New, args...)) } // newProbe spawns the probe with the given DEC modes and waits until it has // initialized. func (e *env) newProbe(modes string) string { e.t.Helper() exe, err := os.Executable() if err != nil { e.t.Fatal(err) } id := e.newSession("-env", "TERMD_TEST_PROBE=1", "-env", "PROBE_MODES="+modes, exe) e.waitFor("probe ready", func() bool { return strings.Contains(e.screenText(id), "PROBE READY") }) return id } func (e *env) screen(id string) api.Screen { e.t.Helper() scr, err := e.c.Screen(ctx(), id, false) if err != nil { e.t.Fatalf("screen %s: %v", id, err) } return scr } func (e *env) screenText(id string) string { e.t.Helper() return strings.Join(e.screen(id).Lines, "\n") } func (e *env) send(id string, args ...string) { e.t.Helper() e.run(cli.Send, append([]string{id}, args...)...) } func (e *env) waitFor(desc string, cond func() bool) { e.t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if cond() { return } time.Sleep(10 * time.Millisecond) } e.t.Fatalf("timed out waiting for %s", desc) } // probeHex extracts the hex byte stream the probe has echoed so far. func (e *env) probeHex(id string) string { var toks []string for _, line := range e.screen(id).Lines { if strings.Contains(line, "PROBE") { continue } for _, f := range strings.Fields(line) { if len(f) == 2 && isHex(f) { toks = append(toks, f) } } } return strings.Join(toks, "") } func isHex(s string) bool { for _, c := range s { if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') { return false } } return true } func hexOf(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { fmt.Fprintf(&b, "%02x", s[i]) } return b.String() } func TestHealthz(t *testing.T) { e := newEnv(t) resp, err := http.Get(e.srv.URL + "/healthz") if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) { t.Fatalf("healthz: %d %s", resp.StatusCode, body) } } func TestSessionLifecycle(t *testing.T) { e := newEnv(t) // A session whose process exits keeps its screen queryable and reports // the exit code. id := e.newSession("sh", "-c", "echo done; exit 3") e.waitFor("exit code 3", func() bool { info, err := e.c.Info(ctx(), id) return err == nil && info.Exited && info.ExitCode != nil && *info.ExitCode == 3 }) if !strings.Contains(e.screenText(id), "done") { t.Errorf("screen after exit lost output:\n%s", e.screenText(id)) } if out := e.run(cli.Ls); !strings.Contains(out, "exited(3)") { t.Errorf("ls does not show exit state:\n%s", out) } // Input into an exited session is rejected loudly. if err := cli.Send(ctx(), e.c, []string{id, "nope"}, io.Discard); err == nil || !strings.Contains(err.Error(), "exited") { t.Errorf("input to exited session: err = %v, want 'exited'", err) } // Kill removes it; a live process actually dies. e.run(cli.Kill, id) if _, err := e.c.Info(ctx(), id); err == nil || !strings.Contains(err.Error(), "404") { t.Errorf("info after kill: err = %v, want 404", err) } id2 := e.newSession("sleep", "100") info, err := e.c.Info(ctx(), id2) if err != nil { t.Fatal(err) } e.run(cli.Kill, id2) e.waitFor("process gone", func() bool { return syscall.Kill(info.PID, 0) != nil }) if out := e.run(cli.Ls); strings.Contains(out, id2) { t.Errorf("killed session still listed:\n%s", out) } } func TestShellEchoThroughCLI(t *testing.T) { e := newEnv(t) id := e.newSession("-cols", "60", "-rows", "10", "sh") e.send(id, "echo hello-$((6*7))", "Enter") e.waitFor("echo output", func() bool { return strings.Contains(e.screenText(id), "hello-42") }) scr := e.screen(id) if scr.Cols != 60 || scr.Rows != 10 || len(scr.Lines) != 10 { t.Errorf("screen dims: %dx%d, %d lines", scr.Cols, scr.Rows, len(scr.Lines)) } if scr.Cursor.Y == 0 { t.Errorf("cursor did not advance: %+v", scr.Cursor) } } func TestNamedKeysReachApplication(t *testing.T) { e := newEnv(t) // DECCKM on (?1), so arrows must arrive in application form. id := e.newProbe("1") e.send(id, "Up", "C-Up", "C-c", "F5", "hi", "BTab") want := hexOf("\x1bOA" + "\x1b[1;5A" + "\x03" + "\x1b[15~" + "hi" + "\x1b[Z") e.waitFor("all key bytes to reach the probe", func() bool { return e.probeHex(id) == want }) // Raw input bytes pass through unmodified, ordered with keys. if _, err := e.c.Input(ctx(), id, []api.InputItem{ {Raw: strPtr("\x1b[200~paste\x1b[201~")}, {Key: strPtr("Enter")}, }); err != nil { t.Fatal(err) } want += hexOf("\x1b[200~paste\x1b[201~\r") e.waitFor("raw bytes to reach the probe", func() bool { return e.probeHex(id) == want }) } func strPtr(s string) *string { return &s } func TestModeTrackingLifecycle(t *testing.T) { e := newEnv(t) id := e.newProbe("1049,1,1003,1006,2004") scr := e.screen(id) if !scr.AltScreen { t.Error("alt_screen not reported") } m := scr.Modes if m.Mouse != "any_event" || m.MouseEncoding != "sgr" || !m.AppCursorKeys || !m.BracketedPaste { t.Errorf("modes wrong: %+v", m) } // Probe drops mouse reporting on R. e.send(id, "-l", "R") e.waitFor("mouse modes dropped", func() bool { return e.screen(id).Modes.Mouse == "off" }) // Probe leaves the alt screen and exits on Q. e.send(id, "-l", "Q") e.waitFor("probe exit", func() bool { scr := e.screen(id) return scr.Exited && !scr.AltScreen && scr.ExitCode != nil && *scr.ExitCode == 0 }) } func TestMouseGatingAndDelivery(t *testing.T) { e := newEnv(t) // No mouse reporting → 409, loudly, with the modes attached. plain := e.newSession("sh") err := cli.Mouse(ctx(), e.c, []string{plain, "click", "5,3"}, io.Discard) if err == nil || !strings.Contains(err.Error(), "409") || !strings.Contains(err.Error(), "mouse=off") { t.Errorf("click without mouse mode: err = %v, want 409 with modes", err) } // ?1002 + ?1006: click arrives as exact SGR press+release bytes. id := e.newProbe("1002,1006") e.run(cli.Mouse, id, "click", "10,5") want := hexOf("\x1b[<0;11;6M" + "\x1b[<0;11;6m") e.waitFor("SGR click bytes", func() bool { return e.probeHex(id) == want }) // Drag is fine under ?1002 (motion+32 in the button bits)... e.run(cli.Mouse, id, "drag", "11,5") want += hexOf("\x1b[<32;12;6M") e.waitFor("SGR drag bytes", func() bool { return e.probeHex(id) == want }) // ...but bare motion needs ?1003. err = cli.Mouse(ctx(), e.c, []string{id, "move", "12,5"}, io.Discard) if err == nil || !strings.Contains(err.Error(), "1003") { t.Errorf("move under ?1002: err = %v, want 1003 explanation", err) } // Scroll defaults to wheel-up; modifiers are encoded. e.run(cli.Mouse, id, "scroll", "0,0") want += hexOf("\x1b[<64;1;1M") e.waitFor("SGR wheel bytes", func() bool { return e.probeHex(id) == want }) e.run(cli.Mouse, "-mod", "ctrl", id, "click", "0,0") want += hexOf("\x1b[<16;1;1M" + "\x1b[<16;1;1m") e.waitFor("ctrl-click bytes", func() bool { return e.probeHex(id) == want }) // Only ?1000: drag must be rejected. normal := e.newProbe("1000,1006") err = cli.Mouse(ctx(), e.c, []string{normal, "drag", "1,1"}, io.Discard) if err == nil || !strings.Contains(err.Error(), "1002") { t.Errorf("drag under ?1000: err = %v, want 1002 explanation", err) } } func TestResize(t *testing.T) { e := newEnv(t) id := e.newSession("sh") e.run(cli.Resize, id, "100x20") scr := e.screen(id) if scr.Cols != 100 || scr.Rows != 20 { t.Fatalf("screen size after resize: %dx%d", scr.Cols, scr.Rows) } // The child's tty must agree — stty reads the PTY, not our emulator. e.send(id, "stty size", "Enter") e.waitFor("stty output", func() bool { return strings.Contains(e.screenText(id), "20 100") }) } func TestInputValidation(t *testing.T) { e := newEnv(t) id := e.newSession("sh") for _, tc := range []struct { name string items []api.InputItem want string }{ {"unknown key", []api.InputItem{{Key: strPtr("NoSuchKey")}}, "NoSuchKey"}, {"empty", nil, "empty"}, {"two fields", []api.InputItem{{Text: strPtr("a"), Key: strPtr("b")}}, "exactly one"}, {"no fields", []api.InputItem{{}}, "exactly one"}, } { _, err := e.c.Input(ctx(), id, tc.items) if err == nil || !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: err = %v, want 400 mentioning %q", tc.name, err, tc.want) } } // Nothing from a rejected batch may be delivered: the screen stays quiet. if _, err := e.c.Input(ctx(), id, []api.InputItem{ {Text: strPtr("echo LEAKED")}, {Key: strPtr("BogusKey")}, }); err == nil { t.Fatal("batch with bad key accepted") } e.send(id, "echo marker", "Enter") e.waitFor("marker", func() bool { return strings.Contains(e.screenText(id), "marker") }) if strings.Contains(e.screenText(id), "LEAKED") { t.Errorf("rejected batch partially delivered:\n%s", e.screenText(id)) } } func TestScreenFormats(t *testing.T) { e := newEnv(t) id := e.newSession("sh") e.send(id, "printf '\\033[31mred\\033[0m\\n'", "Enter") e.waitFor("red output", func() bool { return strings.Contains(e.screenText(id), "red") }) scr, err := e.c.Screen(ctx(), id, true) if err != nil { t.Fatal(err) } if scr.Raw == "" || !strings.Contains(scr.Raw, "red") { t.Errorf("raw format missing styled content") } if plain := e.screen(id); plain.Raw != "" { t.Errorf("text format unexpectedly carries raw payload") } resp, err := http.Get(e.srv.URL + "/sessions/" + id + "/screen?format=nope") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 400 { t.Errorf("bad format: status %d, want 400", resp.StatusCode) } } func TestAuthToken(t *testing.T) { reg := session.NewRegistry() srv := httptest.NewServer(server.New(reg, "sekrit")) defer srv.Close() resp, err := http.Get(srv.URL + "/healthz") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 401 { t.Fatalf("no token: status %d, want 401", resp.StatusCode) } authed := client.New(srv.URL, "sekrit") if _, err := authed.List(ctx()); err != nil { t.Fatalf("with token: %v", err) } } // TestConcurrentScreenAndInput hammers a flooding session from many // goroutines; the race detector owns the assertions here. func TestConcurrentScreenAndInput(t *testing.T) { e := newEnv(t) id := e.newSession("sh", "-c", "while :; do echo spam; done") e.waitFor("flood running", func() bool { return strings.Contains(e.screenText(id), "spam") }) var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 25; j++ { if _, err := e.c.Screen(ctx(), id, j%2 == 0); err != nil { t.Errorf("screen: %v", err) return } if _, err := e.c.Input(ctx(), id, []api.InputItem{{Text: strPtr("x")}}); err != nil { t.Errorf("input: %v", err) return } } }() } wg.Wait() }