返回 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 "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
250 lines GO