//go:build unix // Real-daemon TUI fixture: drives an AppModel directly through Bubble Tea // update steps against the suite's shared daemon. package e2e import ( "context" "reflect" "slices" "strings" "sync" "testing" "albedo/cli/internal/daemon" "albedo/cli/internal/tui" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) // tuiDriver drives a full tui.AppModel against the suite's real daemon, // stepping Bubble Tea commands synchronously in place of the event loop. type tuiDriver struct { t *testing.T App *tui.AppModel } // newTUIDriver opens a fresh daemon session in a temp workspace. func newTUIDriver(t *testing.T) *tuiDriver { t.Helper() session := daemonSession(t, newSession(t, t.TempDir())) return driveTUI(t, &session) } // driveTUI boots an AppModel on session, or on the session picker when nil. func driveTUI(t *testing.T, session *daemon.Session) *tuiDriver { t.Helper() return driveTUIWithConnection(t, session, conn(t)) } func driveTUIWithConnection(t *testing.T, session *daemon.Session, connection *daemon.Connection) *tuiDriver { t.Helper() workspace := t.TempDir() if session != nil { workspace = session.Workspace } settings, err := daemon.GetSettings(t.Context(), connection) if err != nil { t.Fatal(err) } sessions, err := daemon.ListSessions(t.Context(), connection) if err != nil { t.Fatal(err) } d := &tuiDriver{t: t, App: tui.NewAppModel(connection, tui.Bootstrap{Sessions: sessions, Settings: settings}, session, workspace, false, nil)} d.Update(tea.WindowSizeMsg{Width: 80, Height: 22}) return d } func daemonSessions(t *testing.T) []daemon.Session { t.Helper() sessions, err := daemon.ListSessions(context.Background(), conn(t)) if err != nil { t.Fatalf("list sessions: %v", err) } return sessions } func daemonSession(t *testing.T, id string) daemon.Session { t.Helper() session, err := daemon.GetSession(t.Context(), conn(t), id) if err != nil { t.Fatalf("read session %s: %v", id, err) } return session } // Update feeds one message to the model and returns its command. func (d *tuiDriver) Update(msg tea.Msg) tea.Cmd { d.t.Helper() updated, cmd := d.App.Update(msg) d.App = updated.(*tui.AppModel) return cmd } // Send feeds msg to the model and returns what its command produced, or nil // when the update returned no command. func (d *tuiDriver) Send(msg tea.Msg) tea.Msg { d.t.Helper() results := d.results(d.Update(msg)) if len(results) > 1 { d.t.Fatalf("expected one result, got %#v", results) } if len(results) == 0 { return nil } return results[0] } // Key presses code and returns what its command produced. func (d *tuiDriver) Key(code rune) tea.Msg { d.t.Helper() return d.Send(tea.KeyPressMsg{Code: code}) } // Dispatch feeds msg and every result it leads to back into the model until // it settles. func (d *tuiDriver) Dispatch(msg tea.Msg) { d.t.Helper() d.settle(d.results(d.Update(msg))) } // Type presses each rune of s as fast as a person types and then settles once: // the event loop runs the keys' commands side by side, so a debounce waits once // for the whole text instead of once per key. func (d *tuiDriver) Type(s string) { d.t.Helper() var cmds []tea.Cmd for _, r := range s { cmds = append(cmds, d.Update(tea.KeyPressMsg{Code: r, Text: string(r)})) } d.settle(d.results(tea.Batch(cmds...))) } // settle feeds queued messages, and the results they lead to, until none are // left; cursor blinks are dropped because nothing consumes them. func (d *tuiDriver) settle(queue []tea.Msg) { d.t.Helper() for step := 0; len(queue) > 0; step++ { if step == 64 { d.t.Fatal("the model never settled") } var msg tea.Msg msg, queue = queue[0], queue[1:] queue = append(queue, d.results(d.Update(msg))...) } } // results runs cmd, and every command batched inside it side by side as the // event loop does, and returns what they produced in batch order. func (d *tuiDriver) results(cmd tea.Cmd) []tea.Msg { if cmd == nil { return nil } switch msg := cmd().(type) { case nil: return nil case tea.BatchMsg: produced := make([][]tea.Msg, len(msg)) var wg sync.WaitGroup for i, c := range msg { wg.Go(func() { produced[i] = d.results(c) }) } wg.Wait() return slices.Concat(produced...) default: if _, recurring := msg.(tui.ChatStatusPollMsg); recurring { return nil } if reflect.TypeOf(msg).PkgPath() == "charm.land/bubbles/v2/cursor" { return nil } return []tea.Msg{msg} } } // connected answers the chat's status poll once, as the event loop would // before the composer lets a message through. func (d *tuiDriver) connected() { d.t.Helper() chat := d.App.Chat d.Update(d.Update(tui.ChatStatusPollMsg{SessionID: chat.SessionID, Generation: chat.Generation})()) if view := d.View(); strings.Contains(view, "connecting…") { d.t.Fatalf("the session still reads as connecting after its status answered:\n%s", view) } } // pasteImage delivers image as a clipboard paste at the composer's cursor. func (d *tuiDriver) pasteImage(image *daemon.ImageAttachment) { chat := d.App.Chat d.Update(tui.ClipboardImagePastedMsg{SessionID: chat.SessionID, Generation: chat.Generation, Image: image}) } // View returns the rendered terminal contents without styling. func (d *tuiDriver) View() string { d.t.Helper() content := d.App.View().Content if strings.Contains(content, "\x1b_albedo:") { d.t.Fatalf("private row marks reached the terminal: %q", content) } return ansi.Strip(content) }