1//go:build linux
2
3package modes
4
5import (
6 "context"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/bnema/zut/packages/tui"
15)
16
17type blockingLoginClipboardTerminal struct {
18 alertTestTerminal
19 started chan struct{}
20 release chan struct{}
21 once sync.Once
22}
23
24func (t *blockingLoginClipboardTerminal) Write(p []byte) (int, error) {
25 t.once.Do(func() { close(t.started) })
26 <-t.release
27 return t.alertTestTerminal.Write(p)
28}
29
30func (t *blockingLoginClipboardTerminal) WriteString(s string) (int, error) {
31 return t.Write([]byte(s))
32}
33
34func TestInteractiveAltCUsesSystemClipboardWithoutTerminal(t *testing.T) {
35 dir := t.TempDir()
36 payload := filepath.Join(dir, "clipboard-payload")
37 if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil {
38 t.Fatal(err)
39 }
40 t.Setenv("PATH", dir)
41 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
42 t.Setenv("DISPLAY", "")
43 t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
44
45 const loginURL = "https://example.com/oauth/authorize?state=xyz"
46 i := NewInteractive(InteractiveConfig{})
47 i.dialog.Open(t.TempDir())
48 i.dialog.ShowWaiting(loginURL)
49
50 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
51
52 got, err := os.ReadFile(payload)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if string(got) != loginURL {
57 t.Fatalf("clipboard payload = %q, want %q", got, loginURL)
58 }
59 i.mu.Lock()
60 defer i.mu.Unlock()
61 if i.statusOK != "login URL copied" || i.statusErr != "" {
62 t.Fatalf("status = (%q, %q), want successful system copy", i.statusOK, i.statusErr)
63 }
64}
65
66func TestInteractiveAltCReportsSystemClipboardError(t *testing.T) {
67 dir := t.TempDir()
68 if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil {
69 t.Fatal(err)
70 }
71 t.Setenv("PATH", dir)
72 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
73 t.Setenv("DISPLAY", "")
74
75 i := NewInteractive(InteractiveConfig{})
76 i.dialog.Open(t.TempDir())
77 i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz")
78
79 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
80
81 i.mu.Lock()
82 defer i.mu.Unlock()
83 if !strings.Contains(i.statusErr, "exit status 23") {
84 t.Fatalf("status error = %q, want clipboard command failure", i.statusErr)
85 }
86 if i.statusOK != "" {
87 t.Fatalf("success status = %q, want empty", i.statusOK)
88 }
89}
90
91func TestInteractiveAltCUsesSystemClipboardWhenOSC52IsUnknown(t *testing.T) {
92 dir := t.TempDir()
93 payload := filepath.Join(dir, "clipboard-payload")
94 if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil {
95 t.Fatal(err)
96 }
97 t.Setenv("PATH", dir)
98 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
99 t.Setenv("DISPLAY", "")
100 t.Setenv("TERM_PROGRAM", "")
101 t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
102
103 const loginURL = "https://example.com/oauth/authorize?state=xyz"
104 term := &alertTestTerminal{}
105 i := NewInteractive(InteractiveConfig{Terminal: term})
106 i.dialog.Open(t.TempDir())
107 i.dialog.ShowWaiting(loginURL)
108
109 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
110
111 if got := term.String(); got != "" {
112 t.Fatalf("terminal output = %q, want no OSC 52 request", got)
113 }
114 got, err := os.ReadFile(payload)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if string(got) != loginURL {
119 t.Fatalf("clipboard payload = %q, want %q", got, loginURL)
120 }
121}
122
123func TestInteractiveAltCDoesNotHoldMutexDuringTerminalWrite(t *testing.T) {
124 t.Setenv("TERM_PROGRAM", "vev")
125 term := &blockingLoginClipboardTerminal{started: make(chan struct{}), release: make(chan struct{})}
126 i := NewInteractive(InteractiveConfig{Terminal: term})
127 i.dialog.Open(t.TempDir())
128 i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz")
129
130 done := make(chan struct{})
131 go func() {
132 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
133 close(done)
134 }()
135 <-term.started
136
137 unlocked := make(chan struct{})
138 go func() {
139 i.mu.Lock()
140 close(unlocked)
141 i.mu.Unlock()
142 }()
143 select {
144 case <-unlocked:
145 case <-time.After(time.Second):
146 t.Fatal("interactive mutex remained locked during terminal write")
147 }
148
149 close(term.release)
150 select {
151 case <-done:
152 case <-time.After(time.Second):
153 t.Fatal("copy did not finish after terminal write unblocked")
154 }
155}