//go:build unix
// /cd crosses the composer, the folder picker, the daemon's /fs routes and
// its workspace move. A fake folder source cannot show the daemon listing a
// real directory, resolving a relative path, or keeping the new workspace.
package e2e
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"albedo/cli/internal/daemon"
"albedo/cli/internal/tui"
tea "charm.land/bubbletea/v2"
)
func TestTUICdMovesTheSessionThroughThePicker(t *testing.T) {
t.Parallel()
providerRoute(t, echoReply)
d := newTUIDriver(t)
root := t.TempDir()
for _, dir := range []string{"picked/src", "other", ".hidden"} {
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
t.Fatal(err)
}
}
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
same := func(got, want string) bool {
g, _ := filepath.EvalSymlinks(got)
w, _ := filepath.EvalSymlinks(want)
return g == w
}
d.App.Chat.TextArea.SetValue("/cd")
d.Dispatch(d.Key(tea.KeyEnter))
if d.App.State != tui.AppStateFolderPicker {
t.Fatalf("/cd did not open the picker:\n%s", d.View())
}
// The daemon lists the typed folder; the last segment filters it and
// dot folders stay hidden.
d.Type(root + "/pi")
if view := d.View(); !strings.Contains(view, "picked") || strings.Contains(view, "other") || strings.Contains(view, ".hidden") {
t.Fatalf("the listing was not filtered to picked:\n%s", view)
}
d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
if d.App.State != tui.AppStateChat {
t.Fatalf("enter did not return to the chat:\n%s", d.View())
}
id := d.App.ActiveSession.ID
if got := daemonSession(t, id).Workspace; !same(got, filepath.Join(root, "picked")) {
t.Fatalf("the daemon kept workspace %q", got)
}
// /cd with a path moves at once, relative to where the session is.
d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/cd", Args: "../other"})
if got := daemonSession(t, id).Workspace; !same(got, filepath.Join(root, "other")) || !same(d.App.Chat.Workspace, got) {
t.Fatalf("/cd ../other left the daemon at %q and the chat at %q", got, d.App.Chat.Workspace)
}
// The chat offers linking the folder it left, as a command to paste.
view := d.View()
_, offered, ok := strings.Cut(view, "/link add ")
if !ok {
t.Fatalf("moving did not offer /link add:\n%s", view)
}
offered = strings.Fields(offered)[0]
if !same(offered, filepath.Join(root, "picked")) {
t.Fatalf("the hint offers %q, not the folder the session left", offered)
}
d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/link", Args: "add " + offered})
if view := d.View(); !strings.Contains(view, "linked with "+offered) {
t.Fatalf("pasting the hint did not link the folders:\n%s", view)
}
}
func TestTUIMissingWorkspaceSendsTheTurnFromThePickedFolder(t *testing.T) {
t.Parallel()
profile := providerRoute(t, echoReply)
root := t.TempDir()
gone, found := filepath.Join(root, "gone"), filepath.Join(root, "found")
for _, dir := range []string{gone, found} {
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
}
session := daemonSession(t, newSession(t, gone))
d := driveTUI(t, &session)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
if err := os.Remove(gone); err != nil {
t.Fatal(err)
}
d.connected()
d.App.Chat.TextArea.SetValue("hello from nowhere")
messages := d.results(d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}))
var admitted tui.ChatTurnSentMsg
for _, message := range messages {
if sent, ok := message.(tui.ChatTurnSentMsg); ok {
admitted = sent
d.Update(sent)
}
}
if admitted.Err != nil || admitted.Handle == nil || !admitted.OK {
t.Fatalf("durable admission: %+v", admitted)
}
var receipt daemon.OperationReceipt
deadline := time.Now().Add(5 * time.Second)
for {
var err error
receipt, err = daemon.ResolveOperation(t.Context(), conn(t), admitted.Handle)
if err != nil {
t.Fatal(err)
}
if receipt.BlockingReason != "" {
break
}
if time.Now().After(deadline) {
t.Fatal("missing workspace did not retain blocked pending input")
}
time.Sleep(25 * time.Millisecond)
}
if receipt.DeliveryStatus != "pending" {
t.Fatalf("blocked input was lost: %+v", receipt)
}
d.Update(tui.ChatOperationResolvedMsg{SessionID: session.ID, Generation: d.App.Chat.Generation, Handle: admitted.Handle, Receipt: receipt})
if d.App.State != tui.AppStateChat || d.App.Chat.TextArea.Value() != "" || !strings.Contains(d.View(), "hello from nowhere") {
t.Fatalf("blocked admission state=%v composer=%q view=%q", d.App.State, d.App.Chat.TextArea.Value(), d.View())
}
d.Dispatch(tui.ChatExecuteCommandMsg{Name: "/cd"})
d.Type(root + "/fou")
moved, ok := d.Key(tea.KeyEnter).(tui.FolderMovedMsg)
if !ok || moved.Err != nil {
t.Fatalf("enter did not move the session: %#v", moved)
}
d.Update(moved)
waitIdle(t, session.ID, profile, 1)
history, err := daemon.NewChatClient(conn(t), session.ID).History(t.Context(), 0, 120)
if err != nil {
t.Fatal(err)
}
users := 0
for _, event := range history.Events {
if event.Type == daemon.EventUser {
users++
if event.OperationID != admitted.OperationID {
t.Fatal("workspace repair created another user intent")
}
}
}
if users != 1 {
t.Fatalf("workspace repair delivered %d user rows", users)
}
if got, _ := filepath.EvalSymlinks(daemonSession(t, session.ID).Workspace); got != mustEval(t, found) {
t.Fatalf("the daemon kept workspace %q", got)
}
}
func TestTUISessionsByFolderOpenAndStartSessions(t *testing.T) {
t.Parallel()
providerRoute(t, echoReply)
root := t.TempDir()
busy, empty, crowded := filepath.Join(root, "busy"), filepath.Join(root, "empty"), filepath.Join(root, "crowded")
for _, dir := range []string{busy, empty, crowded} {
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
}
// A deep tree, taller than the pane, must not push the sessions off.
for i := range 12 {
for j := range 5 {
if err := os.MkdirAll(filepath.Join(busy, fmt.Sprintf("d%02d/c%d", i, j)), 0o755); err != nil {
t.Fatal(err)
}
}
}
there := newSession(t, busy)
// In the sessions view the leading slash opens the folder browser, which
// must be on screen before the rest of the path is typed into it.
typePath := func(d *tuiDriver, path string) {
d.Dispatch(tea.KeyPressMsg{Code: '/', Text: "/"})
d.Type(strings.TrimPrefix(path, "/"))
}
// ^f in the sessions view browses folders; → steps into the highlighted
// folder's sessions and enter opens one.
d := driveTUI(t, nil)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
d.Dispatch(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
if d.App.State != tui.AppStateFolderPicker {
t.Fatalf("^f did not open the folder browser:\n%s", d.View())
}
d.Type(root + "/bu")
if view := d.View(); !strings.Contains(view, "d00/") || !strings.Contains(view, "Untitled session") {
t.Fatalf("the preview lost its tree or its sessions:\n%s", view)
}
d.Dispatch(tea.KeyPressMsg{Code: tea.KeyRight})
opened, ok := d.Key(tea.KeyEnter).(tui.FolderOpenSessionMsg)
if !ok || opened.Session.ID != there {
t.Fatalf("enter in the folder's sessions did not open %s: %#v\n%s", there, opened, d.View())
}
d.Update(opened)
if d.App.State != tui.AppStateChat || d.App.ActiveSession.ID != there {
t.Fatalf("the picked session is not on screen:\n%s", d.View())
}
// Under a short tree the sessions fill the pane instead of a third of it.
for range 12 {
newSession(t, crowded)
}
d = driveTUI(t, nil)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
typePath(d, root+"/cr")
if view := d.View(); strings.Count(view, "Untitled session") != 12 {
t.Fatalf("the preview cut the sessions short:\n%s", view)
}
// A path typed into an empty search browses too, and enter on a folder
// starts a session there.
d = driveTUI(t, nil)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
typePath(d, root+"/em")
if d.App.State != tui.AppStateFolderPicker {
t.Fatalf("typing a path did not open the folder browser:\n%s", d.View())
}
started, ok := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg)
if !ok {
t.Fatalf("enter on a folder did not start a session: %#v", started)
}
d.Update(d.Send(started))
if got := d.App.ActiveSession; d.App.State != tui.AppStateChat || got == nil || mustEval(t, got.Workspace) != mustEval(t, empty) {
t.Fatalf("the new session is not in %s:\n%s", empty, d.View())
}
}
func mustEval(t *testing.T, p string) string {
t.Helper()
resolved, err := filepath.EvalSymlinks(p)
if err != nil {
t.Fatal(err)
}
return resolved
}
// A host is picked like a folder: scp syntax lists its home through the
// daemon's ssh, the rows fold under that host's ~, enter starts a session
// whose kernel boots there, and a host ssh cannot reach is never picked.
//
// The TUI starts the session on the active profile, so this runs alone.
func TestTUIStartsASessionOnAHostPickedLikeAFolder(t *testing.T) {
profile := providerRoute(t, echoReply)
picked := filepath.Join(suite.remoteHome, "proj", fmt.Sprintf("picked-%d", os.Getpid()))
if err := os.MkdirAll(picked, 0o755); err != nil {
t.Fatal(err)
}
d := driveTUI(t, nil)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
d.Dispatch(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
d.Type("gohost:")
if view := d.View(); !strings.Contains(view, "in gohost:~") || !strings.Contains(view, "proj") {
t.Fatalf("gohost: did not list the host's home:\n%s", view)
}
d.Type("proj/" + filepath.Base(picked)[:6])
if view := d.View(); !strings.Contains(view, "in gohost:~/proj") || !strings.Contains(view, filepath.Base(picked)) {
t.Fatalf("the host's folder did not list:\n%s", view)
}
started, ok := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg)
if !ok || started.Workspace != "gohost:"+picked {
t.Fatalf("enter did not start a session in gohost:%s: %#v", picked, started)
}
d.Update(d.Send(started))
session := d.App.ActiveSession
if d.App.State != tui.AppStateChat || session == nil || session.Workspace != "gohost:"+picked {
t.Fatalf("the new session is not on gohost:\n%s", d.View())
}
// the daemon's parsed location reaches the client, so the header shows the host
if l := session.Location; l == nil || l.Host == nil || *l.Host != "gohost" || l.Path != picked {
t.Fatalf("the session's location did not decode: %+v", l)
}
// its kernel boots on the host, and the status says it is attached
if _, err := daemon.NewChatClient(conn(t), session.ID).Send(context.Background(), "hello there", nil); err != nil {
t.Fatal(err)
}
waitIdle(t, session.ID, profile, 1)
status, err := daemon.NewChatClient(conn(t), session.ID).GetStatus(context.Background())
if err != nil || status.KernelLink != "attached" {
t.Fatalf("after a turn the kernel status is %+v (%v)", status, err)
}
// a host ssh cannot reach says why and is never picked
d = driveTUI(t, nil)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
d.Dispatch(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
d.Type("nohost:")
if view := d.View(); !strings.Contains(view, "connect to host nohost") {
t.Fatalf("an unreachable host does not say why:\n%s", view)
}
if msg, started := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg); started {
t.Fatalf("enter started a session on an unreachable host: %#v", msg)
}
}
// A turn at a host that needs a person to sign in is kept, waiting on the
// daemon's next try, and the chat offers to open the daemon's ssh master
// here so ssh can ask.
func TestTUIOffersSignInForATurnWaitingOnItsHost(t *testing.T) {
t.Parallel()
providerRoute(t, echoReply)
session := daemonSession(t, newSession(t, "lockedhost:/srv/app"))
d := driveTUI(t, &session)
d.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
d.connected()
d.App.Chat.TextArea.SetValue("hello behind a lock")
var admitted tui.ChatTurnSentMsg
for _, message := range d.results(d.Update(tea.KeyPressMsg{Code: tea.KeyEnter})) {
if sent, ok := message.(tui.ChatTurnSentMsg); ok {
admitted = sent
d.Update(sent)
}
}
if admitted.Err != nil || admitted.Handle == nil {
t.Fatalf("the turn was not admitted: %+v", admitted)
}
var receipt daemon.OperationReceipt
for deadline := time.Now().Add(10 * time.Second); receipt.BlockingReason == ""; time.Sleep(25 * time.Millisecond) {
var err error
if receipt, err = daemon.ResolveOperation(t.Context(), conn(t), admitted.Handle); err != nil {
t.Fatal(err)
}
if time.Now().After(deadline) {
t.Fatalf("the turn never waited on its host: %+v", receipt)
}
}
// The receipt's own poll answers only at the daemon's next try, so each
// answer is taken as it comes rather than waiting for all of them.
resolved := tui.ChatOperationResolvedMsg{SessionID: session.ID, Generation: d.App.Chat.Generation, Handle: admitted.Handle, Receipt: receipt}
answers := make(chan tea.Msg, 4)
for _, cmd := range d.Update(resolved)().(tea.BatchMsg) {
go func() { answers <- cmd() }()
}
offer := "Press ctrl+l to sign in to lockedhost here"
for timeout := time.After(10 * time.Second); !strings.Contains(d.View(), offer); {
select {
case answer := <-answers:
d.Update(answer)
case <-timeout:
t.Fatalf("a turn waiting on a sign-in does not offer one:\n%s", d.View())
}
}
if view := d.View(); !strings.Contains(view, "Permission denied") {
t.Fatalf("the waiting turn does not say why:\n%s", view)
}
}