okami.mom/albedo

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

Aly Raffaufperf(tui): reuse prepared startup dataed54422

main
5.3 KiB152 linesraw
1// Count requests across preparation and TUI initialization, including notice
2// acknowledgements. Daemon E2E cannot inspect the detached UI's startup commands.
3package terminal
4
5import (
6	"context"
7	"encoding/json"
8	"io"
9	"net"
10	"net/http"
11	"net/http/httptest"
12	"strings"
13	"testing"
14
15	"albedo/cli/internal/app"
16	"albedo/cli/internal/daemon"
17	"albedo/cli/internal/testwire"
18	"albedo/cli/internal/tui"
19	tea "charm.land/bubbletea/v2"
20)
21
22func runInitialCommands(model tea.Model, command tea.Cmd) {
23	if command == nil {
24		return
25	}
26	msg := command()
27	if batch, ok := msg.(tea.BatchMsg); ok {
28		for _, child := range batch {
29			runInitialCommands(model, child)
30		}
31		return
32	}
33	// Follow-up commands include recurring UI timers and stream subscriptions.
34	_, _ = model.Update(msg)
35}
36
37func TestPreparedStartupReusesReadsAndRefreshesNoticeValidator(t *testing.T) {
38	for _, scenario := range []struct {
39		name                           string
40		selected, fresh, login, notice bool
41	}{
42		{name: "picker"},
43		{name: "acknowledged notice", notice: true},
44		{name: "resume", selected: true},
45		{name: "fresh", fresh: true},
46		{name: "login", login: true},
47	} {
48		t.Run(scenario.name, func(t *testing.T) {
49			settingsReads, sessionLists := 0, 0
50			acknowledged := false
51			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52				switch {
53				case r.URL.Path == "/server":
54					var resource map[string]any
55					_ = json.Unmarshal([]byte(testwire.Server), &resource)
56					if scenario.notice {
57						resource["notices"] = []any{map[string]any{"id": "migration", "kind": "migration", "message": "Updated settings"}}
58					}
59					_ = json.NewEncoder(w).Encode(resource)
60				case r.URL.Path == "/sessions":
61					sessionLists++
62					items := []any{}
63					if !scenario.login {
64						items = append(items, testwire.Session("existing", testwire.GenerationA, 0))
65					}
66					_ = json.NewEncoder(w).Encode(map[string]any{"items": items, "next": nil})
67				case strings.HasPrefix(r.URL.Path, "/sessions/"):
68					id := strings.TrimPrefix(r.URL.Path, "/sessions/")
69					if r.Method == http.MethodPut {
70						w.WriteHeader(http.StatusCreated)
71					}
72					_ = json.NewEncoder(w).Encode(testwire.Session(id, testwire.GenerationA, 0))
73				case r.URL.Path == "/settings":
74					settings := testwire.Settings()
75					if !scenario.login {
76						settings["providers"].(map[string]any)["default_profile"] = "work"
77					}
78					ui := settings["ui"].(map[string]any)
79					if r.Method == http.MethodPatch {
80						if r.Header.Get("If-Match") != `"ui-a"` {
81							t.Errorf("notice acknowledgement used stale validator: %s", r.Header.Get("If-Match"))
82						}
83						acknowledged = true
84						ui["dismissed_notices"] = []any{"migration"}
85						_ = json.NewEncoder(w).Encode(testwire.SettingsChange("ui", ui))
86						return
87					}
88					settingsReads++
89					if acknowledged {
90						ui["dismissed_notices"] = []any{"migration"}
91						settings["group_resources"].(map[string]any)["ui"].(map[string]any)["etag"] = `"ui-b"`
92					}
93					_ = json.NewEncoder(w).Encode(settings)
94				case r.URL.Path == "/auth":
95					_, _ = io.WriteString(w, `{"providers":[],"accounts":[]}`)
96				default:
97					t.Errorf("unexpected startup request: %s %s", r.Method, r.URL)
98					http.NotFound(w, r)
99				}
100			}))
101			t.Cleanup(server.Close)
102			conn, err := daemon.Attach(t.Context(), daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Token: "token"}, nil)
103			if err != nil {
104				t.Fatal(err)
105			}
106			t.Cleanup(conn.HTTPClient().CloseIdleConnections)
107			workflow := app.Service{Connect: func(_ context.Context) (*daemon.Connection, error) { return conn, nil }}
108			options := app.OpenOptions{Terminal: true, Fresh: scenario.fresh}
109			if scenario.selected {
110				options.SessionID = "existing"
111			}
112			prepared, err := workflow.PrepareOpen(t.Context(), options)
113			if err != nil {
114				t.Fatal(err)
115			}
116			service := Service{In: strings.NewReader("\n"), Out: io.Discard}
117			settings, err := service.announceMigration(t.Context(), conn, prepared.Settings)
118			if err != nil {
119				t.Fatal(err)
120			}
121			model := tui.NewAppModel(conn, tui.Bootstrap{Sessions: prepared.Sessions, Settings: settings}, prepared.Selected, prepared.Workspace, prepared.LoginRequired, nil)
122			t.Cleanup(func() { _ = model.Close() })
123			if scenario.selected || scenario.fresh {
124				if model.ActiveSession == nil || !model.Chat.Flags.Thinking || model.Chat.Flags.Tools {
125					t.Fatal("chat did not start from prepared preferences")
126				}
127				found := false
128				for _, session := range model.Sessions {
129					found = found || session.ID == model.ActiveSession.ID && session.ETag != ""
130				}
131				if !found {
132					t.Fatal("selected or created session missing from bootstrap")
133				}
134			} else {
135				runInitialCommands(model, model.Init())
136				if model.SessionPicker.Loading || len(model.Sessions) != len(prepared.Sessions) {
137					t.Fatal("prepared list was discarded, including its known empty state")
138				}
139			}
140			wantSettings := 1
141			if scenario.notice {
142				wantSettings++
143				if model.UI.ETag != `"ui-b"` || len(model.UI.DismissedNotices) != 1 {
144					t.Fatalf("TUI seeded before notice acknowledgement: %+v", model.UI)
145				}
146			}
147			if sessionLists != 1 || settingsReads != wantSettings {
148				t.Fatalf("startup read sessions %d times and settings %d times, want 1 and %d", sessionLists, settingsReads, wantSettings)
149			}
150		})
151	}
152}