package session
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"tobi/internal/protocol"
"tobi/internal/termcolor"
)
// t.TempDir() blows past the ~104 byte cap on a unix socket path on macos
func shortTempDir(t *testing.T) string {
dir, err := os.MkdirTemp("", "tb")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func startOwner(t *testing.T, cmd ...string) string {
id, sp := GenerateID(), ""
sp = SocketPath(id)
go func() { _ = RunOwner(id, cmd) }()
for i := 0; i < 50; i++ {
if c, err := net.Dial("unix", sp); err == nil {
_ = c.Close()
return sp
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("owner did not start: %s", sp)
return ""
}
func attach(t *testing.T, sp string) net.Conn {
c, err := net.Dial("unix", sp)
if err != nil {
t.Fatal(err)
}
_ = protocol.WriteFrame(c, protocol.TypeAttach, protocol.EncodeSize(80, 24))
if ft, _, err := protocol.ReadFrame(c); err != nil || ft != protocol.TypeSnapshot {
t.Fatalf("snapshot: %v", err)
}
return c
}
func expectFrame(t *testing.T, c net.Conn, want protocol.FrameType, contains string) {
_ = c.SetReadDeadline(time.Now().Add(time.Second))
for {
ft, p, err := protocol.ReadFrame(c)
if err != nil {
t.Fatalf("frame %v: %v", want, err)
}
if ft == want && (contains == "" || strings.Contains(string(p), contains)) {
return
}
}
}
func TestKill(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sleep", "60")
id := strings.TrimSuffix(filepath.Base(sp), ".sock")
if err := Kill(id); err != nil {
t.Fatal(err)
}
if list := ListLocal(); len(list) != 0 {
t.Fatalf("session survived: %v", list)
}
if err := Kill(id); err == nil {
t.Fatal("killing a dead session succeeded")
}
}
func TestSnapshotKeepsMouseEncoding(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh")
v := attach(t, sp)
defer v.Close()
// the tty echoes the typed line, so the marker is split by quotes to
// match only once the command has run
_ = protocol.WriteFrame(v, protocol.TypeInput, []byte("printf '\\033[?1002h\\033[?1006h'; echo do''ne\n"))
expectFrame(t, v, protocol.TypeOutput, "done")
c, err := net.Dial("unix", sp)
if err != nil {
t.Fatal(err)
}
defer c.Close()
_ = protocol.WriteFrame(c, protocol.TypeAttach, protocol.EncodeSize(80, 24))
if _, snap, err := protocol.ReadFrame(c); err != nil || !strings.Contains(string(snap), "\x1b[?1006h") {
t.Fatalf("snapshot lost sgr mouse: %v %q", err, snap)
}
}
func TestKillSignalsAnOwnerThatPredatesIt(t *testing.T) {
if sock := os.Getenv("TOBI_TEST_DEAF_OWNER"); sock != "" {
l, err := net.Listen("unix", sock)
if err != nil {
os.Exit(1)
}
for {
c, err := l.Accept()
if err != nil {
os.Exit(1)
}
_, _, _ = protocol.ReadFrame(c)
_ = c.Close()
}
}
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
id := GenerateID()
owner := exec.Command(os.Args[0], "-test.run=^TestKillSignalsAnOwnerThatPredatesIt$")
owner.Env = append(os.Environ(), "TOBI_TEST_DEAF_OWNER="+SocketPath(id))
if err := owner.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = owner.Process.Kill() })
for i := 0; i < 100 && !answers(SocketPath(id), 250*time.Millisecond); i++ {
time.Sleep(20 * time.Millisecond)
}
if err := Kill(id); err != nil {
t.Fatal(err)
}
if err := owner.Wait(); err == nil || !strings.Contains(err.Error(), "terminated") {
t.Fatalf("owner exit: %v", err)
}
}
func TestQueryFilter(t *testing.T) {
var f queryFilter
for _, tc := range []struct{ in, want string }{
{"\x1b]10", ""}, {";?\x1b\\", ""},
{"\x1b]4;1;?;2", ""}, {";?\a", ""},
{"hi\x1b[?6nthere\x1b[2", "hithere"}, {"Jok", "\x1b[2Jok"},
{"ok\x1b", "ok"}, {"[31mred", "\x1b[31mred"},
} {
if got := f.Feed([]byte(tc.in)).shared; string(got) != tc.want {
t.Fatalf("Feed(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestQueryFilterRoutesClipboardTraffic(t *testing.T) {
var f queryFilter
req := "\x1b]5522;type=read:id=a;aW1hZ2UvcG5n\x1b\\"
for _, tc := range []struct{ in, emulator, shared, routed string }{
{"hi\x1b]5522;type=re", "hi", "hi", ""},
{"ad:id=a;aW1hZ2UvcG5n\x1b\\there", "there", "there", req},
{"\x1b]52;c;?\a", "\x1b]52;c;?\a", "\x1b]52;c;?\a", ""},
{"\x1b[?5522$p", "", "", "\x1b[?5522$p"},
{"\x1b[?2026$p", "\x1b[?2026$p", "", ""},
} {
got := f.Feed([]byte(tc.in))
if string(got.emulator) != tc.emulator || string(got.shared) != tc.shared || string(got.routed) != tc.routed {
t.Fatalf("Feed(%q) = %q; want emulator %q, shared %q, routed %q", tc.in, got, tc.emulator, tc.shared, tc.routed)
}
}
}
func TestStaleSocketCleanup(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := filepath.Join(RuntimeDir(), "stale123456.sock")
_ = os.WriteFile(sp, []byte("stale"), 0600)
_ = ListLocal()
if _, err := os.Stat(sp); !os.IsNotExist(err) {
t.Fatal("stale socket not removed")
}
}
func TestOwnerMultiViewerHopAndOSC(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh")
v1, v2 := attach(t, sp), attach(t, sp)
defer v1.Close()
defer v2.Close()
cmd := "printf '\\033]2;hx main.go\\a\\033]7;file://localhost/custom/dir\\a'; echo sha''red\n" // quoted apart from the tty's echo
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte(cmd))
expectFrame(t, v1, protocol.TypeOutput, "shared")
expectFrame(t, v2, protocol.TypeOutput, "shared")
if err := SendHop(sp, "remote.123456"); err != nil {
t.Fatal(err)
}
expectFrame(t, v1, protocol.TypeHop, "remote.123456")
expectFrame(t, v2, protocol.TypeHop, "remote.123456")
if list := ListLocal(); len(list) == 0 || list[0].Command != "hx main.go" || list[0].Cwd != "/custom/dir" {
t.Fatalf("metadata mismatch: %v", list)
}
}
func TestHopRetiresOwnerOnceViewersLeave(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh")
v := attach(t, sp)
if err := SendHop(sp, "remote.123456"); err != nil {
t.Fatal(err)
}
expectFrame(t, v, protocol.TypeHop, "remote.123456")
if !answers(sp, 100*time.Millisecond) {
t.Fatal("owner went away while a viewer was still attached")
}
_ = v.Close()
waitGone(t, sp)
}
func TestHopToSelfKeepsOwner(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh")
self := ShortHostname() + "." + strings.TrimSuffix(filepath.Base(sp), ".sock")
v := attach(t, sp)
if err := SendHop(sp, self); err != nil {
t.Fatal(err)
}
expectFrame(t, v, protocol.TypeHop, self)
_ = v.Close()
time.Sleep(200 * time.Millisecond)
if !answers(sp, 100*time.Millisecond) {
t.Fatal("hop to the same session retired it")
}
}
func TestHopWithoutViewersKeepsOwner(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh")
if err := SendHop(sp, "remote.123456"); err != nil {
t.Fatal(err)
}
v := attach(t, sp)
_ = v.Close()
time.Sleep(200 * time.Millisecond)
if !answers(sp, 100*time.Millisecond) {
t.Fatal("a hop nobody saw retired the owner")
}
}
func waitGone(t *testing.T, sp string) {
t.Helper()
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
if !answers(sp, 50*time.Millisecond) {
return
}
}
t.Fatalf("owner still answering: %s", sp)
}
func TestSlowViewerDoesNotBlockOwner(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh", "-c", "dd if=/dev/zero bs=1024 count=2000; sleep 10")
slow, _ := net.Dial("unix", sp)
defer slow.Close()
_ = protocol.WriteFrame(slow, protocol.TypeAttach, protocol.EncodeSize(80, 24))
time.Sleep(300 * time.Millisecond)
conn, err := net.DialTimeout("unix", sp, 500*time.Millisecond)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
go func() { _ = SendHop(sp, "remote.blocked") }()
_ = protocol.WriteFrame(conn, protocol.TypeInfo, nil)
if ft, _, err := protocol.ReadFrame(conn); err != nil || ft != protocol.TypeInfoResp {
t.Fatalf("owner blocked: %v", err)
}
}
func TestDetachedIdleTTL(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
defer detachedTTL.Store(detachedTTL.Swap(int64(80 * time.Millisecond))) // the swap runs now, the restore on return
sp := startOwner(t, "sh", "-c", "sleep 10")
v := attach(t, sp)
_ = v.Close()
time.Sleep(150 * time.Millisecond)
if _, err := os.Stat(sp); !os.IsNotExist(err) {
t.Fatalf("socket not reaped: %v", err)
}
}
func TestEnsureOwnerNamesAMissingCommand(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
err := EnsureOwner(GenerateID(), []string{"tobi-no-such-command-xyz"})
if err == nil || !strings.Contains(err.Error(), "tobi-no-such-command-xyz") {
t.Fatalf("EnsureOwner = %v, want the missing command named", err)
}
if strings.Contains(err.Error(), "did not start") {
t.Fatalf("EnsureOwner = %v, want the real reason rather than a timeout", err)
}
}
func attachColors(t *testing.T, sp string, colors map[int]termcolor.Color) net.Conn {
c, err := net.Dial("unix", sp)
if err != nil {
t.Fatal(err)
}
_ = protocol.WriteFrame(c, protocol.TypeAttach, append(protocol.EncodeSize(80, 24), termcolor.EncodeColors(colors)...))
if ft, _, err := protocol.ReadFrame(c); err != nil || ft != protocol.TypeSnapshot {
t.Fatalf("snapshot: %v", err)
}
return c
}
func expectNoOutput(t *testing.T, c net.Conn, contains string) {
t.Helper()
_ = c.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
defer c.SetReadDeadline(time.Time{})
for {
ft, p, err := protocol.ReadFrame(c)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return
}
t.Fatalf("frame %q: %v", contains, err)
}
if ft == protocol.TypeOutput && strings.Contains(string(p), contains) {
t.Fatalf("unexpected %q", contains)
}
}
}
func TestOwnerAnswersAgreedColorsOnly(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh", "-c", "stty raw -echo; cat")
ask := func(v net.Conn, i int) {
_ = protocol.WriteFrame(v, protocol.TypeInput, []byte(fmt.Sprintf("\x1b]%d;?\x1b\\", i)))
}
fg := termcolor.Color{R: 0xffff, G: 0xffff, B: 0xffff}
v1 := attachColors(t, sp, map[int]termcolor.Color{termcolor.Foreground: fg, termcolor.Background: {R: 0, G: 0, B: 0}})
defer v1.Close()
v2 := attachColors(t, sp, map[int]termcolor.Color{termcolor.Foreground: fg, termcolor.Background: {R: 0, G: 0, B: 0xffff}})
defer v2.Close()
ask(v1, 10)
expectFrame(t, v1, protocol.TypeOutput, "\x1b]10;rgb:ffff/ffff/ffff")
ask(v1, 11)
expectNoOutput(t, v1, "\x1b]11;rgb:")
// a viewer without colors silences even agreed colors
v3 := attach(t, sp)
defer v3.Close()
ask(v1, 10)
expectNoOutput(t, v1, "\x1b]10;rgb:")
// once the disagreeing and unknown viewers detach the answer appears
v2.Close()
v3.Close()
time.Sleep(100 * time.Millisecond)
ask(v1, 11)
expectFrame(t, v1, protocol.TypeOutput, "\x1b]11;rgb:0000/0000/0000")
}
// every terminal that sees a clipboard read request prompts and answers it into
// the same pty, so only the viewer that last typed (the one who pressed paste)
// may see it
func TestClipboardRequestReachesOnlyTheTypingViewer(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh", "-c", "stty raw -echo; cat")
time.Sleep(200 * time.Millisecond) // until the tty stops echoing, which would show v2 the request
v1, v2 := attach(t, sp), attach(t, sp)
defer v1.Close()
defer v2.Close()
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b]5522;type=read:id=x;aW1hZ2UvcG5n\x1b\\"))
expectFrame(t, v1, protocol.TypeOutput, "\x1b]5522;type=read:id=x;")
expectNoOutput(t, v2, "5522")
// the reply goes back through the same viewer and reaches the app, which cat echoes
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("replydone\n"))
expectFrame(t, v1, protocol.TypeOutput, "replydone")
expectFrame(t, v2, protocol.TypeOutput, "replydone")
_ = protocol.WriteFrame(v2, protocol.TypeInput, []byte("\x1b]5522;type=read:id=y;aW1hZ2UvcG5n\x1b\\"))
expectFrame(t, v2, protocol.TypeOutput, "\x1b]5522;type=read:id=y;")
expectNoOutput(t, v1, "id=y")
}
// the owner's emulator would answer the capability probe with "mode unknown"
// before the real terminal could, so the probe must reach only that terminal
func TestClipboardProbeIsLeftToTheTypingViewersTerminal(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
sp := startOwner(t, "sh", "-c", "stty raw -echo; cat")
time.Sleep(200 * time.Millisecond)
v1, v2 := attach(t, sp), attach(t, sp)
defer v1.Close()
defer v2.Close()
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b[?5522$p"))
expectFrame(t, v1, protocol.TypeOutput, "\x1b[?5522$p")
expectNoOutput(t, v1, "5522;")
expectNoOutput(t, v2, "5522")
}
func TestAttachPayloadKeepsLegacySize(t *testing.T) {
colors := map[int]termcolor.Color{termcolor.Foreground: {R: 1, G: 2, B: 3}}
payload := append(protocol.EncodeSize(120, 40), termcolor.EncodeColors(colors)...)
c, r, err := protocol.DecodeSize(payload)
if err != nil || c != 120 || r != 40 {
t.Fatalf("DecodeSize on a longer payload = %d %d %v, want the size bytes only", c, r, err)
}
if colors2, ok := termcolor.DecodeColors(payload[4:]); !ok || colors2[termcolor.Foreground] != (termcolor.Color{R: 1, G: 2, B: 3}) {
t.Fatalf("DecodeColors on the tail = %v %v", colors2, ok)
}
}