返回 DeepSeek-Reasonix
file_browser_preview_lifecycle_test.go
根目录 / desktop / file_browser_preview_lifecycle_test.go
1 package main
2
3 import (
4 "context"
5 "net/http"
6 "os"
7 "path/filepath"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/browser"
13 )
14
15 func filePreviewLifecycleFixture(t *testing.T) (*App, *WorkspaceTab, FileBrowserPreviewRequest) {
16 t.Helper()
17 isolateDesktopUserDirs(t)
18 root := t.TempDir()
19 if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<!doctype html>preview"), 0o600); err != nil {
20 t.Fatal(err)
21 }
22 a := NewApp()
23 tab := &WorkspaceTab{ID: "preview", Scope: "project", WorkspaceRoot: root, SessionGeneration: 1}
24 a.tabs["preview"] = tab
25 a.tabOrder = []string{"preview"}
26 t.Cleanup(a.stopWorkspacePreviewOrigin)
27 return a, tab, FileBrowserPreviewRequest{Source: "workspace", Path: "index.html", OperationID: "preview"}
28 }
29
30 func assertPreviewRevoked(t *testing.T, rawURL string) {
31 t.Helper()
32 client := &http.Client{Timeout: 3 * time.Second}
33 response, err := client.Get(rawURL)
34 if err != nil {
35 t.Fatal(err)
36 }
37 defer response.Body.Close()
38 if response.StatusCode != http.StatusNotFound {
39 t.Fatalf("retired preview returned %d", response.StatusCode)
40 }
41 }
42
43 func TestFileBrowserPreviewTabRemovalDoesNotDeadlock(t *testing.T) {
44 for _, operation := range []string{"remove", "transfer"} {
45 t.Run(operation, func(t *testing.T) {
46 a, tab, request := filePreviewLifecycleFixture(t)
47 exec := &previewTestExecutor{tabs: map[string]browser.Tab{}}
48 opened, err := a.openFileBrowserPreview(t.Context(), tab.ID, request, exec)
49 if err != nil {
50 t.Fatal(err)
51 }
52 a.mu.Lock()
53 if operation == "remove" {
54 a.removeTabOrderLocked(tab.ID)
55 } else {
56 applyRuntimeTab(&WorkspaceTab{ID: "replacement"}, tab, "", context.Background(), a)
57 }
58 a.mu.Unlock()
59 if len(a.filePreviews.bindings) != 0 {
60 t.Fatal("removed tab retained preview bindings")
61 }
62 a.ListTabs()
63 a.Settings()
64 assertPreviewRevoked(t, opened.URL)
65 })
66 }
67 }
68
69 type gatedFilePreviewExecutor struct {
70 previewTestExecutor
71 app *App
72 started chan string
73 resume chan struct{}
74 }
75
76 type cancellationAwarePreviewExecutor struct{ gatedFilePreviewExecutor }
77
78 func (e *cancellationAwarePreviewExecutor) Close(ctx context.Context, req browser.CloseRequest) error {
79 if err := ctx.Err(); err != nil {
80 return err
81 }
82 return e.previewTestExecutor.Close(ctx, req)
83 }
84
85 func TestFileBrowserPreviewCancelledRequestClosesLateTab(t *testing.T) {
86 a, tab, request := filePreviewLifecycleFixture(t)
87 exec := &cancellationAwarePreviewExecutor{gatedFilePreviewExecutor{
88 previewTestExecutor: previewTestExecutor{tabs: map[string]browser.Tab{}},
89 app: a, started: make(chan string, 1), resume: make(chan struct{}),
90 }}
91 resume := sync.OnceFunc(func() { close(exec.resume) })
92 defer resume()
93 ctx, cancel := context.WithCancel(t.Context())
94 defer cancel()
95 done := make(chan error, 1)
96 go func() { _, err := a.openFileBrowserPreview(ctx, tab.ID, request, exec); done <- err }()
97 pendingURL := <-exec.started
98 a.mu.Lock()
99 a.removeTabOrderLocked(tab.ID)
100 delete(a.tabs, tab.ID)
101 a.mu.Unlock()
102 cancel()
103 resume()
104 select {
105 case err := <-done:
106 if err == nil {
107 t.Fatal("late preview attached to the removed tab")
108 }
109 case <-time.After(10 * time.Second):
110 t.Fatal("cancelled preview cleanup did not finish")
111 }
112 if len(exec.tabs) != 0 || len(exec.closes) != 1 {
113 t.Fatalf("late browser tab was not closed: tabs=%d closes=%d", len(exec.tabs), len(exec.closes))
114 }
115 assertPreviewRevoked(t, pendingURL)
116 }
117
118 func (e *gatedFilePreviewExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
119 e.wait(req.URL)
120 return e.previewTestExecutor.Open(ctx, req)
121 }
122
123 func (e *gatedFilePreviewExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
124 e.wait(req.URL)
125 return e.previewTestExecutor.Navigate(ctx, req)
126 }
127
128 func (e *gatedFilePreviewExecutor) wait(url string) {
129 e.started <- url
130 <-e.resume
131 // A tool's browser executor resolves its current App binding before RPC.
132 e.app.mu.RLock()
133 e.app.mu.RUnlock() //nolint:staticcheck // Acquiring the lock itself simulates RPC binding resolution and detects lock inversion.
134 }
135
136 func TestFileBrowserPreviewRemovalDoesNotWaitForHostOrResurrectBinding(t *testing.T) {
137 a, tab, request := filePreviewLifecycleFixture(t)
138 exec := &gatedFilePreviewExecutor{previewTestExecutor: previewTestExecutor{tabs: map[string]browser.Tab{}}, app: a, started: make(chan string, 1), resume: make(chan struct{})}
139 resume := sync.OnceFunc(func() { close(exec.resume) })
140 defer resume()
141 done := make(chan error, 1)
142 go func() { _, err := a.openFileBrowserPreview(t.Context(), tab.ID, request, exec); done <- err }()
143 url := <-exec.started
144 removed := make(chan struct{})
145 go func() {
146 a.mu.Lock()
147 a.removeTabOrderLocked(tab.ID)
148 delete(a.tabs, tab.ID)
149 a.mu.Unlock()
150 close(removed)
151 }()
152 select {
153 case <-removed:
154 case <-time.After(3 * time.Second):
155 t.Fatal("navigation waited for a pending browser RPC")
156 }
157 assertPreviewRevoked(t, url)
158 a.Settings()
159 resume()
160 if err := <-done; err == nil {
161 t.Fatal("late host reply republished a removed preview")
162 }
163 if len(a.filePreviews.bindings) != 0 || len(exec.tabs) != 0 {
164 t.Fatal("late preview was not cleaned up")
165 }
166 }
167
168 func TestFileBrowserPreviewRefreshRevocationFencesLateReply(t *testing.T) {
169 for _, revoke := range []string{"browser-tab", "previous-url", "session-generation"} {
170 t.Run(revoke, func(t *testing.T) {
171 a, tab, request := filePreviewLifecycleFixture(t)
172 exec := &gatedFilePreviewExecutor{previewTestExecutor: previewTestExecutor{tabs: map[string]browser.Tab{}}, app: a, started: make(chan string, 1), resume: make(chan struct{})}
173 first, err := a.openFileBrowserPreview(t.Context(), tab.ID, request, &exec.previewTestExecutor)
174 if err != nil {
175 t.Fatal(err)
176 }
177 resume := sync.OnceFunc(func() { close(exec.resume) })
178 defer resume()
179 done := make(chan error, 1)
180 request.OperationID = "refresh"
181 go func() { _, err := a.openFileBrowserPreview(t.Context(), tab.ID, request, exec); done <- err }()
182 pendingURL := <-exec.started
183 switch revoke {
184 case "browser-tab":
185 a.releaseFileBrowserPreviewTab(first.TabID)
186 case "previous-url":
187 a.RevokeWorkspaceBrowserPreview(first.URL)
188 case "session-generation":
189 a.mu.Lock()
190 tab.SessionGeneration++
191 a.mu.Unlock()
192 }
193 if revoke != "session-generation" {
194 assertPreviewRevoked(t, first.URL)
195 assertPreviewRevoked(t, pendingURL)
196 }
197 resume()
198 if err := <-done; err == nil {
199 t.Fatal("refresh republished an explicitly revoked preview")
200 }
201 if len(a.filePreviews.bindings) != 0 || len(exec.tabs) != 0 {
202 t.Fatal("revoked refresh retained a preview")
203 }
204 assertPreviewRevoked(t, first.URL)
205 assertPreviewRevoked(t, pendingURL)
206 })
207 }
208 }
209
210 func TestFileBrowserPreviewLateReplyCannotAttachToReplacedSession(t *testing.T) {
211 a, tab, request := filePreviewLifecycleFixture(t)
212 exec := &gatedFilePreviewExecutor{previewTestExecutor: previewTestExecutor{tabs: map[string]browser.Tab{}}, app: a, started: make(chan string, 1), resume: make(chan struct{})}
213 resume := sync.OnceFunc(func() { close(exec.resume) })
214 defer resume()
215 done := make(chan error, 1)
216 go func() { _, err := a.openFileBrowserPreview(t.Context(), tab.ID, request, exec); done <- err }()
217 pendingURL := <-exec.started
218 a.mu.Lock()
219 tab.SessionGeneration++
220 a.mu.Unlock()
221 resume()
222 if err := <-done; err == nil {
223 t.Fatal("old preview attached to the replacement session")
224 }
225 assertPreviewRevoked(t, pendingURL)
226 if len(a.filePreviews.bindings) != 0 || len(exec.tabs) != 0 {
227 t.Fatal("stale preview was not cleaned up")
228 }
229 }
230
230 lines GO