返回 DeepSeek-Reasonix
saved_tab_route_reconcile_test.go
根目录 / desktop / saved_tab_route_reconcile_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "runtime"
9 "testing"
10
11 "reasonix/desktop/internal/workspacestate"
12 )
13
14 func TestReconcileSavedTabsNormalizesExactCanonicalRoute(t *testing.T) {
15 app := newSavedTabReconcileTestApp(t)
16 root := t.TempDir()
17 ref, workspaceID := createLegacyCleanupSession(t, app, root, "exact-route", true)
18 finishSavedTabMigration(app)
19 entry := savedProjectTab("saved", "", "", root, workspaceID)
20 entry.SessionPath = sessionRoute(ref.SessionID)
21 entry.extra = map[string]json.RawMessage{"future": json.RawMessage(`{"kept":true}`)}
22
23 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID})
24 if !changed || len(got.Tabs) != 1 {
25 t.Fatalf("exact route reconciliation = changed:%v file:%+v", changed, got)
26 }
27 if got.Tabs[0].SessionID != ref.SessionID || got.Tabs[0].SessionPath != "" {
28 t.Fatalf("exact route identity = id:%q path:%q", got.Tabs[0].SessionID, got.Tabs[0].SessionPath)
29 }
30 if string(got.Tabs[0].extra["future"]) != `{"kept":true}` {
31 t.Fatalf("unknown entry field lost: %s", mustMarshalJSON(t, got.Tabs[0]))
32 }
33 second, changedAgain := app.reconcileSavedTabs(t.Context(), got)
34 if changedAgain || second.Tabs[0].SessionID != ref.SessionID || second.Tabs[0].SessionPath != "" {
35 t.Fatalf("route repair was not idempotent: changed=%v file=%+v", changedAgain, second)
36 }
37 }
38
39 func TestReconcileSavedTabsNormalizesWindowsPseudoRouteWithCanonicalEvidence(t *testing.T) {
40 app := newSavedTabReconcileTestApp(t)
41 root := t.TempDir()
42 ref, workspaceID := createLegacyCleanupSession(t, app, root, "windows-pseudo-route", true)
43 finishSavedTabMigration(app)
44 entry := savedProjectTab("saved", "", "", root, workspaceID)
45 entry.SessionPath = `C:\old\reasonix\session-id:` + ref.SessionID
46
47 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
48 if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != ref.SessionID || got.Tabs[0].SessionPath != "" {
49 t.Fatalf("pseudo route reconciliation = changed:%v file:%+v", changed, got)
50 }
51 }
52
53 func TestReconcileSavedTabsBlocksConflictingAndInvalidRoutes(t *testing.T) {
54 for _, test := range []struct {
55 name string
56 entry desktopTabEntry
57 }{
58 {name: "conflicting ids", entry: desktopTabEntry{ID: "conflict", Scope: "global", SessionID: "other", SessionPath: "session-id:route-id"}},
59 {name: "invalid route", entry: desktopTabEntry{ID: "invalid", Scope: "global", SessionPath: "session-id:bad/id"}},
60 {name: "sidecar is not transcript", entry: desktopTabEntry{ID: "sidecar", Scope: "global", SessionPath: filepath.Join(t.TempDir(), "session-id:route.meta")}},
61 {name: "relative legacy path", entry: desktopTabEntry{ID: "relative", Scope: "global", SessionPath: "relative.jsonl"}},
62 } {
63 t.Run(test.name, func(t *testing.T) {
64 app := newSavedTabReconcileTestApp(t)
65 finishSavedTabMigration(app)
66 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{test.entry}})
67 if changed || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked {
68 t.Fatalf("blocked route = changed:%v file:%+v", changed, got)
69 }
70 if got.Tabs[0].SessionID != test.entry.SessionID || got.Tabs[0].SessionPath != test.entry.SessionPath {
71 t.Fatalf("conflicting identity was modified: got=%+v want=%+v", got.Tabs[0], test.entry)
72 }
73 })
74 }
75 }
76
77 func TestReconcileSavedTabsPreservesRealPseudoRouteArtifact(t *testing.T) {
78 if runtime.GOOS == "windows" {
79 t.Skip("POSIX permits colons in file names")
80 }
81 app := newSavedTabReconcileTestApp(t)
82 root := t.TempDir()
83 ref, workspaceID := createLegacyCleanupSession(t, app, root, "real-artifact-route", true)
84 finishSavedTabMigration(app)
85 path := filepath.Join(t.TempDir(), sessionRoute(ref.SessionID))
86 if err := os.WriteFile(path, []byte("legacy content"), 0o600); err != nil {
87 t.Fatal(err)
88 }
89 entry := savedProjectTab("saved", "", "", root, workspaceID)
90 entry.SessionPath = path
91
92 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
93 if changed || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked || got.Tabs[0].SessionPath != path {
94 t.Fatalf("real pseudo-route artifact was rewritten: changed=%v file=%+v", changed, got)
95 }
96 }
97
98 func TestPseudoRouteArtifactProbePreservesUnreadableResult(t *testing.T) {
99 want := errors.New("permission denied")
100 path := filepath.Join(t.TempDir(), "session-id:probe")
101 if runtime.GOOS == "windows" {
102 // A pseudo-route file name is unrepresentable on Windows and is skipped
103 // before Lstat. Use a valid transcript spelling to exercise error flow.
104 path = filepath.Join(t.TempDir(), "probe.jsonl")
105 }
106 present, err := savedTabPseudoRouteArtifactsPresentWith(path, func(string) (os.FileInfo, error) {
107 return nil, want
108 })
109 if present || !errors.Is(err, want) {
110 t.Fatalf("artifact probe = present:%v err:%v", present, err)
111 }
112 }
113
114 func TestRestoreBlocksInvalidRouteWithoutControllerOrSidecar(t *testing.T) {
115 app := newSavedTabReconcileTestApp(t)
116 finishSavedTabMigration(app)
117 entry := desktopTabEntry{ID: "invalid", Scope: "global", TopicID: "topic", SessionPath: "session-id:bad/id"}
118 file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}}
119 if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
120 t.Fatal(err)
121 }
122 if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), mustMarshalJSON(t, file), 0o600); err != nil {
123 t.Fatal(err)
124 }
125 app.tabsRestored = make(chan struct{})
126
127 app.restoreOrBuildTabs()
128 tab := app.tabs[entry.ID]
129 if tab == nil || tab.Ctrl != nil || tab.StartupErr == "" {
130 t.Fatalf("blocked restore tab = %+v", tab)
131 }
132 if len(app.runtimeByID) != 0 || len(app.runtimeBySessionKey) != 0 {
133 t.Fatalf("blocked restore created runtime state: %d/%d", len(app.runtimeByID), len(app.runtimeBySessionKey))
134 }
135 for _, artifact := range []string{"session-id:bad/id.meta", "session-id:bad/id.lock", "session-id:bad/id.lease.lock"} {
136 _, err := os.Lstat(artifact)
137 if runtime.GOOS == "windows" && err != nil {
138 // The route colon makes the spelling unrepresentable on Windows.
139 continue
140 }
141 if !errors.Is(err, os.ErrNotExist) {
142 t.Fatalf("blocked route created %q: %v", artifact, err)
143 }
144 }
145 }
146
147 func TestReconcileTabsBeforeRestoreBlocksOriginalWhenRepairWriteFails(t *testing.T) {
148 app := newSavedTabReconcileTestApp(t)
149 root := t.TempDir()
150 ref, workspaceID := createLegacyCleanupSession(t, app, root, "write-failure-route", true)
151 finishSavedTabMigration(app)
152 entry := savedProjectTab("saved", "", "", root, workspaceID)
153 entry.SessionPath = sessionRoute(ref.SessionID)
154 file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID}
155
156 destination := filepath.Join(desktopConfigDir(), tabsFileName)
157 if err := os.RemoveAll(destination); err != nil {
158 t.Fatal(err)
159 }
160 if err := os.MkdirAll(destination, 0o700); err != nil {
161 t.Fatal(err)
162 }
163 got, _, current := app.reconcileTabsBeforeRestore(t.Context(), file, app.tabsSnapshotVersion())
164 if !current || len(got.Tabs) != 1 || !got.Tabs[0].restoreBlocked {
165 t.Fatalf("failed repair write restore = current:%v file:%+v", current, got)
166 }
167 if got.Tabs[0].SessionID != "" || got.Tabs[0].SessionPath != entry.SessionPath {
168 t.Fatalf("failed repair write published unpersisted identity: %+v", got.Tabs[0])
169 }
170 }
171
172 func TestReconcileTabsBeforeRestoreRejectsRouteRepairAfterConcurrentSave(t *testing.T) {
173 app := newSavedTabReconcileTestApp(t)
174 root := t.TempDir()
175 ref, workspaceID := createLegacyCleanupSession(t, app, root, "concurrent-route", true)
176 entry := savedProjectTab("saved", "", "", root, workspaceID)
177 entry.SessionPath = sessionRoute(ref.SessionID)
178 file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID}
179 reached := make(chan struct{})
180 app.beforeSavedTabMigrationWait = func() { close(reached) }
181 type result struct {
182 file desktopTabsFile
183 current bool
184 }
185 done := make(chan result, 1)
186 version := app.tabsSnapshotVersion()
187 go func() {
188 got, _, current := app.reconcileTabsBeforeRestore(t.Context(), file, version)
189 done <- result{file: got, current: current}
190 }()
191 <-reached
192 app.mu.Lock()
193 app.tabsSaveVersion++
194 app.mu.Unlock()
195 finishSavedTabMigration(app)
196 got := <-done
197 if got.current {
198 t.Fatal("stale startup reconciliation remained publishable")
199 }
200 if got.file.Tabs[0].SessionID != "" || got.file.Tabs[0].SessionPath != entry.SessionPath {
201 t.Fatalf("stale repair escaped its snapshot: %+v", got.file.Tabs[0])
202 }
203 }
204
205 func TestReconcileSavedTabsPseudoRouteUsesPendingAndRecoveryEvidence(t *testing.T) {
206 t.Run("pending create", func(t *testing.T) {
207 app := newSavedTabReconcileTestApp(t)
208 root := t.TempDir()
209 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
210 if err != nil {
211 t.Fatal(err)
212 }
213 const sessionID = "pending-pseudo-route"
214 const operationID = "pending-pseudo-operation"
215 if err := app.workspaceRegistry().BeginCreate(t.Context(), workspacestate.PendingCreate{
216 OperationID: operationID, WorkspaceID: workspaceID, SessionID: sessionID,
217 }); err != nil {
218 t.Fatal(err)
219 }
220 finishSavedTabMigration(app)
221 entry := savedProjectTab("pending", "", operationID, root, workspaceID)
222 entry.SessionPath = `C:\old\session-id:` + sessionID
223 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
224 if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != sessionID || got.Tabs[0].SessionPath != "" || got.Tabs[0].restoreBlocked {
225 t.Fatalf("pending pseudo-route evidence = changed:%v file:%+v", changed, got)
226 }
227 })
228
229 t.Run("recovery owner", func(t *testing.T) {
230 app := newSavedTabReconcileTestApp(t)
231 root := t.TempDir()
232 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
233 if err != nil {
234 t.Fatal(err)
235 }
236 const sessionID = "recovery-pseudo-route"
237 if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
238 ID: "recovery-pseudo", SourceKey: "pseudo", SessionID: sessionID, WorkspaceID: workspaceID,
239 Scope: "project", WorkspaceRoot: root, Format: "canonical", Reason: "interrupted", Status: "pending",
240 }); err != nil {
241 t.Fatal(err)
242 }
243 finishSavedTabMigration(app)
244 entry := savedProjectTab("recovery", "", "", root, workspaceID)
245 entry.SessionPath = `C:\old\session-id:` + sessionID
246 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
247 if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != sessionID || got.Tabs[0].SessionPath != "" {
248 t.Fatalf("recovery pseudo-route evidence = changed:%v file:%+v", changed, got)
249 }
250 if !got.Tabs[0].restoreBlocked {
251 t.Fatal("missing recovery content was allowed to create a replacement runtime")
252 }
253 })
254 }
255
255 lines GO