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