| 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 |