| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/control" |
| 12 | ) |
| 13 | |
| 14 | func TestProjectTreeRuntimeSnapshotWailsArraysAreNonNil(t *testing.T) { |
| 15 | snapshot := NewApp().GetProjectTreeRuntimeSnapshot() |
| 16 | raw, err := json.Marshal(snapshot) |
| 17 | if err != nil || snapshot.Topics == nil || !strings.Contains(string(raw), `"topics":[]`) { |
| 18 | t.Fatalf("empty runtime snapshot = %s (%v), want non-nil topics:[]", raw, err) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | func TestProjectTreeRuntimeSnapshotLocalizesAutoTopicTitle(t *testing.T) { |
| 23 | app := NewApp() |
| 24 | app.setDesktopLocale("en-US") |
| 25 | const sessionID = "desktop-runtime-title" |
| 26 | app.tabs["auto"] = &WorkspaceTab{ |
| 27 | ID: "auto", Scope: "global", TopicID: "topic-auto", |
| 28 | TopicTitle: defaultTopicTitle, topicTitleSource: topicTitleSourceAuto, |
| 29 | SessionID: sessionID, SessionPath: sessionRoute(sessionID), |
| 30 | } |
| 31 | |
| 32 | snapshot := app.GetProjectTreeRuntimeSnapshot() |
| 33 | if len(snapshot.Topics) != 1 || snapshot.Topics[0].Node.Label != defaultTopicTitleEn { |
| 34 | t.Fatalf("runtime topic = %+v, want localized %q", snapshot.Topics, defaultTopicTitleEn) |
| 35 | } |
| 36 | if strings.Contains(snapshot.Topics[0].Node.Label, "session-id:") { |
| 37 | t.Fatalf("canonical route leaked into user-visible title: %+v", snapshot.Topics[0].Node) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestProjectTreeRuntimeSnapshotFindsRestoredTabBeforeFirstEvent(t *testing.T) { |
| 42 | app := NewApp() |
| 43 | app.tabs["restored"] = &WorkspaceTab{ |
| 44 | ID: "restored", Scope: "project", WorkspaceRoot: "/workspace/restored", |
| 45 | TopicID: "topic-restored", TopicTitle: "Restored task", |
| 46 | SessionPath: "/sessions/restored.jsonl", |
| 47 | Ctrl: &activationStubController{sessionPath: "/sessions/restored.jsonl"}, |
| 48 | Ready: true, |
| 49 | } |
| 50 | snapshot := app.GetProjectTreeRuntimeSnapshot() |
| 51 | if snapshot.Revision == 0 || len(snapshot.Topics) != 1 { |
| 52 | t.Fatalf("initial runtime snapshot = %+v, want one committed topic with a revision", snapshot) |
| 53 | } |
| 54 | topic := snapshot.Topics[0] |
| 55 | if topic.Node.TopicID != "topic-restored" || !topic.Node.Open || !topic.Node.Running { |
| 56 | t.Fatalf("restored runtime topic = %+v, want open/running topic-restored", topic) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestSyncProjectRootSpellingIncludesDetachedRuntime(t *testing.T) { |
| 61 | isolateDesktopUserDirs(t) |
| 62 | root := normalizeProjectRoot(t.TempDir()) |
| 63 | if err := addProject(root, "Project"); err != nil { |
| 64 | t.Fatalf("add project: %v", err) |
| 65 | } |
| 66 | app := NewApp() |
| 67 | app.detachedSessions["detached"] = &WorkspaceTab{ |
| 68 | Scope: "project", WorkspaceRoot: root + string(os.PathSeparator) + ".", |
| 69 | } |
| 70 | app.syncTabWorkspaceRootSpellings() |
| 71 | if got := app.detachedSessions["detached"].WorkspaceRoot; got != root { |
| 72 | t.Fatalf("detached runtime root = %q, want canonical %q", got, root) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestKeepOnlyVisibleTabPublishesDetachedRuntimeProjection(t *testing.T) { |
| 77 | isolateDesktopUserDirs(t) |
| 78 | projectA := t.TempDir() |
| 79 | projectB := t.TempDir() |
| 80 | for _, dir := range []string{desktopSessionDir(projectA), desktopSessionDir(projectB)} { |
| 81 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 82 | t.Fatalf("mkdir session dir: %v", err) |
| 83 | } |
| 84 | } |
| 85 | sessionA := writeTopicSessionWithPrompt( |
| 86 | t, |
| 87 | desktopSessionDir(projectA), |
| 88 | "running-a.jsonl", |
| 89 | "topic-a", |
| 90 | "Running A", |
| 91 | projectA, |
| 92 | "keep working in project A", |
| 93 | time.Now(), |
| 94 | ) |
| 95 | sessionB := writeTopicSessionWithPrompt( |
| 96 | t, |
| 97 | desktopSessionDir(projectB), |
| 98 | "visible-b.jsonl", |
| 99 | "topic-b", |
| 100 | "Visible B", |
| 101 | projectB, |
| 102 | "switch to project B", |
| 103 | time.Now(), |
| 104 | ) |
| 105 | |
| 106 | app := NewApp() |
| 107 | app.ctx = context.Background() |
| 108 | broadCatalogRefreshes := 0 |
| 109 | app.projectTreeCatalogRefreshHook = func() { broadCatalogRefreshes++ } |
| 110 | events := make(chan runtimeEventEnvelope, 8) |
| 111 | app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) { |
| 112 | events <- runtimeEventEnvelope{ctx: ctx, name: name, payload: append([]any(nil), payload...)} |
| 113 | } |
| 114 | t.Cleanup(func() { app.shutdown(context.Background()) }) |
| 115 | |
| 116 | runningCtrl := &activationStubController{sessionPath: sessionA} |
| 117 | visibleCtrl := &activationStubController{sessionPath: sessionB, status: &control.RuntimeStatus{}} |
| 118 | running := &WorkspaceTab{ |
| 119 | ID: "tab-a", Scope: "project", WorkspaceRoot: projectA, |
| 120 | TopicID: "topic-a", TopicTitle: "Running A", SessionPath: sessionA, |
| 121 | Ctrl: runningCtrl, Ready: true, disabledMCP: map[string]ServerView{}, |
| 122 | } |
| 123 | visible := &WorkspaceTab{ |
| 124 | ID: "tab-b", Scope: "project", WorkspaceRoot: projectB, |
| 125 | TopicID: "topic-b", TopicTitle: "Visible B", SessionPath: sessionB, |
| 126 | Ctrl: visibleCtrl, Ready: true, disabledMCP: map[string]ServerView{}, |
| 127 | } |
| 128 | running.sink = &tabEventSink{tabID: running.ID, app: app} |
| 129 | visible.sink = &tabEventSink{tabID: visible.ID, app: app} |
| 130 | app.tabs[running.ID] = running |
| 131 | app.tabs[visible.ID] = visible |
| 132 | app.tabOrder = []string{running.ID, visible.ID} |
| 133 | app.activeTabID = running.ID |
| 134 | |
| 135 | if _, err := app.keepOnlyVisibleTab(visible.ID); err != nil { |
| 136 | t.Fatalf("keepOnlyVisibleTab: %v", err) |
| 137 | } |
| 138 | if broadCatalogRefreshes != 0 { |
| 139 | t.Fatalf("topic switch requested %d broad catalog refreshes, want runtime-only projection", broadCatalogRefreshes) |
| 140 | } |
| 141 | app.mu.RLock() |
| 142 | detached := app.detachedSessions[sessionRuntimeKey(sessionA)] |
| 143 | app.mu.RUnlock() |
| 144 | if detached != running || runningCtrl.closed.Load() { |
| 145 | t.Fatal("project A's running session was not preserved as a detached runtime") |
| 146 | } |
| 147 | |
| 148 | deadline := time.After(time.Second) |
| 149 | foundRuntime := false |
| 150 | foundLegacy := false |
| 151 | for { |
| 152 | select { |
| 153 | case emitted := <-events: |
| 154 | switch emitted.name { |
| 155 | case "project-tree:runtime-changed": |
| 156 | if len(emitted.payload) != 1 { |
| 157 | t.Fatalf("project-tree:runtime-changed payload count = %d, want 1", len(emitted.payload)) |
| 158 | } |
| 159 | event, ok := emitted.payload[0].(ProjectTreeRuntimeSnapshot) |
| 160 | if !ok { |
| 161 | t.Fatalf("project-tree:runtime-changed payload type = %T, want ProjectTreeRuntimeSnapshot", emitted.payload[0]) |
| 162 | } |
| 163 | if event.Topics == nil || event.Revision == 0 { |
| 164 | t.Fatalf("project-tree:runtime-changed event = %+v, want a versioned runtime snapshot", event) |
| 165 | } |
| 166 | for _, topic := range event.Topics { |
| 167 | if topic.Scope == "project" && sameProjectRoot(topic.WorkspaceRoot, projectA) && topic.Node.TopicID == "topic-a" { |
| 168 | foundRuntime = !topic.Node.Open && topic.Node.Running |
| 169 | } |
| 170 | } |
| 171 | case "project-tree:changed": |
| 172 | if len(emitted.payload) != 1 { |
| 173 | t.Fatalf("legacy runtime invalidation payload = %#v, want one tagged payload", emitted.payload) |
| 174 | } |
| 175 | reason, ok := emitted.payload[0].(map[string]string) |
| 176 | foundLegacy = ok && reason["reason"] == "runtime" |
| 177 | } |
| 178 | if foundRuntime && foundLegacy { |
| 179 | return |
| 180 | } |
| 181 | case <-deadline: |
| 182 | if !foundRuntime { |
| 183 | t.Fatal("detaching project A emitted no runtime snapshot; the running conversation stays invisible") |
| 184 | } |
| 185 | if !foundLegacy { |
| 186 | t.Fatal("detaching project A emitted an untagged or missing compatibility invalidation") |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestProjectTreeCatalogV2CompatibilityEventIsTagged(t *testing.T) { |
| 193 | app := NewApp() |
| 194 | app.ctx = context.Background() |
| 195 | events := make(chan runtimeEventEnvelope, 2) |
| 196 | app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) { |
| 197 | events <- runtimeEventEnvelope{ctx: ctx, name: name, payload: append([]any(nil), payload...)} |
| 198 | } |
| 199 | |
| 200 | app.emitProjectTreeChangedV2(7, nil, "metadata") |
| 201 | first := <-events |
| 202 | second := <-events |
| 203 | if first.name != "project-tree:changed-v2" || second.name != "project-tree:changed" { |
| 204 | t.Fatalf("catalog events = %#v, %#v, want v2 followed by compatibility event", first, second) |
| 205 | } |
| 206 | reason, ok := second.payload[0].(map[string]string) |
| 207 | if !ok || reason["reason"] != "catalog-v2" { |
| 208 | t.Fatalf("catalog compatibility payload = %#v, want reason catalog-v2", second.payload) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestOpenProjectTabPublishesTaggedRuntimeInvalidation(t *testing.T) { |
| 213 | isolateDesktopUserDirs(t) |
| 214 | app := NewApp() |
| 215 | app.ctx = context.Background() |
| 216 | t.Cleanup(func() { app.shutdown(context.Background()) }) |
| 217 | events := make(chan runtimeEventEnvelope, 8) |
| 218 | app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) { |
| 219 | events <- runtimeEventEnvelope{ctx: ctx, name: name, payload: append([]any(nil), payload...)} |
| 220 | } |
| 221 | projectRoot := t.TempDir() |
| 222 | topic, err := app.CreateTopic("project", projectRoot, "Stable row") |
| 223 | if err != nil { |
| 224 | t.Fatalf("CreateTopic: %v", err) |
| 225 | } |
| 226 | |
| 227 | if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil { |
| 228 | t.Fatalf("OpenProjectTab: %v", err) |
| 229 | } |
| 230 | deadline := time.After(time.Second) |
| 231 | for { |
| 232 | select { |
| 233 | case event := <-events: |
| 234 | if event.name != "project-tree:changed" { |
| 235 | continue |
| 236 | } |
| 237 | if len(event.payload) != 1 { |
| 238 | continue |
| 239 | } |
| 240 | reason, ok := event.payload[0].(map[string]string) |
| 241 | if !ok || reason["reason"] != "runtime" { |
| 242 | continue |
| 243 | } |
| 244 | return |
| 245 | case <-deadline: |
| 246 | t.Fatal("opening a project topic emitted no tagged runtime invalidation") |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 |