返回 DeepSeek-Reasonix
browser_broker_test.go
根目录 / internal / serve / browser_broker_test.go
1 package serve
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "path/filepath"
8 "slices"
9 "strings"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/boot"
14 "reasonix/internal/browser"
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 "reasonix/internal/session"
18 )
19
20 // brokerTestExecutor records the session header each call arrived with.
21 type brokerTestExecutor struct {
22 sessions []string
23 tabs []browser.Tab
24 }
25
26 func (e *brokerTestExecutor) session(ctx context.Context) string {
27 id := browser.SessionFromContext(ctx)
28 e.sessions = append(e.sessions, id)
29 return id
30 }
31
32 func (e *brokerTestExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) {
33 e.session(ctx)
34 return e.tabs, nil
35 }
36 func (e *brokerTestExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
37 e.session(ctx)
38 return browser.Tab{ID: "t1", URL: req.URL}, nil
39 }
40 func (e *brokerTestExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
41 e.session(ctx)
42 return browser.Tab{ID: req.TabID}, nil
43 }
44 func (e *brokerTestExecutor) Snapshot(ctx context.Context, _ browser.SnapshotRequest) (browser.Snapshot, error) {
45 e.session(ctx)
46 return browser.Snapshot{}, nil
47 }
48 func (e *brokerTestExecutor) Screenshot(ctx context.Context, _ browser.ScreenshotRequest) (browser.Screenshot, error) {
49 e.session(ctx)
50 return browser.Screenshot{}, nil
51 }
52 func (e *brokerTestExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) {
53 e.session(ctx)
54 return browser.ActResult{Executed: true, Outcome: browser.OutcomeExecuted}, nil
55 }
56 func (e *brokerTestExecutor) Downloads(ctx context.Context, _ browser.DownloadsRequest) ([]browser.Download, error) {
57 e.session(ctx)
58 return nil, nil
59 }
60 func (e *brokerTestExecutor) Close(ctx context.Context, _ browser.CloseRequest) error {
61 e.session(ctx)
62 return nil
63 }
64
65 func newBrokerTestHost(t *testing.T, token string) (*httptest.Server, *brokerTestExecutor) {
66 t.Helper()
67 exec := &brokerTestExecutor{tabs: []browser.Tab{{ID: "t1", URL: "https://example.test"}}}
68 srv := httptest.NewServer(browser.NewHTTPHandler(exec, token))
69 t.Cleanup(srv.Close)
70 return srv, exec
71 }
72
73 func TestNewBrowserBrokerValidatesEndpoint(t *testing.T) {
74 for _, endpoint := range []string{
75 "", "http://example.com:1", "https://127.0.0.1:1", "http://127.0.0.1",
76 "http://127.0.0.1:1/path", "http://user@127.0.0.1:1", "http://10.0.0.1:9",
77 } {
78 if _, err := NewBrowserBroker(endpoint, "tok"); err == nil {
79 t.Fatalf("endpoint %q accepted, want rejection", endpoint)
80 }
81 }
82 if _, err := NewBrowserBroker("http://127.0.0.1:9999", " "); err == nil {
83 t.Fatal("empty token accepted")
84 }
85 if _, err := NewBrowserBroker("http://127.0.0.1:9999", "tok"); err != nil {
86 t.Fatalf("valid loopback endpoint rejected: %v", err)
87 }
88 }
89
90 func TestBrowserBrokerSessionScopeTravelsOverHTTP(t *testing.T) {
91 host, exec := newBrokerTestHost(t, "tok")
92 broker, err := NewBrowserBroker(host.URL, "tok")
93 if err != nil {
94 t.Fatal(err)
95 }
96 bc := NewBroadcaster()
97 tag := newSessionTagSink(bc)
98 tag.SetPath("/remote/sessions/a.jsonl")
99 scoped := broker.ForSession(tag)
100 tabs, err := scoped.Tabs(context.Background())
101 if err != nil {
102 t.Fatal(err)
103 }
104 if len(tabs) != 1 || tabs[0].ID != "t1" {
105 t.Fatalf("tabs = %+v", tabs)
106 }
107 if want := agent.CanonicalSessionPath("/remote/sessions/a.jsonl"); len(exec.sessions) != 1 || exec.sessions[0] != want {
108 t.Fatalf("host saw sessions %v, want the tag path %q", exec.sessions, want)
109 }
110 }
111
112 func TestBrowserBrokerRebindRotatesGeneration(t *testing.T) {
113 first, _ := newBrokerTestHost(t, "tok-1")
114 second, secondExec := newBrokerTestHost(t, "tok-2")
115 broker, err := NewBrowserBroker(first.URL, "tok-1")
116 if err != nil {
117 t.Fatal(err)
118 }
119 if err := broker.Rebind(second.URL, "tok-2"); err != nil {
120 t.Fatal(err)
121 }
122 if broker.Endpoint() != second.URL {
123 t.Fatalf("endpoint = %q, want %q", broker.Endpoint(), second.URL)
124 }
125 tabs, err := broker.Tabs(context.Background())
126 if err != nil || len(tabs) != 1 {
127 t.Fatalf("Tabs after rebind = %+v, %v", tabs, err)
128 }
129 if len(secondExec.sessions) != 1 {
130 t.Fatalf("second generation saw %d calls, want 1", len(secondExec.sessions))
131 }
132 if err := broker.Rebind("http://192.0.2.1:9", "tok"); err == nil {
133 t.Fatal("rebind to a non-loopback endpoint accepted")
134 }
135 }
136
137 func newBrokerTestServer(t *testing.T, opts boot.Options) *Server {
138 t.Helper()
139 dir := t.TempDir()
140 bc := NewBroadcaster()
141 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, WorkspaceRoot: dir})
142 t.Cleanup(func() { ctrl.Close() })
143 srv := New(ctrl, bc, config.ServeConfig{})
144 srv.SetControllerBuildOptions(opts)
145 return srv
146 }
147
148 func TestServerCapabilitiesFollowBroker(t *testing.T) {
149 if caps := newBrokerTestServer(t, boot.Options{}).capabilities(); !slices.Equal(caps, []string{capabilityPermissionPresets, capabilityPresentFiles, capabilityExecutionV2, capabilitySessionHistory, capabilityExtensionFormInstanceV1, capabilityInteractionTargetV1, capabilityTranscriptOutline}) {
150 t.Fatalf("capabilities without broker = %v", caps)
151 }
152 broker, err := NewBrowserBroker("http://127.0.0.1:9999", "tok")
153 if err != nil {
154 t.Fatal(err)
155 }
156 srv := newBrokerTestServer(t, boot.Options{BrowserExecutor: broker})
157 caps := srv.capabilities()
158 if !slices.Equal(caps, []string{capabilityPermissionPresets, capabilityPresentFiles, capabilityExecutionV2, capabilitySessionHistory, capabilityExtensionFormInstanceV1, capabilityInteractionTargetV1, capabilityBrowser, capabilityTranscriptOutline}) {
159 t.Fatalf("capabilities with broker = %v", caps)
160 }
161 }
162
163 func TestServerAdvertisesImmutableSessionIdentityOnlyForExclusiveV3(t *testing.T) {
164 service, err := session.NewService("serve", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
165 if err != nil {
166 t.Fatal(err)
167 }
168 t.Cleanup(func() {
169 if err := service.Shutdown(context.Background()); err != nil {
170 t.Errorf("shutdown session service: %v", err)
171 }
172 })
173 ctrl := control.New(control.Options{SessionService: service, ExclusiveSession: true})
174 defer ctrl.Close()
175 srv := New(ctrl, NewBroadcaster(), config.ServeConfig{})
176 if !slices.Contains(srv.capabilities(), capabilitySessionIdentityV1) || !slices.Contains(srv.capabilities(), capabilitySessionContentV1) || !slices.Contains(srv.capabilities(), capabilitySessionReadV2) || !slices.Contains(srv.capabilities(), capabilityGoalLifecycleV2) {
177 t.Fatalf("exclusive v3 capabilities = %v", srv.capabilities())
178 }
179 if slices.Contains(srv.capabilities(), capabilityAttachmentsV1) {
180 t.Fatal("serve must not advertise attachments-v1; draft staging is a local desktop host capability")
181 }
182 }
183
184 func TestBrowserBrokerRebindHTTP(t *testing.T) {
185 // No broker configured: the route refuses instead of inventing one.
186 srv := newBrokerTestServer(t, boot.Options{})
187 rec := httptest.NewRecorder()
188 srv.browserBrokerRebind(rec, httptest.NewRequest(http.MethodPost, "/browser/broker",
189 strings.NewReader(`{"endpoint":"http://127.0.0.1:9","token":"t"}`)))
190 if rec.Code != http.StatusConflict {
191 t.Fatalf("rebind without broker = %d, want 409", rec.Code)
192 }
193
194 broker, err := NewBrowserBroker("http://127.0.0.1:9999", "old")
195 if err != nil {
196 t.Fatal(err)
197 }
198 srv = newBrokerTestServer(t, boot.Options{BrowserExecutor: broker})
199 rec = httptest.NewRecorder()
200 srv.browserBrokerRebind(rec, httptest.NewRequest(http.MethodPost, "/browser/broker", strings.NewReader(`{`)))
201 if rec.Code != http.StatusBadRequest {
202 t.Fatalf("rebind with malformed body = %d, want 400", rec.Code)
203 }
204 rec = httptest.NewRecorder()
205 srv.browserBrokerRebind(rec, httptest.NewRequest(http.MethodPost, "/browser/broker",
206 strings.NewReader(`{"endpoint":"http://127.0.0.1:1234","token":"new"}`)))
207 if rec.Code != http.StatusNoContent {
208 t.Fatalf("rebind = %d, want 204", rec.Code)
209 }
210 if broker.Endpoint() != "http://127.0.0.1:1234" {
211 t.Fatalf("endpoint after rebind = %q", broker.Endpoint())
212 }
213 }
214
215 func TestHandshakeAdvertisesBrowserCapability(t *testing.T) {
216 broker, err := NewBrowserBroker("http://127.0.0.1:9999", "tok")
217 if err != nil {
218 t.Fatal(err)
219 }
220 for _, withBroker := range []bool{false, true} {
221 opts := boot.Options{}
222 if withBroker {
223 opts.BrowserExecutor = broker
224 }
225 dir := t.TempDir()
226 bc := NewBroadcaster()
227 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, WorkspaceRoot: dir})
228 defer ctrl.Close()
229 srv := New(ctrl, bc, config.ServeConfig{AuthMode: "token", Token: "secret"})
230 srv.SetControllerBuildOptions(opts)
231 ts := httptest.NewServer(srv.Handler())
232 defer ts.Close()
233 resp, err := http.Post(ts.URL+"/auth/token", "application/json", strings.NewReader(`{"token":"secret"}`))
234 if err != nil {
235 t.Fatal(err)
236 }
237 _ = resp.Body.Close()
238 if resp.StatusCode != http.StatusNoContent {
239 t.Fatalf("handshake status = %d, want 204", resp.StatusCode)
240 }
241 got := resp.Header.Get(capabilitiesHeader)
242 if withBroker && got != capabilityPermissionPresets+","+capabilityPresentFiles+","+capabilityExecutionV2+","+capabilitySessionHistory+","+capabilityExtensionFormInstanceV1+","+capabilityInteractionTargetV1+","+capabilityBrowser+","+capabilityTranscriptOutline {
243 t.Fatalf("capabilities header = %q, want permission, present-files, browser and outline capabilities", got)
244 }
245 if !withBroker && got != capabilityPermissionPresets+","+capabilityPresentFiles+","+capabilityExecutionV2+","+capabilitySessionHistory+","+capabilityExtensionFormInstanceV1+","+capabilityInteractionTargetV1+","+capabilityTranscriptOutline {
246 t.Fatalf("capabilities header = %q, want permission, present-files and outline capabilities", got)
247 }
248 }
249 }
250
251 func TestSessionBrowserExecutorPassthrough(t *testing.T) {
252 exec := &brokerTestExecutor{}
253 srv := newBrokerTestServer(t, boot.Options{BrowserExecutor: exec})
254 if got := srv.sessionBrowserExecutor(nil); got != browser.Executor(exec) {
255 t.Fatalf("non-broker executor wrapped: %T", got)
256 }
257 if srv.browserBroker() != nil {
258 t.Fatal("browserBroker() non-nil without a broker")
259 }
260 }
261
262 func TestBuildTaggedScopesBrokerToSession(t *testing.T) {
263 host, hostExec := newBrokerTestHost(t, "tok")
264 broker, err := NewBrowserBroker(host.URL, "tok")
265 if err != nil {
266 t.Fatal(err)
267 }
268 srv := newBrokerTestServer(t, boot.Options{BrowserExecutor: broker})
269 var gotOpts boot.Options
270 srv.buildControllerWithOptions = func(_ context.Context, _ string, opts boot.Options) (*control.Controller, error) {
271 gotOpts = opts
272 return control.New(control.Options{Sink: opts.Sink, SessionDir: opts.SessionDir, WorkspaceRoot: opts.WorkspaceRoot}), nil
273 }
274 built, tag, err := srv.buildTagged(context.Background(), "provider/model", false)
275 if err != nil {
276 t.Fatal(err)
277 }
278 defer built.Close()
279 tag.SetPath("/remote/sessions/b.jsonl")
280 if _, ok := gotOpts.BrowserExecutor.(sessionBrowserExecutor); !ok {
281 t.Fatalf("buildTagged BrowserExecutor = %T, want sessionBrowserExecutor", gotOpts.BrowserExecutor)
282 }
283 if _, err := gotOpts.BrowserExecutor.Tabs(context.Background()); err != nil {
284 t.Fatal(err)
285 }
286 if want := agent.CanonicalSessionPath("/remote/sessions/b.jsonl"); len(hostExec.sessions) == 0 || hostExec.sessions[len(hostExec.sessions)-1] != want {
287 t.Fatalf("host saw sessions %v, want the built controller's tag path %q", hostExec.sessions, want)
288 }
289 }
290
290 lines GO