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