| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/desktop/internal/workspacestate" |
| 8 | "reasonix/internal/config" |
| 9 | ) |
| 10 | |
| 11 | // Archiving the only visible session hands the surface to the draft landing. |
| 12 | // A replacement blank session would be registered as a real sidebar row that |
| 13 | // the user can archive again, so the workspace could never become empty. |
| 14 | func TestArchiveLastVisibleSessionLeavesNoReplacementBlankSession(t *testing.T) { |
| 15 | a, ref := lifecycleFixture(t) |
| 16 | a.tabs = map[string]*WorkspaceTab{ |
| 17 | "only": {ID: "only", Scope: "global", WorkspaceRoot: globalWorkspaceRoot(), TopicID: "canonical-" + ref.SessionID, |
| 18 | SessionID: ref.SessionID, Ready: true, disabledMCP: map[string]ServerView{}}, |
| 19 | } |
| 20 | a.tabOrder = []string{"only"} |
| 21 | a.activeTabID = "only" |
| 22 | |
| 23 | archived, err := a.ArchiveSessionTarget(SessionSelector{Ref: &ref}) |
| 24 | if err != nil || !archived.Committed { |
| 25 | t.Fatalf("ArchiveSessionTarget = %+v, %v", archived, err) |
| 26 | } |
| 27 | assertNoVisibleRuntime(t, a) |
| 28 | state, err := a.workspaceRegistry().Load(t.Context()) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if got := state.SessionStates[ref.SessionID].Lifecycle; got != workspacestate.Archived { |
| 33 | t.Fatalf("archived lifecycle = %q", got) |
| 34 | } |
| 35 | for id, sessionState := range state.SessionStates { |
| 36 | if sessionState.Lifecycle == workspacestate.Active { |
| 37 | t.Fatalf("archive registered replacement session %q", id) |
| 38 | } |
| 39 | } |
| 40 | page, err := a.ListWorkspaceSessions(workspacestate.GlobalWorkspaceID, "", "", 50, false) |
| 41 | if err != nil || len(page.Sessions) != 0 { |
| 42 | t.Fatalf("workspace still lists sessions after archiving its only one: %+v, %v", page.Sessions, err) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // The legacy topic archive shares the fallback mechanism with canonical |
| 47 | // archive, so it must leave the same empty surface. |
| 48 | func TestTrashLastTopicLeavesNoReplacementBlankSession(t *testing.T) { |
| 49 | isolateDesktopUserDirs(t) |
| 50 | projectRoot := t.TempDir() |
| 51 | topicID := "topic_trash_last" |
| 52 | if err := addProject(projectRoot, ""); err != nil { |
| 53 | t.Fatalf("add project: %v", err) |
| 54 | } |
| 55 | if err := setTopicTitle(projectRoot, topicID, "Trash last"); err != nil { |
| 56 | t.Fatalf("set topic title: %v", err) |
| 57 | } |
| 58 | dir := config.SessionDir() |
| 59 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 60 | t.Fatalf("mkdir sessions: %v", err) |
| 61 | } |
| 62 | sessionPath := writeTopicSession(t, dir, "trash-last.jsonl", topicID, "Trash last", projectRoot) |
| 63 | ctrl := controllerWithContent(t, sessionPath) |
| 64 | tab := &WorkspaceTab{ID: "only", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID, |
| 65 | TopicTitle: "Trash last", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}} |
| 66 | app := NewApp() |
| 67 | app.ctx = t.Context() |
| 68 | installNoopRuntimeEvents(app) |
| 69 | t.Cleanup(app.closeSessionServices) |
| 70 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 71 | app.tabOrder = []string{tab.ID} |
| 72 | app.activeTabID = tab.ID |
| 73 | |
| 74 | if err := app.TrashTopic(topicID); err != nil { |
| 75 | t.Fatalf("TrashTopic: %v", err) |
| 76 | } |
| 77 | assertNoVisibleRuntime(t, app) |
| 78 | } |
| 79 | |
| 80 | func assertNoVisibleRuntime(t *testing.T, a *App) { |
| 81 | t.Helper() |
| 82 | a.mu.RLock() |
| 83 | defer a.mu.RUnlock() |
| 84 | if len(a.tabs) != 0 || len(a.tabOrder) != 0 || a.activeTabID != "" { |
| 85 | ids := make([]string, 0, len(a.tabs)) |
| 86 | for id, tab := range a.tabs { |
| 87 | ids = append(ids, id+"/topic="+tab.TopicID+"/session="+tab.SessionID) |
| 88 | } |
| 89 | t.Fatalf("removing the last visible session opened a replacement runtime: tabs=%v order=%v active=%q", ids, a.tabOrder, a.activeTabID) |
| 90 | } |
| 91 | } |
| 92 |