返回 DeepSeek-Reasonix
browser_runtime_binding_test.go
根目录 / desktop / browser_runtime_binding_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10
11 "reasonix/internal/browser"
12 )
13
14 func TestBrowserExecutorFollowsRuntimeDetachAndReattach(t *testing.T) {
15 isolateDesktopUserDirs(t)
16 a := NewApp()
17 a.hostShell = &hostShellBridge{app: a}
18 source := &WorkspaceTab{ID: "source", SessionID: "session-a", Ready: true}
19 source.sink = &tabEventSink{tabID: source.ID, app: a}
20 a.tabs[source.ID] = source
21 exec := a.browserExecutorForTab(source).(*tabBrowserExecutor)
22 if !exec.Available(context.Background()) {
23 t.Fatal("new task must have browser access")
24 }
25
26 if !a.detachRuntimeForReplacement(source) {
27 t.Fatal("detach failed")
28 }
29 key := sessionRuntimeKey(sessionRoute(source.SessionID))
30 detached := a.detachedSessions[key]
31 if detached == nil {
32 t.Fatal("detached runtime missing")
33 }
34 // The old visible surface now belongs to another session. Its browser
35 // must never be used by the detached controller.
36 a.mu.Lock()
37 a.tabs[source.ID] = &WorkspaceTab{ID: source.ID, SessionID: "session-b"}
38 a.mu.Unlock()
39 assertBrowserBinding(t, a, exec, detached)
40 detachedGrant, _ := exec.current()
41
42 target := &WorkspaceTab{ID: "target"}
43 a.mu.Lock()
44 a.tabs[target.ID] = target
45 delete(a.detachedSessions, key)
46 applyRuntimeTab(target, detached, sessionRoute("session-a"), context.Background(), a)
47 a.mu.Unlock()
48 if !detachedGrant.revoked.Load() {
49 t.Fatal("detached grant survived transfer to a new surface")
50 }
51 assertBrowserBinding(t, a, exec, target)
52
53 a.setBrowserControlEnabled(false)
54 if exec.Available(context.Background()) {
55 t.Fatal("disabled browser control must still deny access")
56 }
57 a.setBrowserControlEnabled(true)
58 assertBrowserBinding(t, a, exec, target)
59 a.mu.Lock()
60 delete(a.tabs, target.ID)
61 a.mu.Unlock()
62 if exec.Available(context.Background()) {
63 t.Fatal("removed runtime must not retain browser access")
64 }
65 }
66
67 func TestBrowserExecutorFollowsVisibleRuntimeTransfer(t *testing.T) {
68 isolateDesktopUserDirs(t)
69 a := NewApp()
70 a.hostShell = &hostShellBridge{app: a}
71 root := t.TempDir()
72 if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<!doctype html><h1>Preview</h1>"), 0o600); err != nil {
73 t.Fatal(err)
74 }
75 t.Cleanup(a.stopWorkspacePreviewOrigin)
76 source := &WorkspaceTab{ID: "source", SessionID: "session-a", Scope: "project", WorkspaceRoot: root}
77 source.sink = &tabEventSink{tabID: source.ID, app: a}
78 a.tabs[source.ID] = source
79 exec := a.browserExecutorForTab(source).(*tabBrowserExecutor)
80 target := &WorkspaceTab{ID: "target", Scope: "project", WorkspaceRoot: root}
81 a.mu.Lock()
82 delete(a.tabs, source.ID)
83 a.tabs[target.ID] = target
84 applyRuntimeTab(target, source, sessionRoute("session-a"), context.Background(), a)
85 a.mu.Unlock()
86 assertBrowserBinding(t, a, exec, target)
87 current, _ := exec.current()
88 current.host = &fakeBrowserHost{replies: map[string]any{
89 "host/browser.tabs.open": map[string]any{"id": "preview-tab", "url": "https://example.test"},
90 }}
91 for _, tl := range browser.Tools(exec) {
92 if tl.Name() == "browser_preview" {
93 out, err := tl.Execute(context.Background(), []byte(`{"operationId":"preview-moved","source":"workspace","path":"index.html"}`))
94 if err != nil || !strings.Contains(out, "preview-tab") {
95 t.Fatalf("file preview after runtime transfer = %s, %v", out, err)
96 }
97 }
98 }
99 }
100
101 func TestBrowserAvailabilityIsObservational(t *testing.T) {
102 isolateDesktopUserDirs(t)
103 a := NewApp()
104 a.hostShell = &hostShellBridge{app: a}
105 tab := &WorkspaceTab{ID: "task", SessionID: "session-a"}
106 tab.sink = &tabEventSink{tabID: tab.ID, app: a}
107 a.tabs[tab.ID] = tab
108 exec := a.browserExecutorForTab(tab).(*tabBrowserExecutor)
109 for range 3 {
110 if !exec.Available(context.Background()) {
111 t.Fatal("bound runtime unavailable")
112 }
113 }
114 if len(a.browserExecutors) != 0 {
115 t.Fatal("availability lookup minted a browser grant")
116 }
117 }
118
119 func TestBrowserExecutorUsesReplacementRuntimeSink(t *testing.T) {
120 isolateDesktopUserDirs(t)
121 a := NewApp()
122 a.hostShell = &hostShellBridge{app: a}
123 tab := &WorkspaceTab{ID: "task", SessionID: "old"}
124 tab.sink = &tabEventSink{tabID: tab.ID, app: a}
125 a.tabs[tab.ID] = tab
126 old := a.browserExecutorForTab(tab).(*tabBrowserExecutor)
127 newSink := &tabEventSink{tabID: tab.ID, app: a}
128 replacement := a.browserExecutorForRuntime(tab.ID, newSink).(*tabBrowserExecutor)
129 if replacement.Available(context.Background()) {
130 t.Fatal("unpublished replacement inherited the old runtime's browser")
131 }
132 a.mu.Lock()
133 tab.sink = newSink
134 tab.SessionID = "new"
135 a.mu.Unlock()
136 if old.Available(context.Background()) {
137 t.Fatal("retired controller inherited the replacement runtime's browser")
138 }
139 if !replacement.Available(context.Background()) {
140 t.Fatal("published replacement has no browser")
141 }
142 }
143
144 func TestBrowserRuntimeCleanupPreservesReplacementGrant(t *testing.T) {
145 isolateDesktopUserDirs(t)
146 a := NewApp()
147 a.hostShell = &hostShellBridge{app: a}
148 old := &WorkspaceTab{ID: "task", SessionID: "old"}
149 newTab := &WorkspaceTab{ID: old.ID, SessionID: "new"}
150 a.tabs[newTab.ID] = newTab
151 grant := a.hostBrowserExecutorForTab(newTab.ID)
152 a.closeRemovedSessionRuntimes([]removedSessionRuntime{{tab: old}})
153 if grant.revoked.Load() {
154 t.Fatal("late cleanup revoked the replacement task's grant")
155 }
156 a.mu.Lock()
157 delete(a.tabs, newTab.ID)
158 a.mu.Unlock()
159 a.closeRemovedSessionRuntimes([]removedSessionRuntime{{tab: newTab}})
160 if !grant.revoked.Load() {
161 t.Fatal("removed runtime retained its grant")
162 }
163 }
164
165 func TestBrowserBindingConcurrentTransfer(t *testing.T) {
166 isolateDesktopUserDirs(t)
167 a := NewApp()
168 a.hostShell = &hostShellBridge{app: a}
169 source := &WorkspaceTab{ID: "source", SessionID: "session-a"}
170 source.sink = &tabEventSink{tabID: source.ID, app: a}
171 a.tabs[source.ID] = source
172 exec := a.browserExecutorForTab(source).(*tabBrowserExecutor)
173 target := &WorkspaceTab{ID: "target", SessionID: "session-a"}
174 var readers sync.WaitGroup
175 start := make(chan struct{})
176 for range 4 {
177 readers.Go(func() {
178 <-start
179 for range 100 {
180 if !exec.Available(context.Background()) {
181 t.Error("atomic runtime transfer exposed an unavailable binding")
182 return
183 }
184 grant, err := exec.current()
185 if err != nil || grant.sessionKey != "session-a:0" {
186 t.Errorf("runtime transfer selected another task: grant=%v err=%v", grant, err)
187 return
188 }
189 }
190 })
191 }
192 close(start)
193 for range 30 {
194 a.mu.Lock()
195 delete(a.tabs, source.ID)
196 a.tabs[target.ID] = target
197 applyRuntimeTab(target, source, sessionRoute("session-a"), context.Background(), a)
198 a.mu.Unlock()
199 source, target = target, source
200 }
201 readers.Wait()
202 }
203
204 func assertBrowserBinding(t *testing.T, a *App, proxy *tabBrowserExecutor, want *WorkspaceTab) {
205 t.Helper()
206 exec, err := proxy.current()
207 if err != nil {
208 t.Fatalf("current browser binding: %v", err)
209 }
210 if exec.tabID != want.ID || exec.browserSessionKey() != want.SessionID+":0" {
211 t.Fatalf("browser bound to %q/%q, want %q/%q", exec.tabID, exec.browserSessionKey(), want.ID, want.SessionID)
212 }
213 // Exercise the real browser tool boundary, not just the boolean probe.
214 host := &fakeBrowserHost{replies: map[string]any{"host/browser.tabs.list": map[string]any{"tabs": []map[string]any{{"id": "browser-tab"}}}}}
215 exec.host = host
216 for _, target := range browser.Tools(proxy) {
217 if target.Name() == "browser_tabs" {
218 if _, err := target.Execute(context.Background(), []byte(`{}`)); err != nil {
219 t.Fatalf("browser tool failed after runtime move: %v", err)
220 }
221 }
222 }
223 if len(host.methods()) == 0 {
224 t.Fatal("browser call did not reach the host")
225 }
226 }
227
227 lines GO