1package modes
2
3import (
4 "context"
5 "net/url"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/bnema/zut/packages/provider/auth"
12 "github.com/bnema/zut/packages/tui"
13)
14
15func TestLoginDialogProviderPickerFiltersLikeModelPicker(t *testing.T) {
16 d := newLoginDialog()
17 d.step = loginStepProvider
18 d.method = "apikey"
19 d.status = map[string]string{}
20 for _, r := range "llamacpp" {
21 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
22 }
23 options := d.providerOptions()
24 if len(options) != 1 || options[0] != "llama.cpp" {
25 t.Fatalf("options = %v", options)
26 }
27 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
28 if d.step != loginStepLlamaURL {
29 t.Fatalf("step = %v", d.step)
30 }
31}
32
33func TestLoginDialogProviderPickerShowsNoMatches(t *testing.T) {
34 d := newLoginDialog()
35 d.step = loginStepProvider
36 d.method = "apikey"
37 d.status = map[string]string{}
38 d.providerQuery = "not-a-provider"
39 if options := d.providerOptions(); len(options) != 0 {
40 t.Fatalf("options = %v", options)
41 }
42 d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
43 if d.cursor != 0 {
44 t.Fatalf("cursor = %d", d.cursor)
45 }
46 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action != (loginDialogAction{}) {
47 t.Fatalf("action = %+v", action)
48 }
49}
50
51func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
52 d := newLoginDialog()
53 d.step = loginStepLlamaURL
54 d.provider = "llama.cpp"
55 d.method = "apikey"
56 d.llamaEd = tui.NewEditor("")
57
58 d.llamaEd.SetValue("file:///tmp/router")
59 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SaveLlama || d.step != loginStepLlamaURL {
60 t.Fatalf("invalid URL advanced dialog: action=%+v step=%v", action, d.step)
61 }
62 if !strings.Contains(d.message, "http or https") {
63 t.Fatalf("validation message = %q", d.message)
64 }
65
66 d.llamaEd.SetValue("http://127.0.0.1:8080/v1/")
67 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
68 if d.step != loginStepLlamaKey || d.llamaURL != "http://127.0.0.1:8080" {
69 t.Fatalf("step=%v URL=%q", d.step, d.llamaURL)
70 }
71 action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
72 if !action.SaveLlama || action.LlamaURL != "http://127.0.0.1:8080" || action.LlamaAPIKey != "" {
73 t.Fatalf("action = %+v", action)
74 }
75}
76
77func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
78 d := newLoginDialog()
79 d.Open(t.TempDir())
80 d.method = "oauth"
81 d.provider = "anthropic"
82 d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
83
84 // Floating panes render this dialog narrower than the main terminal, so
85 // CursorPos must use the identical width to retain wrapping alignment.
86 const paneWidth = 62
87 lines := padDialogFrame(d.Render(tui.Theme{}, paneWidth))
88 row, _ := d.CursorPos(paneWidth)
89 if row < 0 || row >= len(lines) {
90 t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines))
91 }
92 if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") {
93 t.Fatalf("CursorPos row %d = %q; want editor input row", row, got)
94 }
95}
96
97func TestLoginDialogWaitingShowsCopyURLShortcut(t *testing.T) {
98 for _, provider := range []string{"openai-codex", "kimi", "xai", "github-copilot"} {
99 t.Run(provider, func(t *testing.T) {
100 d := newLoginDialog()
101 d.Open(t.TempDir())
102 d.method = "oauth"
103 d.provider = provider
104 d.ShowWaiting("https://example.com/oauth/authorize?state=xyz")
105
106 lines := strings.Join(d.Render(tui.Theme{}, 80), "\n")
107 if !strings.Contains(lines, "alt+c copies URL") {
108 t.Fatalf("login dialog does not advertise the copy shortcut: %q", lines)
109 }
110 })
111 }
112}
113
114func TestInteractiveAltCSendsLoginURLToTerminalClipboard(t *testing.T) {
115 t.Setenv("PATH", t.TempDir())
116 t.Setenv("WAYLAND_DISPLAY", "")
117 t.Setenv("DISPLAY", "")
118 t.Setenv("TERM_PROGRAM", "vev")
119
120 const loginURL = "https://example.com/oauth/authorize?state=xyz"
121 term := &alertTestTerminal{}
122 i := NewInteractive(InteractiveConfig{Terminal: term})
123 i.dialog.Open(t.TempDir())
124 i.dialog.method = "oauth"
125 i.dialog.provider = "openai-codex"
126 i.dialog.ShowWaiting(loginURL)
127 i.dialog.Render(tui.Theme{}, 80)
128
129 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
130
131 const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a"
132 if got := term.String(); got != want {
133 t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want)
134 }
135 if got := i.dialog.codeEd.SubmitValue(); got != "" {
136 t.Fatalf("authorization code input = %q after alt+c, want unchanged", got)
137 }
138 i.mu.Lock()
139 defer i.mu.Unlock()
140 if i.statusOK != "sent login URL to terminal clipboard" || i.statusErr != "" {
141 t.Fatalf("status = (%q, %q), want successful terminal copy request", i.statusOK, i.statusErr)
142 }
143}
144
145func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T) {
146 dir := t.TempDir()
147 state := filepath.Join(dir, "wl-copy-called")
148 if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\n: > \"$CLIPBOARD_TEST_STATE\"\n"), 0o700); err != nil {
149 t.Fatal(err)
150 }
151 t.Setenv("PATH", dir)
152 t.Setenv("WAYLAND_DISPLAY", "wayland-test")
153 t.Setenv("DISPLAY", "")
154 t.Setenv("TERM_PROGRAM", "vev")
155 t.Setenv("CLIPBOARD_TEST_STATE", state)
156
157 const loginURL = "https://example.com/oauth/authorize?state=xyz"
158 term := &alertTestTerminal{}
159 i := NewInteractive(InteractiveConfig{Terminal: term})
160 i.dialog.Open(t.TempDir())
161 i.dialog.ShowWaiting(loginURL)
162
163 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
164
165 const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a"
166 if got := term.String(); got != want {
167 t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want)
168 }
169 if _, err := os.Stat(state); !os.IsNotExist(err) {
170 t.Fatalf("system clipboard helper ran: %v", err)
171 }
172}
173
174func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
175 d := newLoginDialog()
176 d.Open(t.TempDir())
177 d.method = "oauth"
178 d.provider = "openai-codex"
179 d.ShowWaiting("http://localhost:1455/auth/callback")
180
181 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
182 if !strings.Contains(text, "paste the authorization code") {
183 t.Fatal("OpenAI OAuth dialog does not offer pasted code entry")
184 }
185 d.codeEd.SetValue("code#state")
186 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" {
187 t.Fatalf("code submission = %+v", action)
188 }
189}
190
191func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
192 for _, tc := range []struct {
193 name string
194 choice int
195 manual bool
196 step loginStep
197 }{
198 {name: "browser", choice: 0, step: loginStepWaiting},
199 {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
200 } {
201 t.Run(tc.name, func(t *testing.T) {
202 d := newLoginDialog()
203 d.Open(t.TempDir())
204 d.method = "oauth"
205 d.step = loginStepProvider
206 for idx, p := range d.providerOptions() {
207 if p == "anthropic" {
208 d.cursor = idx
209 break
210 }
211 }
212 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
213 t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
214 }
215 for i := 0; i < tc.choice; i++ {
216 d.HandleKey(tui.Key{Kind: tui.KeyDown})
217 }
218 action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
219 if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
220 t.Fatalf("flow action=%+v step=%v", action, d.step)
221 }
222 })
223 }
224}
225
226func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
227 manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
228 t.Cleanup(manager.Close)
229 i := NewInteractive(InteractiveConfig{AuthManager: manager})
230 i.dialog.Open(t.TempDir())
231 i.dialog.method = "oauth"
232 i.dialog.provider = "anthropic"
233 i.dialog.step = loginStepOAuthMethod
234 i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
235 act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
236 if !act.StartManual || act.Provider != "anthropic" {
237 t.Fatalf("expected manual Anthropic transaction, got %+v", act)
238 }
239 i.startManualOAuthFlow(act.Provider)
240 i.handleAuthEvent(<-manager.Events())
241 if i.dialog.step != loginStepPasteCode {
242 t.Fatalf("step = %v, want paste code", i.dialog.step)
243 }
244 u, err := url.Parse(i.dialog.url)
245 if err != nil {
246 t.Fatal(err)
247 }
248 if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
249 t.Fatalf("redirect URI = %q, want copy-code redirect", got)
250 }
251 if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
252 t.Fatalf("manual transaction not ready for code entry: %v", err)
253 }
254}
255
256func TestLoginDialogCloseCancelsOAuthTransaction(t *testing.T) {
257 manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
258 t.Cleanup(manager.Close)
259 i := NewInteractive(InteractiveConfig{AuthManager: manager})
260 i.dialog.Open(t.TempDir())
261 i.dialog.method = "oauth"
262 i.dialog.provider = "anthropic"
263 if _, err := manager.StartManualOAuth("anthropic"); err != nil {
264 t.Fatal(err)
265 }
266 i.dialog.ShowPasteCode("https://example.com/oauth/authorize")
267 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
268 if i.dialog.Active() {
269 t.Fatal("dialog still active after esc")
270 }
271 if err := manager.CompleteManualOAuth(context.Background(), "code"); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") {
272 t.Fatalf("oauth transaction survived dialog close: %v", err)
273 }
274}