okami.mom/albedo

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

Aly Raffauffix: restore host staging progress and correct configuration597cb84

main
19.1 KiB539 linesraw
1//go:build unix
2
3// /cd crosses the composer, the folder picker, the daemon's workspace routes and
4// its workspace move. A fake folder source cannot show the daemon listing a
5// real directory, resolving a relative path, or keeping the new workspace.
6package e2e
7
8import (
9	"context"
10	"errors"
11	"fmt"
12	"os"
13	"path/filepath"
14	"strings"
15	"testing"
16	"time"
17
18	"albedo/cli/internal/daemon"
19	"albedo/cli/internal/tui"
20
21	tea "charm.land/bubbletea/v2"
22)
23
24func TestTUICdMovesTheSessionThroughThePicker(t *testing.T) {
25	providerRoute(t, echoReply)
26	t.Parallel()
27	d := newTUIDriver(t)
28	root := t.TempDir()
29	for _, dir := range []string{"picked/src", "other", ".hidden"} {
30		if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
31			t.Fatal(err)
32		}
33	}
34	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
35	same := func(got, want string) bool {
36		g, _ := filepath.EvalSymlinks(got)
37		w, _ := filepath.EvalSymlinks(want)
38		return g == w
39	}
40
41	d.App.Chat.TextArea.SetValue("/cd")
42	d.Dispatch(d.Key(tea.KeyEnter))
43	if d.App.State != tui.AppStateFolderPicker {
44		t.Fatalf("/cd did not open the picker:\n%s", d.View())
45	}
46	// The daemon lists the typed folder; the last segment filters it and
47	// dot folders stay hidden.
48	d.Type(root + "/pi")
49	if view := d.View(); !strings.Contains(view, "picked") || strings.Contains(view, "other") || strings.Contains(view, ".hidden") {
50		t.Fatalf("the listing was not filtered to picked:\n%s", view)
51	}
52	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
53	if d.App.State != tui.AppStateChat {
54		t.Fatalf("enter did not return to the chat:\n%s", d.View())
55	}
56	id := d.App.ActiveSession.ID
57	if got := daemonSession(t, id).Workspace; !same(got, filepath.Join(root, "picked")) {
58		t.Fatalf("the daemon kept workspace %q", got)
59	}
60
61	// /cd with a path moves at once, relative to where the session is.
62	d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/cd", Args: "../other"})
63	if got := daemonSession(t, id).Workspace; !same(got, filepath.Join(root, "other")) || !same(d.App.Chat.Workspace, got) {
64		t.Fatalf("/cd ../other left the daemon at %q and the chat at %q", got, d.App.Chat.Workspace)
65	}
66	// The chat offers linking the folder it left, as a command to paste.
67	view := d.View()
68	_, offered, ok := strings.Cut(view, "/link add ")
69	if !ok {
70		t.Fatalf("moving did not offer /link add:\n%s", view)
71	}
72	offered = strings.Fields(offered)[0]
73	if !same(offered, filepath.Join(root, "picked")) {
74		t.Fatalf("the hint offers %q, not the folder the session left", offered)
75	}
76	d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/link", Args: "add " + offered})
77	if d.App.State != tui.AppStatePageView || d.App.PageView.Doc == nil || d.App.PageView.Doc.Title != "Link workspace groups" || len(d.App.PageView.Doc.Rows) != 2 || !strings.Contains(d.View(), "complete groups will share memory and work") {
78		t.Fatalf("pasting the hint did not prepare the observed groups:\n%s", d.View())
79	}
80	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
81	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
82	if d.App.PageView.Error != "" {
83		t.Fatal(d.App.PageView.Error)
84	}
85	group, err := daemon.LoadPage(t.Context(), conn(t), d.App.ActiveSession.ID, "/links")
86	if err != nil {
87		t.Fatal(err)
88	}
89	linked := false
90	for _, member := range group.Rows {
91		linked = linked || member.Text == offered
92	}
93	if !linked {
94		t.Fatalf("confirmed merge omitted old workspace: %+v", group.Rows)
95	}
96}
97
98func TestTUIMissingWorkspaceSendsTheTurnFromThePickedFolder(t *testing.T) {
99	profile := providerRoute(t, echoReply)
100	t.Parallel()
101	root := t.TempDir()
102	gone, found := filepath.Join(root, "gone"), filepath.Join(root, "found")
103	for _, dir := range []string{gone, found} {
104		if err := os.Mkdir(dir, 0o755); err != nil {
105			t.Fatal(err)
106		}
107	}
108	session := daemonSession(t, newSession(t, gone))
109	d := driveTUI(t, &session)
110	defer d.App.Chat.Close()
111	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
112	d.App.Chat.TextArea.SetValue("keep this draft")
113	d.Dispatch(tui.FolderNewSessionMsg{Workspace: filepath.Join(root, "never-created")})
114	if d.App.State != tui.AppStateChat || d.App.ActiveSession == nil || d.App.ActiveSession.ID != session.ID || d.App.Chat.Workspace != session.Workspace || d.App.Chat.TextArea.Value() != "keep this draft" {
115		t.Fatalf("a rejected creation changed the unrelated active session:\n%s", d.View())
116	}
117	if err := os.Remove(gone); err != nil {
118		t.Fatal(err)
119	}
120
121	d.connected()
122	d.App.Chat.TextArea.SetValue("hello from nowhere")
123	messages := d.results(d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}))
124	var admitted tui.ChatTurnSentMsg
125	for _, message := range messages {
126		if sent, ok := message.(tui.ChatTurnSentMsg); ok {
127			admitted = sent
128			d.Update(sent)
129		}
130	}
131	if admitted.Err != nil || admitted.Handle == nil || !admitted.OK {
132		t.Fatalf("durable admission: %+v", admitted)
133	}
134	var receipt daemon.InputReceipt
135	deadline := time.Now().Add(5 * time.Second)
136	for {
137		var err error
138		receipt, err = daemon.ResolveOperation(t.Context(), conn(t), admitted.Handle)
139		if err != nil {
140			t.Fatal(err)
141		}
142		if receipt.BlockingDetail() != "" {
143			break
144		}
145		if time.Now().After(deadline) {
146			t.Fatal("missing workspace did not retain blocked pending input")
147		}
148		time.Sleep(25 * time.Millisecond)
149	}
150	if !receipt.Pending() {
151		t.Fatalf("blocked input was lost: %+v", receipt)
152	}
153	d.Update(tui.ChatOperationResolvedMsg{SessionID: session.ID, Generation: d.App.Chat.Generation, Handle: admitted.Handle, Receipt: receipt})
154	if d.App.State != tui.AppStateChat || d.App.Chat.TextArea.Value() != "" || !strings.Contains(d.View(), "hello from nowhere") {
155		t.Fatalf("blocked admission state=%v composer=%q view=%q", d.App.State, d.App.Chat.TextArea.Value(), d.View())
156	}
157
158	d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/cd"})
159	d.Type(root + "/fou")
160	moved, ok := d.Key(tea.KeyEnter).(tui.FolderMovedMsg)
161	if !ok || moved.Err != nil {
162		for cause := moved.Err; cause != nil; cause = errors.Unwrap(cause) {
163			t.Logf("workspace move cause: %T %v", cause, cause)
164		}
165		t.Fatalf("enter did not move the session: %v (message %T)", moved.Err, moved)
166	}
167	d.Update(moved)
168	waitIdle(t, session.ID, profile, 1)
169	history, err := daemon.NewChatClient(conn(t), session.ID).History(t.Context(), 0, 120)
170	if err != nil {
171		t.Fatal(err)
172	}
173	users := 0
174	for _, event := range history.Events {
175		if event.Type == daemon.EventUser {
176			users++
177			if event.OperationID != admitted.OperationID {
178				t.Fatal("workspace repair created another user intent")
179			}
180		}
181	}
182	if users != 1 {
183		t.Fatalf("workspace repair delivered %d user rows", users)
184	}
185	if got, _ := filepath.EvalSymlinks(daemonSession(t, session.ID).Workspace); got != mustEval(t, found) {
186		t.Fatalf("the daemon kept workspace %q", got)
187	}
188}
189
190func TestTUISessionsByFolderOpenAndStartSessions(t *testing.T) {
191	providerRoute(t, echoReply)
192	// Default-changing tests finish before parallel bodies. These creations
193	// send no turns and do not assert which provider the daemon selects.
194	t.Parallel()
195	cache, err := os.UserCacheDir()
196	if err != nil {
197		t.Fatal(err)
198	}
199	if err = os.MkdirAll(cache, 0o700); err != nil {
200		t.Fatal(err)
201	}
202	// The system temporary directory may be inside a repository. This
203	// scenario needs a plain directory whose complete tree is visible.
204	root, err := os.MkdirTemp(cache, "albedo-go-plain-workspace-")
205	if err != nil {
206		t.Fatal(err)
207	}
208	t.Cleanup(func() {
209		if err := os.RemoveAll(root); err != nil {
210			t.Errorf("remove the plain workspace fixture: %v", err)
211		}
212	})
213	busy, empty, crowded := filepath.Join(root, "busy"), filepath.Join(root, "empty"), filepath.Join(root, "crowded")
214	for _, dir := range []string{busy, empty, crowded} {
215		if err := os.Mkdir(dir, 0o755); err != nil {
216			t.Fatal(err)
217		}
218	}
219	// A deep tree, taller than the pane, must not push the sessions off.
220	for i := range 12 {
221		for j := range 5 {
222			if err := os.MkdirAll(filepath.Join(busy, fmt.Sprintf("d%02d/c%d", i, j)), 0o755); err != nil {
223				t.Fatal(err)
224			}
225		}
226	}
227	there := newSession(t, busy)
228	// In the sessions view the leading slash opens the folder browser, which
229	// must be on screen before the rest of the path is typed into it.
230	typePath := func(d *tuiDriver, path string) {
231		d.Dispatch(tea.KeyPressMsg{Code: '/', Text: "/"})
232		d.Type(strings.TrimPrefix(path, "/"))
233	}
234
235	// ^f in the sessions view browses folders; → steps into the highlighted
236	// folder's sessions and enter opens one.
237	d := driveTUI(t, nil)
238	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
239	d.Dispatch(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
240	if d.App.State != tui.AppStateFolderPicker {
241		t.Fatalf("^f did not open the folder browser:\n%s", d.View())
242	}
243	d.Type(root + "/bu")
244	if view := d.View(); !strings.Contains(view, "d00/") || !strings.Contains(view, "Untitled session") {
245		preview, previewErr := daemon.PreviewFolder(t.Context(), conn(t), busy)
246		t.Logf("daemon preview: tree=%d more=%d repository=%+v error=%v", len(preview.Tree), preview.More, preview.Repo, previewErr)
247		listing, listErr := daemon.ListFolders(t.Context(), conn(t), busy)
248		t.Logf("daemon directory: entries=%d error=%v", len(listing.Entries), listErr)
249		t.Fatalf("the preview lost its tree or its sessions:\n%s", view)
250	}
251	d.Dispatch(tea.KeyPressMsg{Code: tea.KeyRight})
252	opened, ok := d.Key(tea.KeyEnter).(tui.FolderOpenSessionMsg)
253	if !ok || opened.Session.ID != there {
254		t.Fatalf("enter in the folder's sessions did not open %s: %#v\n%s", there, opened, d.View())
255	}
256	d.Update(opened)
257	if d.App.State != tui.AppStateChat || d.App.ActiveSession.ID != there {
258		t.Fatalf("the picked session is not on screen:\n%s", d.View())
259	}
260
261	// Under a short tree the sessions fill the pane instead of a third of it.
262	for range 12 {
263		newSession(t, crowded)
264	}
265	d = driveTUI(t, nil)
266	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
267	typePath(d, root+"/cr")
268	if view := d.View(); strings.Count(view, "Untitled session") != 12 {
269		t.Fatalf("the preview cut the sessions short:\n%s", view)
270	}
271
272	// A path typed into an empty search browses too, and enter on a folder
273	// starts a session there.
274	d = driveTUI(t, nil)
275	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
276	typePath(d, root+"/em")
277	if d.App.State != tui.AppStateFolderPicker {
278		t.Fatalf("typing a path did not open the folder browser:\n%s", d.View())
279	}
280	started, ok := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg)
281	if !ok {
282		t.Fatalf("enter on a folder did not start a session: %#v", started)
283	}
284	d.Update(d.Send(started))
285	if got := d.App.ActiveSession; d.App.State != tui.AppStateChat || got == nil || mustEval(t, got.Workspace) != mustEval(t, empty) {
286		t.Fatalf("the new session is not in %s:\n%s", empty, d.View())
287	}
288}
289
290func mustEval(t *testing.T, p string) string {
291	t.Helper()
292	resolved, err := filepath.EvalSymlinks(p)
293	if err != nil {
294		t.Fatal(err)
295	}
296	return resolved
297}
298
299// A host is picked like a folder: scp syntax lists its home through the
300// daemon's ssh, the rows fold under that host's ~, enter starts a session
301// whose kernel boots there, and a host ssh cannot reach is never picked.
302//
303// The TUI starts the session on the active profile, so this runs alone.
304func TestTUIStartsASessionOnAHostPickedLikeAFolder(t *testing.T) {
305	profile := providerRoute(t, echoReply)
306	picked := filepath.Join(suite.remoteHome, "proj", fmt.Sprintf("picked-%d", os.Getpid()))
307	if err := os.MkdirAll(picked, 0o755); err != nil {
308		t.Fatal(err)
309	}
310	d := driveTUI(t, nil)
311	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
312	d.Dispatch(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
313	d.Type("gohost:")
314	if view := d.View(); !strings.Contains(view, "in gohost:~") || !strings.Contains(view, "proj") {
315		t.Fatalf("gohost: did not list the host's home:\n%s", view)
316	}
317	d.Type("proj/" + filepath.Base(picked)[:6])
318	if view := d.View(); !strings.Contains(view, "in gohost:proj") || !strings.Contains(view, filepath.Base(picked)) {
319		t.Fatalf("the host's folder did not list:\n%s", view)
320	}
321	started, ok := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg)
322	if !ok || started.Workspace != "gohost:"+picked {
323		t.Fatalf("enter did not start a session in gohost:%s: %#v", picked, started)
324	}
325	d.Update(d.Send(started))
326	session := d.App.ActiveSession
327	if d.App.State != tui.AppStateChat || session == nil || session.Workspace != "gohost:"+picked {
328		t.Fatalf("the new session is not on gohost:\n%s", d.View())
329	}
330	// the daemon's parsed location reaches the client, so the header shows the host
331	if l := session.Location; l == nil || l.Host == nil || *l.Host != "gohost" || l.Path != picked {
332		t.Fatalf("the session's location did not decode: %+v", l)
333	}
334
335	// its kernel boots on the host, and the status says it is attached
336	if _, err := daemon.NewChatClient(conn(t), session.ID).Send(context.Background(), "hello there", nil); err != nil {
337		t.Fatal(err)
338	}
339	waitIdle(t, session.ID, profile, 1)
340	status, err := daemon.NewChatClient(conn(t), session.ID).GetStatus(context.Background())
341	if err != nil || status.KernelLink != "attached" {
342		t.Fatalf("after a turn the kernel status is %+v (%v)", status, err)
343	}
344
345	// a host ssh cannot reach says why and is never picked
346	d = driveTUI(t, nil)
347	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
348	d.Dispatch(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
349	d.Type("nohost:")
350	if view := d.View(); !strings.Contains(view, "connect to host nohost") {
351		t.Fatalf("an unreachable host does not say why:\n%s", view)
352	}
353	if msg, started := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg); started {
354		t.Fatalf("enter started a session on an unreachable host: %#v", msg)
355	}
356}
357
358// A turn at a host that needs a person to sign in is kept, waiting on the
359// daemon's next try, and the chat offers to open the daemon's ssh master
360// here so ssh can ask.
361func TestTUIOffersSignInForATurnWaitingOnItsHost(t *testing.T) {
362	providerRoute(t, echoReply)
363	t.Parallel()
364	session := daemonSession(t, newSession(t, "lockedhost:/srv/app"))
365	d := driveTUI(t, &session)
366	d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
367	d.connected()
368	d.App.Chat.TextArea.SetValue("hello behind a lock")
369	var admitted tui.ChatTurnSentMsg
370	for _, message := range d.results(d.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
371		if sent, ok := message.(tui.ChatTurnSentMsg); ok {
372			admitted = sent
373			d.Update(sent)
374		}
375	}
376	if admitted.Err != nil || admitted.Handle == nil {
377		t.Fatalf("the turn was not admitted: %+v", admitted)
378	}
379	var receipt daemon.InputReceipt
380	for deadline := time.Now().Add(10 * time.Second); receipt.BlockingDetail() == ""; time.Sleep(25 * time.Millisecond) {
381		var err error
382		if receipt, err = daemon.ResolveOperation(t.Context(), conn(t), admitted.Handle); err != nil {
383			t.Fatal(err)
384		}
385		if time.Now().After(deadline) {
386			t.Fatalf("the turn never waited on its host: %+v", receipt)
387		}
388	}
389	// The receipt's own poll answers only at the daemon's next try, so each
390	// answer is taken as it comes rather than waiting for all of them.
391	resolved := tui.ChatOperationResolvedMsg{SessionID: session.ID, Generation: d.App.Chat.Generation, Handle: admitted.Handle, Receipt: receipt}
392	answers := make(chan tea.Msg, 4)
393	for _, cmd := range d.Update(resolved)().(tea.BatchMsg) {
394		go func() { answers <- cmd() }()
395	}
396	offer := "Press ctrl+l to sign in to lockedhost here"
397	for timeout := time.After(10 * time.Second); !strings.Contains(d.View(), offer); {
398		select {
399		case answer := <-answers:
400			d.Update(answer)
401		case <-timeout:
402			t.Fatalf("a turn waiting on a sign-in does not offer one:\n%s", d.View())
403		}
404	}
405	if view := d.View(); !strings.Contains(view, "Permission denied") {
406		t.Fatalf("the waiting turn does not say why:\n%s", view)
407	}
408}
409
410// A probe is readable before its target has SSH config or session history.
411func TestHostProbeObservesUnconfiguredTargets(t *testing.T) {
412	t.Parallel()
413	for _, target := range []string{"fresh-host", "ana@fresh-host"} {
414		t.Run(target, func(t *testing.T) {
415			known, err := daemon.ListHosts(t.Context(), conn(t))
416			if err != nil {
417				t.Fatal(err)
418			}
419			for _, host := range known {
420				if host.Host == target {
421					t.Fatalf("fixture target %s is already known", target)
422				}
423			}
424			status, err := daemon.GetHost(t.Context(), conn(t), target)
425			if err != nil || status.Host != target || status.State != "unknown" {
426				t.Fatalf("unconfigured target before probe: %+v, %v", status, err)
427			}
428			ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
429			defer cancel()
430			status, err = daemon.WarmHost(ctx, conn(t), target)
431			if err != nil || status.Host != target || status.State != "ready" || status.Home != suite.remoteHome {
432				t.Fatalf("unconfigured target after probe: %+v, %v", status, err)
433			}
434		})
435	}
436}
437
438// A real probe's progress must cross the Host API and the picker adapter.
439func TestHostStagingProgressReachesThePickerAndClearsWhenSettled(t *testing.T) {
440	providerRoute(t, echoReply)
441	session := daemonSession(t, newSession(t, t.TempDir()))
442	for _, target := range []string{"stage-ready", "stage-failure"} {
443		t.Run(target, func(t *testing.T) {
444			gate := filepath.Join(suite.remoteHome, target)
445			release := func(stage string) {
446				t.Helper()
447				if err := os.WriteFile(gate+".release-"+stage, nil, 0o600); err != nil {
448					t.Fatal(err)
449				}
450			}
451			t.Cleanup(func() { release("connect"); release("stage") })
452			ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
453			defer cancel()
454			finished := make(chan daemon.HostStatus, 1)
455			go func() {
456				status, err := daemon.WarmHost(ctx, conn(t), target)
457				if err != nil {
458					status.Detail = err.Error()
459				}
460				finished <- status
461			}()
462			observe := func(state, step string) daemon.HostStatus {
463				t.Helper()
464				for {
465					status, err := daemon.GetHost(ctx, conn(t), target)
466					if err != nil {
467						t.Fatal(err)
468					}
469					if status.State == state && status.Step == step {
470						return status
471					}
472					select {
473					case <-ctx.Done():
474						t.Fatalf("host never reached %s/%s: %+v", state, step, status)
475					case <-time.After(20 * time.Millisecond):
476					}
477				}
478			}
479			observe("probing", "")
480			release("connect")
481			observe("probing", "staging")
482
483			if target == "stage-ready" {
484				d := driveTUI(t, &session)
485				d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
486				d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/cd"})
487				defer d.App.FolderPicker.Close()
488				answers := make(chan tea.Msg, 128)
489				var run func(tea.Cmd)
490				run = func(command tea.Cmd) {
491					if command == nil {
492						return
493					}
494					go func() {
495						message := command()
496						if batch, ok := message.(tea.BatchMsg); ok {
497							for _, child := range batch {
498								run(child)
499							}
500							return
501						}
502						select {
503						case answers <- message:
504						case <-ctx.Done():
505						}
506					}()
507				}
508				for _, letter := range target + ":" {
509					run(d.Update(tea.KeyPressMsg{Code: letter, Text: string(letter)}))
510				}
511				for !strings.Contains(d.View(), "copying the kernel to "+target) {
512					select {
513					case message := <-answers:
514						run(d.Update(message))
515					case <-ctx.Done():
516						t.Fatalf("staging never reached the picker:\n%s", d.View())
517					}
518				}
519			}
520			release("stage")
521			state := "ready"
522			if target == "stage-failure" {
523				state = "unreachable"
524			}
525			settled := observe(state, "")
526			if state == "unreachable" && !strings.Contains(settled.Detail, "fixture staging failed") {
527				t.Fatalf("staging failure was lost: %+v", settled)
528			}
529			select {
530			case status := <-finished:
531				if status.State != state || status.Step != "" {
532					t.Fatalf("probe completion retained progress: %+v", status)
533				}
534			case <-ctx.Done():
535				t.Fatal(ctx.Err())
536			}
537		})
538	}
539}