okami.mom/albedo

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

nekomimi.pettest(e2e): run session-scoped Go scenarios in parallel5b3f8be

main
12.6 KiB354 linesraw
1//go:build unix
2
3// Stream faults are injected ahead of the shared real daemon; the TUI must
4// recover its durable transcript once, then stop visibly if the wire stays bad.
5package e2e
6
7import (
8	"bufio"
9	"context"
10	"encoding/json"
11	"errors"
12	"fmt"
13	"io"
14	"net"
15	"net/http"
16	"net/http/httptest"
17	"net/http/httputil"
18	"net/url"
19	"slices"
20	"strings"
21	"sync"
22	"testing"
23	"time"
24
25	"albedo/cli/internal/daemon"
26	"albedo/cli/internal/tui"
27	tea "charm.land/bubbletea/v2"
28)
29
30type streamFaultProxy struct {
31	connection      *daemon.Connection
32	mu              sync.Mutex
33	cursors         []string
34	generations     []string
35	firstCursor     int64
36	firstGeneration string
37	reconnected     chan struct{}
38}
39
40func faultSessionStream(t *testing.T, session string, fault string) *streamFaultProxy {
41	t.Helper()
42	destination, err := url.Parse(conn(t).BaseURL())
43	if err != nil {
44		t.Fatal(err)
45	}
46	forward := httputil.NewSingleHostReverseProxy(destination)
47	direct := forward.Director
48	forward.Director = func(r *http.Request) { direct(r); r.Host = destination.Host }
49	forward.FlushInterval = -1
50	proxy := &streamFaultProxy{reconnected: make(chan struct{}, 1)}
51	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52		if r.URL.Path == "/sessions/"+session && r.Header.Get("Accept") == "text/event-stream" {
53			proxy.mu.Lock()
54			proxy.cursors = append(proxy.cursors, r.URL.Query().Get("after_seq"))
55			proxy.generations = append(proxy.generations, r.URL.Query().Get("after_generation"))
56			count := len(proxy.cursors)
57			proxy.mu.Unlock()
58			if fault == "eof" || fault == "after-history" {
59				if count == 1 {
60					outgoing := r.Clone(r.Context())
61					outgoing.URL.Scheme, outgoing.URL.Host = destination.Scheme, destination.Host
62					outgoing.Host = destination.Host
63					outgoing.RequestURI = ""
64					response, err := conn(t).HTTPClient().Do(outgoing)
65					if err != nil {
66						http.Error(w, err.Error(), http.StatusBadGateway)
67						return
68					}
69					defer response.Body.Close()
70					scanner := bufio.NewScanner(response.Body)
71					scanner.Buffer(make([]byte, 4096), 10*1024*1024)
72					w.Header().Set("Content-Type", "text/event-stream")
73					for scanner.Scan() {
74						line := scanner.Text()
75						_, _ = fmt.Fprintln(w, line)
76						if strings.HasPrefix(line, "data: ") {
77							var envelope struct {
78								Cursor     int64  `json:"cursor"`
79								Generation string `json:"generation"`
80							}
81							if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &envelope); err != nil {
82								t.Error(err)
83							}
84							proxy.mu.Lock()
85							proxy.firstCursor = envelope.Cursor
86							proxy.firstGeneration = envelope.Generation
87							proxy.mu.Unlock()
88						}
89						if line == "" {
90							return
91						}
92					}
93					if err := scanner.Err(); err != nil {
94						t.Errorf("reading injected EOF frame: %v", err)
95					} else {
96						t.Error("daemon stream ended before a complete SSE frame")
97					}
98					return
99				}
100				if fault == "after-history" && count == 2 {
101					w.Header().Set("Content-Type", "text/event-stream")
102					_, _ = io.WriteString(w, "data: "+`{"generation":"invalid-replacement","cursor":10,"events":[{"type":"text","sequence":10,"data":{"run_id":"run-a","message_id":"message-a","text":"poison must stay hidden"}}]}`+"\n\n")
103					return
104				}
105				select {
106				case proxy.reconnected <- struct{}{}:
107				default:
108				}
109			} else if count == 1 || fault == "repeated" {
110				w.Header().Set("Content-Type", "text/event-stream")
111				_, _ = io.WriteString(w, "data: "+`{"generation":"fault","cursor":10,"events":[{"type":"text","sequence":9,"data":{"run_id":"run-a","message_id":"message-a","text":"poison must stay hidden"}},{"type":"thinking","sequence":10,"data":{"run_id":"run-a","message_id":"message-a","text":false}}]}`+"\n\n")
112				return
113			}
114		}
115		forward.ServeHTTP(w, r)
116	}))
117	t.Cleanup(server.Close)
118	snapshot := conn(t).Snapshot()
119	snapshot.Port = server.Listener.Addr().(*net.TCPAddr).Port
120	proxy.connection, err = daemon.Attach(t.Context(), snapshot, nil)
121	if err != nil {
122		t.Fatal(err)
123	}
124	t.Cleanup(proxy.connection.HTTPClient().CloseIdleConnections)
125	return proxy
126}
127
128func (proxy *streamFaultProxy) requests() []string {
129	proxy.mu.Lock()
130	defer proxy.mu.Unlock()
131	return slices.Clone(proxy.cursors)
132}
133
134// Commands run independently, like Bubble Tea: one pending stream read must
135// not prevent the status result or a terminal notice from reaching the model.
136func streamMessages(ctx context.Context, cmd tea.Cmd, inbox chan<- tea.Msg) {
137	if cmd == nil {
138		return
139	}
140	go func() {
141		msg := cmd()
142		if batch, ok := msg.(tea.BatchMsg); ok {
143			for _, next := range batch {
144				streamMessages(ctx, next, inbox)
145			}
146			return
147		}
148		if msg != nil {
149			select {
150			case inbox <- msg:
151			case <-ctx.Done():
152			}
153		}
154	}()
155}
156
157func pumpStreamUntil(t *testing.T, driver *tuiDriver, condition func(tea.Msg) bool) tea.Msg {
158	t.Helper()
159	ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
160	defer cancel()
161	inbox := make(chan tea.Msg, 32)
162	streamMessages(ctx, driver.App.Chat.Init(), inbox)
163	for {
164		select {
165		case msg := <-inbox:
166			cmd := driver.Update(msg)
167			if condition(msg) {
168				return msg
169			}
170			streamMessages(ctx, cmd, inbox)
171		case <-ctx.Done():
172			t.Fatalf("stream did not reach expected state:\n%s", driver.View())
173		}
174	}
175}
176
177func TestTUIStreamProtocolFailureRecoversDurableHistoryOnce(t *testing.T) {
178	profile := providerRoute(t, echoReply)
179	t.Parallel()
180	session := daemonSession(t, newSession(t, t.TempDir()))
181	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(t.Context(), "durable stream recovery", nil); err != nil {
182		t.Fatal(err)
183	}
184	waitIdle(t, session.ID, profile, 1)
185	proxy := faultSessionStream(t, session.ID, "once")
186	driver := driveTUIWithConnection(t, &session, proxy.connection)
187	t.Cleanup(driver.App.Chat.Close)
188	driver.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
189	recoverySeen := false
190	pumpStreamUntil(t, driver, func(msg tea.Msg) bool {
191		if result, ok := msg.(tui.ChatStreamResultMsg); ok && result.Recovering {
192			recoverySeen = true
193		}
194		event, ok := msg.(tui.ChatStreamEventMsg)
195		return ok && event.Event.Type == daemon.EventMessage && strings.Contains(event.Event.Text, "durable stream recovery")
196	})
197	view := driver.View()
198	if !recoverySeen || strings.Contains(view, "poison must stay hidden") || strings.Count(view, "durable stream recovery") != 2 {
199		t.Fatalf("reset lost or duplicated durable transcript:\n%s", view)
200	}
201	requests := proxy.requests()
202	if len(requests) != 2 || requests[1] != "" {
203		t.Fatalf("protocol recovery did not request exactly one durable reset: %v", requests)
204	}
205}
206
207func TestTUIProtocolFailureStopsUntilTheSessionIsReopened(t *testing.T) {
208	providerRoute(t, echoReply)
209	t.Parallel()
210	session := daemonSession(t, newSession(t, t.TempDir()))
211	proxy := faultSessionStream(t, session.ID, "repeated")
212	driver := driveTUIWithConnection(t, &session, proxy.connection)
213	t.Cleanup(func() { driver.App.Chat.Close() })
214	terminal := pumpStreamUntil(t, driver, func(msg tea.Msg) bool {
215		result, ok := msg.(tui.ChatStreamResultMsg)
216		return ok && !result.Recovering
217	})
218	failure, ok := errors.AsType[*daemon.StreamError](terminal.(tui.ChatStreamResultMsg).Err)
219	if !ok || failure.Kind != daemon.StreamProtocol || !driver.App.Chat.Notices.HasError() || strings.Contains(driver.View(), "poison must stay hidden") {
220		t.Fatalf("protocol stop not visible or malformed text escaped:\n%s", driver.View())
221	}
222	// Wait beyond both initial retry delays: a stopped attachment must stay stopped.
223	time.Sleep(1600 * time.Millisecond)
224	if requests := proxy.requests(); len(requests) != 2 {
225		t.Fatalf("poison stream retried forever: %v", requests)
226	}
227	if cmd := driver.Update(tui.ChatStatusPollMsg{SessionID: session.ID, Generation: driver.App.Chat.Generation}); cmd != nil {
228		t.Fatal("terminal attachment resumed status polling")
229	}
230	driver.Update(tui.FolderOpenSessionMsg{Session: session})
231	before := driver.View()
232	if cmd := driver.Update(terminal); cmd != nil || driver.View() != before {
233		t.Fatal("stale terminal failure affected the reopened session")
234	}
235}
236
237func TestTUIDeletedSessionStopsItsStreamAndStatusPolling(t *testing.T) {
238	providerRoute(t, echoReply)
239	t.Parallel()
240	session := daemonSession(t, newSession(t, t.TempDir()))
241	if _, err := daemon.DeleteSession(t.Context(), conn(t), session.ID, false, sessionCondition(t, conn(t), session.ID)); err != nil {
242		t.Fatal(err)
243	}
244	driver := driveTUI(t, &session)
245	t.Cleanup(driver.App.Chat.Close)
246	msg := pumpStreamUntil(t, driver, func(msg tea.Msg) bool {
247		result, ok := msg.(tui.ChatStreamResultMsg)
248		return ok && !result.Recovering
249	})
250	result := msg.(tui.ChatStreamResultMsg)
251	failure, ok := errors.AsType[*daemon.StreamError](result.Err)
252	api, apiOK := errors.AsType[*daemon.APIError](result.Err)
253	if !ok || failure.Kind != daemon.StreamTerminal || !apiOK || api.StatusCode != http.StatusGone || api.Code != "session_deleted" {
254		t.Fatalf("deleted session did not terminate: %v", result.Err)
255	}
256	if cmd := driver.Update(tui.ChatStatusPollMsg{SessionID: session.ID, Generation: driver.App.Chat.Generation}); cmd != nil {
257		t.Fatal("deleted session kept polling")
258	}
259	if !driver.App.Chat.Notices.HasError() || !strings.Contains(driver.View(), api.Error()) {
260		t.Fatalf("deleted session left no visible failure reason:\n%s", driver.View())
261	}
262}
263
264func TestTUIStreamEOFReconnectsFromConsumedCursor(t *testing.T) {
265	profile := providerRoute(t, echoReply)
266	t.Parallel()
267	session := daemonSession(t, newSession(t, t.TempDir()))
268	client := daemon.NewChatClient(conn(t), session.ID)
269	if _, err := client.Send(t.Context(), "before EOF", nil); err != nil {
270		t.Fatal(err)
271	}
272	waitIdle(t, session.ID, profile, 1)
273	proxy := faultSessionStream(t, session.ID, "eof")
274	driver := driveTUIWithConnection(t, &session, proxy.connection)
275	t.Cleanup(driver.App.Chat.Close)
276	driver.Update(tea.WindowSizeMsg{Width: 100, Height: 60})
277	sent := make(chan error, 1)
278	go func() {
279		select {
280		case <-proxy.reconnected:
281			_, err := client.Send(t.Context(), "after EOF", nil)
282			sent <- err
283		case <-t.Context().Done():
284			sent <- t.Context().Err()
285		}
286	}()
287	pumpStreamUntil(t, driver, func(msg tea.Msg) bool {
288		if result, ok := msg.(tui.ChatStreamResultMsg); ok {
289			t.Errorf("ordinary EOF reported a terminal or protocol failure: %v", result.Err)
290		}
291		event, ok := msg.(tui.ChatStreamEventMsg)
292		return ok && event.Event.Type == daemon.EventMessage && strings.Contains(event.Event.Text, "after EOF")
293	})
294	if err := <-sent; err != nil {
295		t.Fatal(err)
296	}
297	proxy.mu.Lock()
298	requests, first := slices.Clone(proxy.cursors), proxy.firstCursor
299	generations, firstGeneration := slices.Clone(proxy.generations), proxy.firstGeneration
300	proxy.mu.Unlock()
301	if len(requests) != 2 || requests[1] != fmt.Sprint(first) || generations[1] != firstGeneration || firstGeneration == "" {
302		t.Fatalf("EOF lost consumed cursor: requests=%v, cursor=%d", requests, first)
303	}
304	view := driver.View()
305	if strings.Count(view, "before EOF") != 2 || strings.Count(view, "after EOF") != 2 {
306		t.Fatalf("EOF replay lost or duplicated rows:\n%s", view)
307	}
308}
309
310func TestTUIResetReplacesHistoryAndPreservesOlderNavigation(t *testing.T) {
311	profile := providerRoute(t, echoReply)
312	t.Parallel()
313	session := daemonSession(t, newSession(t, t.TempDir()))
314	client := daemon.NewChatClient(conn(t), session.ID)
315	const turns = 62
316	for index := range turns {
317		if _, err := client.Send(t.Context(), fmt.Sprintf("history row %03d", index), nil); err != nil {
318			t.Fatal(err)
319		}
320		waitIdle(t, session.ID, profile, index+1)
321	}
322	proxy := faultSessionStream(t, session.ID, "after-history")
323	driver := driveTUIWithConnection(t, &session, proxy.connection)
324	t.Cleanup(driver.App.Chat.Close)
325	driver.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
326	recoverySeen := false
327	pumpStreamUntil(t, driver, func(msg tea.Msg) bool {
328		if result, ok := msg.(tui.ChatStreamResultMsg); ok && result.Recovering {
329			recoverySeen = true
330		}
331		event, ok := msg.(tui.ChatStreamEventMsg)
332		return recoverySeen && ok && event.Event.Type == daemon.EventMessage && strings.Contains(event.Event.Text, "history row 061")
333	})
334	if requests := proxy.requests(); len(requests) != 3 || requests[2] != "" {
335		t.Fatalf("generation failure did not recover with a fresh subscription: %v", requests)
336	}
337	seen := map[string]int{}
338	for _, entry := range driver.App.Chat.History.Entries() {
339		seen[entry.Text]++
340	}
341	if seen["history row 061"] != 1 || seen["history row 000"] != 0 || strings.Contains(driver.View(), "poison must stay hidden") {
342		t.Fatalf("reset duplicated history or lost the tail boundary: %v", seen)
343	}
344	for range 4 {
345		driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyHome, Mod: tea.ModCtrl})
346	}
347	seen = map[string]int{}
348	for _, entry := range driver.App.Chat.History.Entries() {
349		seen[entry.Text]++
350	}
351	if seen["history row 000"] != 1 || seen["history row 061"] != 1 {
352		t.Fatalf("older navigation after reset lost or duplicated rows: %v", seen)
353	}
354}