okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
3704fc3
main
1// The daemon E2E cannot drive terminal clipboard replies. These tests catch 2// chunk corruption, stale replies attaching to a new draft, input-reader 3// routing that would swallow OSC 5522 or insert its bytes into the composer, and 4// a capability probe that sends a paste to the wrong clipboard. 5package tui 6 7import ( 8"bytes" 9"context" 10"encoding/base64" 11"fmt" 12"image" 13"image/png" 14"io" 15"strings" 16"testing" 17"time" 18 19"albedo/cli/internal/daemon" 20 tea"charm.land/bubbletea/v2" 21 uv"github.com/charmbracelet/ultraviolet" 22"github.com/charmbracelet/x/ansi" 23) 24 25func clipboardPacket (id ,status ,mime ,payload string )string { 26metadata := "type=read:id=" + id + ":status=" + status 27if mime != "" { 28metadata += ":mime=" + base64 .StdEncoding .EncodeToString ([]byte (mime )) 29 } 30return "\x1b]5522;" + metadata + ";" + payload + "\x1b\\" 31} 32 33func clipboardPNG (t * testing.T ) []byte { 34t .Helper () 35var out bytes.Buffer 36if err := png .Encode (& out ,image .NewRGBA (image .Rect (0 ,0 ,2 ,3 )));err != nil { 37t .Fatal (err ) 38 } 39return out .Bytes () 40} 41 42func setClipboardEnv (t * testing.T ,remote bool ) { 43t .Helper () 44for _ ,name := range []string {"SSH_CONNECTION" ,"SSH_CLIENT" ,"MOSH_CONNECTION" } { 45t .Setenv (name ,"" ) 46 } 47if remote { 48t .Setenv ("SSH_CONNECTION" ,"audit" ) 49 } 50} 51 52func clipboardApp (t * testing.T )* AppModel { 53t .Helper () 54setClipboardEnv (t ,true ) 55t .Setenv ("TERM" ,"xterm" ) 56t .Setenv ("TERM_PROGRAM" ,"" ) 57t .Setenv ("TMUX" ,"" ) 58t .Setenv ("KITTY_WINDOW_ID" ,"" ) 59return & AppModel { 60ActiveSession :& daemon.Session {ID :"s" }, 61Chat :composer (t ,80 ,25 ),State :AppStateChat , 62 } 63} 64 65func reportClipboardMode (app * AppModel ,value ansi.ModeSetting ) tea.Cmd { 66_ ,cmd := app .Update (tea.ModeReportMsg {Mode :kittyClipboardMode ,Value :value }) 67return cmd 68} 69 70// startClipboardRead presses ctrl+v on a terminal that supports the protocol 71// and returns the id of the read it starts. 72func startClipboardRead (app * AppModel )string { 73app .Update (tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl }) 74reportClipboardMode (app ,ansi .ModeReset ) 75return app .Chat .terminalClipboard .id 76} 77 78func deliverClipboardPacket (t * testing.T ,app * AppModel ,packet string ) { 79t .Helper () 80var decoder uv.EventDecoder 81n ,event := decoder .Decode ([]byte (packet )) 82if n != len (packet ) { 83t .Fatalf ("terminal decoder consumed %d of %d bytes" ,n ,len (packet )) 84 } 85_ ,cmd := app .Update (event ) 86if cmd != nil { 87msg := cmd () 88if _ ,ok := msg .(ClipboardImagePastedMsg );! ok { 89t .Fatalf ("unexpected clipboard completion %T" ,msg ) 90 } 91app .Update (msg ) 92 } 93} 94 95func TestClipboardProbeChoosesWhereToPasteFrom (t * testing.T ) { 96for _ ,tc := range []struct { 97name string 98remote bool 99answer func (* AppModel ) tea.Cmd 100read bool 101 }{ 102 {"terminal supports it" ,false ,func (a * AppModel ) tea.Cmd {return reportClipboardMode (a ,ansi .ModeReset ) },true }, 103 {"terminal supports it while set" ,false ,func (a * AppModel ) tea.Cmd {return reportClipboardMode (a ,ansi .ModeSet ) },true }, 104 {"local terminal does not know the mode" ,false ,func (a * AppModel ) tea.Cmd {return reportClipboardMode (a ,ansi .ModeNotRecognized ) },false }, 105 {"local terminal pins the mode off" ,false ,func (a * AppModel ) tea.Cmd {return reportClipboardMode (a ,ansi .ModePermanentlyReset ) },false }, 106 {"ssh terminal does not know the mode" ,true ,func (a * AppModel ) tea.Cmd {return reportClipboardMode (a ,ansi .ModeNotRecognized ) },true }, 107 {"local terminal stays silent" ,false ,func (a * AppModel ) tea.Cmd {return probeTimeout (a ) },false }, 108 {"ssh terminal stays silent" ,true ,func (a * AppModel ) tea.Cmd {return probeTimeout (a ) },true }, 109 } { 110t .Run (tc .name ,func (t * testing.T ) { 111app := clipboardApp (t ) 112setClipboardEnv (t ,tc .remote ) 113app .Update (tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl }) 114if ! app .Chat .terminalClipboard .probing { 115t .Fatal ("ctrl+v did not probe the terminal" ) 116 } 117if cmd := tc .answer (app );cmd == nil { 118t .Fatal ("deciding a paste returned no command" ) 119 } 120if reading := app .Chat .terminalClipboard .id != "" ;reading != tc .read || app .Chat .terminalClipboard .probing { 121t .Fatalf ("terminal read = %v, want %v" ,reading ,tc .read ) 122 } 123 }) 124 } 125} 126 127func probeTimeout (app * AppModel ) tea.Cmd { 128_ ,cmd := app .Update (terminalClipboardProbeTimeoutMsg {app .Chat .SessionID ,app .Chat .terminalClipboard .id ,app .Chat .Generation }) 129return cmd 130} 131 132func TestClipboardProbeIgnoresLateAndUnrelatedReports (t * testing.T ) { 133app := clipboardApp (t ) 134setClipboardEnv (t ,false ) 135app .Update (tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl }) 136id := app .Chat .terminalClipboard .id 137app .Update (tea.ModeReportMsg {Mode :ansi .ModeBracketedPaste ,Value :ansi .ModeNotRecognized }) 138if ! app .Chat .terminalClipboard .probing { 139t .Fatal ("a report for another mode ended the probe" ) 140 } 141app .Update (terminalClipboardProbeTimeoutMsg {"other" ,id ,app .Chat .Generation }) 142app .Update (terminalClipboardProbeTimeoutMsg {app .Chat .SessionID ,"stale" ,app .Chat .Generation }) 143if ! app .Chat .terminalClipboard .probing { 144t .Fatal ("a stale timeout ended the probe" ) 145 } 146probeTimeout (app ) 147if app .Chat .terminalClipboard .id != "" { 148t .Fatal ("a silent local terminal did not fall back to local tools" ) 149 } 150if cmd := reportClipboardMode (app ,ansi .ModeReset );cmd != nil || app .Chat .terminalClipboard .id != "" { 151t .Fatal ("a report after the timeout started a read" ) 152 } 153} 154 155func TestSSHClipboardPasteAcrossModal (t * testing.T ) { 156app := clipboardApp (t ) 157app .Chat .TextArea .SetValue ("describe " ) 158app .Update (tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl }) 159if _ ,cmd := app .Update (tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl });cmd != nil { 160t .Fatal ("repeated ctrl+v started a second probe" ) 161 } 162app .State = AppStateModelPicker 163reportClipboardMode (app ,ansi .ModeReset ) 164id := app .Chat .terminalClipboard .id 165if id == "" || app .Chat .terminalClipboard .probing { 166t .Fatal ("the probe reply did not start a terminal clipboard read" ) 167 } 168if _ ,cmd := app .Update (tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl });cmd != nil { 169t .Fatal ("repeated ctrl+v started a second read" ) 170 } 171data := clipboardPNG (t ) 172deliverClipboardPacket (t ,app ,clipboardPacket (id ,"OK" ,"" ,"" )) 173for _ ,chunk := range [][]byte {data [:13 ],data [13 :]} { 174deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DATA" ,"image/png" ,base64 .StdEncoding .EncodeToString (chunk ))) 175 } 176deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DONE" ,"" ,"" )) 177images := app .Chat .Images .referenced (app .Chat .TextArea .Value ()) 178if len (images )!= 1 || app .Chat .Images .byNumber [images [0 ]].Data != base64 .StdEncoding .EncodeToString (data ) { 179t .Fatalf ("clipboard chunks did not attach intact: %v" ,images ) 180 } 181if ! strings .HasPrefix (app .Chat .TextArea .Value (),"describe " )|| app .Chat .terminalClipboard .id != "" { 182t .Fatal ("paste lost the draft or left the read pending" ) 183 } 184app .Chat ,_ = app .Chat .Update (tea.PasteMsg {Content :"plain text" }) 185if ! strings .HasSuffix (app .Chat .TextArea .Value (),"plain text" ) { 186t .Fatal ("normal text paste was intercepted" ) 187 } 188} 189 190func TestSSHClipboardRejectsInvalidReplies (t * testing.T ) { 191for _ ,scenario := range []string { 192"denied" ,"unsupported" ,"busy" ,"bad-base64" ,"chunk-limit" ,"image-limit" , 193"data-before-ok" ,"duplicate-ok" ,"empty" ,"invalid-image" ,"timeout" , 194 } { 195t .Run (scenario ,func (t * testing.T ) { 196app := clipboardApp (t ) 197app .Chat .TextArea .SetValue ("keep this draft" ) 198id := startClipboardRead (app ) 199if scenario != "data-before-ok" { 200deliverClipboardPacket (t ,app ,clipboardPacket (id ,"OK" ,"" ,"" )) 201 } 202switch scenario { 203case "denied" ,"unsupported" ,"busy" : 204status := map [string ]string {"denied" :"EPERM" ,"unsupported" :"ENOSYS" ,"busy" :"EBUSY" }[scenario ] 205deliverClipboardPacket (t ,app ,clipboardPacket (id ,status ,"" ,"" )) 206case "bad-base64" : 207deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DATA" ,"image/png" ,"YQ==\n" )) 208case "chunk-limit" : 209deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DATA" ,"image/png" ,base64 .StdEncoding .EncodeToString (make ([]byte ,4097 )))) 210case "image-limit" : 211chunk := base64 .StdEncoding .EncodeToString (make ([]byte ,4096 )) 212for range MaxClipboardImageBytes / 4096 + 1 { 213deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DATA" ,"image/png" ,chunk )) 214 } 215case "data-before-ok" : 216deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DATA" ,"image/png" ,"YQ==" )) 217case "duplicate-ok" : 218deliverClipboardPacket (t ,app ,clipboardPacket (id ,"OK" ,"" ,"" )) 219case "invalid-image" : 220deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DATA" ,"image/png" ,"YQ==" )) 221deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DONE" ,"" ,"" )) 222case "empty" : 223deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DONE" ,"" ,"" )) 224case "timeout" : 225_ ,cmd := app .Update (terminalClipboardTimeoutMsg {app .Chat .SessionID ,id ,app .Chat .Generation }) 226app .Update (cmd ()) 227 } 228if ! app .Chat .Notices .HasError ()|| app .Chat .terminalClipboard .id != "" { 229t .Fatal ("failed paste did not report an error and release its buffer" ) 230 } 231if app .Chat .TextArea .Value ()!= "keep this draft" || len (app .Chat .Images .referenced (app .Chat .TextArea .Value ()))!= 0 { 232t .Fatal ("failed paste changed the draft" ) 233 } 234// A delayed DONE must not attach data after failure or after retry. 235startClipboardRead (app ) 236deliverClipboardPacket (t ,app ,clipboardPacket (id ,"DONE" ,"" ,"" )) 237if app .Chat .terminalClipboard .id == "" { 238t .Fatal ("a stale response completed the new paste" ) 239 } 240 }) 241 } 242} 243 244func TestSSHClipboardIgnoresOtherSessionAndGeneration (t * testing.T ) { 245app := clipboardApp (t ) 246oldID := startClipboardRead (app ) 247oldGen := app .Chat .Generation 248app .Chat .Close () 249app .Chat = composer (t ,80 ,25 ) 250id := startClipboardRead (app ) 251deliverClipboardPacket (t ,app ,clipboardPacket (oldID ,"OK" ,"" ,"" )) 252deliverClipboardPacket (t ,app ,clipboardPacket (oldID ,"DATA" ,"image/png" ,strings .Repeat ("A" ,9000 ))) 253app .Update (terminalClipboardTimeoutMsg {"other" ,id ,app .Chat .Generation }) 254app .Update (terminalClipboardTimeoutMsg {"s" ,oldID ,oldGen }) 255if app .Chat .terminalClipboard .id != id || app .Chat .terminalClipboard .started || app .Chat .Notices .HasError () { 256t .Fatal ("old session replies changed the new paste" ) 257 } 258} 259 260// This model runs the real Bubble Tea reader/writer without starting daemon I/O. 261type clipboardTerminalProbe struct {app * AppModel } 262 263func (m clipboardTerminalProbe )Init () tea.Cmd { 264return func () tea.Msg {return tea.KeyPressMsg {Code :'v' ,Mod :tea .ModCtrl } } 265} 266func (m clipboardTerminalProbe )View () tea.View {return tea .NewView ("" ) } 267func (m clipboardTerminalProbe )Update (msg tea.Msg ) (tea.Model , tea.Cmd ) { 268_ ,cmd := m .app .Update (msg ) 269if _ ,ok := msg .(ClipboardImagePastedMsg );ok { 270return m ,tea .Quit 271 } 272return m ,cmd 273} 274 275type clipboardTerminalWriter struct {requests chan string } 276 277func (w clipboardTerminalWriter )Write (p []byte ) (int ,error ) { 278out := string (p ) 279if strings .Contains (out ,ansi .RequestMode (kittyClipboardMode )) { 280w .requests <- ansi .RequestMode (kittyClipboardMode ) 281 } 282if start := strings .Index (out ,"\x1b]5522;type=read:" );start >= 0 { 283packet ,_ ,_ := strings .Cut (out [start :],"\x1b\\" ) 284w .requests <- packet + "\x1b\\" 285 } 286return len (p ),nil 287} 288 289func TestSSHClipboardThroughBubbleTeaReader (t * testing.T ) { 290app := clipboardApp (t ) 291input ,replies := io .Pipe () 292defer input .Close () 293defer replies .Close () 294requests := make (chan string ,2 ) 295ctx ,cancel := context .WithTimeout (context .Background (),5 * time .Second ) 296defer cancel () 297program := tea .NewProgram (clipboardTerminalProbe {app },tea .WithInput (input ), 298tea .WithOutput (clipboardTerminalWriter {requests }),tea .WithContext (ctx ),tea .WithoutSignalHandler ()) 299done := make (chan error ,1 ) 300go func () {_ ,err := program .Run ();done <- err }() 301nextRequest := func ()string { 302select { 303case request := <- requests : 304return request 305case <- ctx .Done (): 306t .Fatal ("Bubble Tea never wrote the next clipboard request" ) 307return "" 308 } 309 } 310if probe := nextRequest ();probe != ansi .RequestMode (kittyClipboardMode ) { 311t .Fatalf ("first request was %q, want the capability probe" ,probe ) 312 } 313if _ ,err := fmt .Fprint (replies ,ansi .ReportMode (kittyClipboardMode ,ansi .ModeReset ));err != nil { 314t .Fatal (err ) 315 } 316request := nextRequest () 317metadata ,payload ,_ := strings .Cut (strings .TrimSuffix (strings .TrimPrefix (request ,"\x1b]5522;" ),"\x1b\\" ),";" ) 318id := strings .TrimPrefix (metadata ,"type=read:id=" ) 319if payload != base64 .StdEncoding .EncodeToString ([]byte ("image/png image/jpeg" )) { 320t .Fatalf ("unexpected clipboard request: %q" ,request ) 321 } 322data := clipboardPNG (t ) 323response := clipboardPacket (id ,"OK" ,"" ,"" )+ 324clipboardPacket (id ,"DATA" ,"image/png" ,base64 .StdEncoding .EncodeToString (data ))+ 325clipboardPacket (id ,"DONE" ,"" ,"" ) 326// Split even the escape sequence across writes, as an SSH stream can. 327for _ ,part := range []string {response [:1 ],response [1 :19 ],response [19 :]} { 328if _ ,err := fmt .Fprint (replies ,part );err != nil { 329t .Fatal (err ) 330 } 331 } 332select { 333case err := <- done : 334if err != nil { 335t .Fatal (err ) 336 } 337case <- ctx .Done (): 338t .Fatal ("Bubble Tea did not finish the image paste" ) 339 } 340if len (app .Chat .Images .referenced (app .Chat .TextArea .Value ()))!= 1 || app .Chat .Notices .HasError () { 341t .Fatal ("terminal reader failed to deliver the local image" ) 342 } 343}