okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfix macos terminal.app rendering4e62e9e

main
5.4 KiB193 linesraw
1//go:build unix
2
3// Real-daemon TUI fixture: drives an AppModel directly through Bubble Tea
4// update steps against the suite's shared daemon.
5package e2e
6
7import (
8	"context"
9	"reflect"
10	"slices"
11	"strings"
12	"sync"
13	"testing"
14
15	"albedo/cli/internal/daemon"
16	"albedo/cli/internal/tui"
17
18	tea "charm.land/bubbletea/v2"
19	"github.com/charmbracelet/x/ansi"
20)
21
22// tuiDriver drives a full tui.AppModel against the suite's real daemon,
23// stepping Bubble Tea commands synchronously in place of the event loop.
24type tuiDriver struct {
25	t   *testing.T
26	App *tui.AppModel
27}
28
29// newTUIDriver opens a fresh daemon session in a temp workspace.
30func newTUIDriver(t *testing.T) *tuiDriver {
31	t.Helper()
32	session := daemonSession(t, newSession(t, t.TempDir()))
33	return driveTUI(t, &session)
34}
35
36// driveTUI boots an AppModel on session, or on the session picker when nil.
37func driveTUI(t *testing.T, session *daemon.Session) *tuiDriver {
38	t.Helper()
39	return driveTUIWithConnection(t, session, conn(t))
40}
41
42func driveTUIWithConnection(t *testing.T, session *daemon.Session, connection *daemon.Connection) *tuiDriver {
43	t.Helper()
44	workspace := t.TempDir()
45	if session != nil {
46		workspace = session.Workspace
47	}
48	settings, err := daemon.GetSettings(t.Context(), connection)
49	if err != nil {
50		t.Fatal(err)
51	}
52	sessions, err := daemon.ListSessions(t.Context(), connection)
53	if err != nil {
54		t.Fatal(err)
55	}
56	d := &tuiDriver{t: t, App: tui.NewAppModel(connection, tui.Bootstrap{Sessions: sessions, Settings: settings}, session, workspace, false, nil)}
57	d.Update(tea.WindowSizeMsg{Width: 80, Height: 22})
58	return d
59}
60
61func daemonSessions(t *testing.T) []daemon.Session {
62	t.Helper()
63	sessions, err := daemon.ListSessions(context.Background(), conn(t))
64	if err != nil {
65		t.Fatalf("list sessions: %v", err)
66	}
67	return sessions
68}
69
70func daemonSession(t *testing.T, id string) daemon.Session {
71	t.Helper()
72	session, err := daemon.GetSession(t.Context(), conn(t), id)
73	if err != nil {
74		t.Fatalf("read session %s: %v", id, err)
75	}
76	return session
77}
78
79// Update feeds one message to the model and returns its command.
80func (d *tuiDriver) Update(msg tea.Msg) tea.Cmd {
81	d.t.Helper()
82	updated, cmd := d.App.Update(msg)
83	d.App = updated.(*tui.AppModel)
84	return cmd
85}
86
87// Send feeds msg to the model and returns what its command produced, or nil
88// when the update returned no command.
89func (d *tuiDriver) Send(msg tea.Msg) tea.Msg {
90	d.t.Helper()
91	results := d.results(d.Update(msg))
92	if len(results) > 1 {
93		d.t.Fatalf("expected one result, got %#v", results)
94	}
95	if len(results) == 0 {
96		return nil
97	}
98	return results[0]
99}
100
101// Key presses code and returns what its command produced.
102func (d *tuiDriver) Key(code rune) tea.Msg {
103	d.t.Helper()
104	return d.Send(tea.KeyPressMsg{Code: code})
105}
106
107// Dispatch feeds msg and every result it leads to back into the model until
108// it settles.
109func (d *tuiDriver) Dispatch(msg tea.Msg) {
110	d.t.Helper()
111	d.settle(d.results(d.Update(msg)))
112}
113
114// Type presses each rune of s as fast as a person types and then settles once:
115// the event loop runs the keys' commands side by side, so a debounce waits once
116// for the whole text instead of once per key.
117func (d *tuiDriver) Type(s string) {
118	d.t.Helper()
119	var cmds []tea.Cmd
120	for _, r := range s {
121		cmds = append(cmds, d.Update(tea.KeyPressMsg{Code: r, Text: string(r)}))
122	}
123	d.settle(d.results(tea.Batch(cmds...)))
124}
125
126// settle feeds queued messages, and the results they lead to, until none are
127// left; cursor blinks are dropped because nothing consumes them.
128func (d *tuiDriver) settle(queue []tea.Msg) {
129	d.t.Helper()
130	for step := 0; len(queue) > 0; step++ {
131		if step == 64 {
132			d.t.Fatal("the model never settled")
133		}
134		var msg tea.Msg
135		msg, queue = queue[0], queue[1:]
136		queue = append(queue, d.results(d.Update(msg))...)
137	}
138}
139
140// results runs cmd, and every command batched inside it side by side as the
141// event loop does, and returns what they produced in batch order.
142func (d *tuiDriver) results(cmd tea.Cmd) []tea.Msg {
143	if cmd == nil {
144		return nil
145	}
146	switch msg := cmd().(type) {
147	case nil:
148		return nil
149	case tea.BatchMsg:
150		produced := make([][]tea.Msg, len(msg))
151		var wg sync.WaitGroup
152		for i, c := range msg {
153			wg.Go(func() { produced[i] = d.results(c) })
154		}
155		wg.Wait()
156		return slices.Concat(produced...)
157	default:
158		if _, recurring := msg.(tui.ChatStatusPollMsg); recurring {
159			return nil
160		}
161		if reflect.TypeOf(msg).PkgPath() == "charm.land/bubbles/v2/cursor" {
162			return nil
163		}
164		return []tea.Msg{msg}
165	}
166}
167
168// connected answers the chat's status poll once, as the event loop would
169// before the composer lets a message through.
170func (d *tuiDriver) connected() {
171	d.t.Helper()
172	chat := d.App.Chat
173	d.Update(d.Update(tui.ChatStatusPollMsg{SessionID: chat.SessionID, Generation: chat.Generation})())
174	if view := d.View(); strings.Contains(view, "connecting…") {
175		d.t.Fatalf("the session still reads as connecting after its status answered:\n%s", view)
176	}
177}
178
179// pasteImage delivers image as a clipboard paste at the composer's cursor.
180func (d *tuiDriver) pasteImage(image *daemon.ImageAttachment) {
181	chat := d.App.Chat
182	d.Update(tui.ClipboardImagePastedMsg{SessionID: chat.SessionID, Generation: chat.Generation, Image: image})
183}
184
185// View returns the rendered terminal contents without styling.
186func (d *tuiDriver) View() string {
187	d.t.Helper()
188	content := d.App.View().Content
189	if strings.Contains(content, "\x1b_albedo:") {
190		d.t.Fatalf("private row marks reached the terminal: %q", content)
191	}
192	return ansi.Strip(content)
193}