//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/config"
"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
}
d := &tuiDriver{t: t, App: tui.NewAppModel(connection, config.Profiles{}, 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()
for _, s := range daemonSessions(t) {
if s.ID == id {
return s
}
}
t.Fatalf("the daemon does not list session %s", id)
return daemon.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 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)
}
}
// View returns the rendered terminal contents without styling.
func (d *tuiDriver) View() string {
d.t.Helper()
return ansi.Strip(d.App.View().Content)
}