返回 DeepSeek-Reasonix
tools_test.go
根目录 / internal / browser / tools_test.go
1 package browser
2
3 import (
4 "context"
5 "encoding/json"
6 "reflect"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/tool"
12 )
13
14 const clickArgs = `{"operationId":"op-1","tabId":"t1","documentToken":"doc-1","ref":"e12"}`
15
16 var readOnlyTools = map[string]bool{
17 "browser_tabs": true, "browser_snapshot": true, "browser_screenshot": true, "browser_download": true,
18 }
19
20 func TestNamesMatchTools(t *testing.T) {
21 names := Names()
22 if len(names) != 13 {
23 t.Fatalf("Names() = %d entries, want 13", len(names))
24 }
25 tools := Tools(nil)
26 if len(tools) != len(names) {
27 t.Fatalf("Tools() = %d tools, Names() = %d", len(tools), len(names))
28 }
29 seen := map[string]bool{}
30 for i, tl := range tools {
31 if tl.Name() != names[i] {
32 t.Errorf("tool %d = %q, Names()[%d] = %q", i, tl.Name(), i, names[i])
33 }
34 if seen[tl.Name()] {
35 t.Errorf("duplicate tool %q", tl.Name())
36 }
37 seen[tl.Name()] = true
38 if !strings.HasPrefix(tl.Name(), "browser_") || strings.TrimSpace(tl.Description()) == "" {
39 t.Errorf("tool %q: bad name or empty description", tl.Name())
40 }
41 }
42 }
43
44 func TestReadOnlyPlanModeSnipAndEffectHints(t *testing.T) {
45 for _, tl := range Tools(&fakeExecutor{}) {
46 name := tl.Name()
47 wantRead := readOnlyTools[name]
48 if tl.ReadOnly() != wantRead {
49 t.Errorf("%s ReadOnly = %v, want %v", name, tl.ReadOnly(), wantRead)
50 }
51 if pm, ok := tl.(tool.PlanModeClassifier); !ok || pm.PlanModeSafe() != wantRead {
52 t.Errorf("%s PlanModeSafe: implemented=%v, want %v", name, ok, wantRead)
53 }
54 if _, ok := tl.(tool.ContextualTool); !ok {
55 t.Errorf("%s does not implement ContextualTool", name)
56 }
57 if sh, ok := tl.(tool.SnipHinter); !ok {
58 t.Errorf("%s does not implement SnipHinter", name)
59 } else if h := sh.SnipHint(); h.Head <= 0 || h.Tail <= 0 || h.HeadChars <= 0 || h.TailChars <= 0 {
60 t.Errorf("%s SnipHint = %+v, want positive counts", name, h)
61 }
62 hp, hasHint := tl.(tool.EffectHintProvider)
63 if wantRead {
64 if hasHint {
65 t.Errorf("%s is read-only but implements EffectHintProvider", name)
66 }
67 continue
68 }
69 if !hasHint {
70 t.Errorf("%s is a write but lacks EffectHintProvider", name)
71 continue
72 }
73 h := hp.EffectHint(json.RawMessage(`{"tabId":"t1","operationId":"op-1"}`))
74 if !h.Known || !h.UsesNetwork || h.Destructive || h.ReadOnly || !reflect.DeepEqual(h.Targets, []string{"t1"}) {
75 t.Errorf("%s EffectHint = %+v", name, h)
76 }
77 }
78 open := toolByName(t, nil, "browser_open").(tool.EffectHintProvider)
79 if h := open.EffectHint(json.RawMessage(`{"operationId":"op","url":"https://example.com"}`)); !reflect.DeepEqual(h.Targets, []string{"https://example.com"}) {
80 t.Errorf("browser_open EffectHint targets = %v", h.Targets)
81 }
82 }
83
84 func TestProviderVisibleFollowsExecutor(t *testing.T) {
85 ctx := context.Background()
86 cases := []struct {
87 name string
88 exec Executor
89 want bool
90 }{
91 {"nil", nil, false},
92 {"plain", &fakeExecutor{}, true},
93 {"revoked", gatedExecutor{&fakeExecutor{}, false}, false},
94 {"granted", gatedExecutor{&fakeExecutor{}, true}, true},
95 }
96 for _, c := range cases {
97 for _, tl := range Tools(c.exec) {
98 if got := tl.(tool.ContextualTool).ProviderVisible(ctx); got != c.want {
99 t.Errorf("%s: %s ProviderVisible = %v, want %v", c.name, tl.Name(), got, c.want)
100 }
101 }
102 }
103 _, err := run(t, nil, "browser_tabs", `{}`)
104 if msg, ok := tool.BlockedMessage(err); !ok || !strings.Contains(msg, "no browser") {
105 t.Fatalf("nil executor: err = %v, want blocked no-browser", err)
106 }
107 fake := &fakeExecutor{}
108 _, err = run(t, gatedExecutor{fake, false}, "browser_snapshot", `{"tabId":"t1"}`)
109 if msg, ok := tool.BlockedMessage(err); !ok || !strings.Contains(msg, "grant") {
110 t.Fatalf("revoked grant: err = %v, want blocked no-grant", err)
111 }
112 if len(fake.calls) != 0 {
113 t.Fatalf("revoked grant reached the executor: %v", fake.calls)
114 }
115 }
116
117 func TestSentinelErrorsBecomeBlocked(t *testing.T) {
118 cases := []struct {
119 err error
120 want string
121 }{
122 {ErrStaleReference, "stale reference"},
123 {ErrTakenOver, "took over"},
124 {ErrNoGrant, "grant"},
125 }
126 for _, c := range cases {
127 fake := &fakeExecutor{err: c.err}
128 for _, call := range []struct{ name, args string }{{"browser_click", clickArgs}, {"browser_snapshot", `{"tabId":"t1"}`}} {
129 _, err := run(t, fake, call.name, call.args)
130 msg, ok := tool.BlockedMessage(err)
131 if !ok || !strings.Contains(msg, c.want) {
132 t.Errorf("%s with %v: err = %v, want blocked containing %q", call.name, c.err, err, c.want)
133 }
134 }
135 }
136 }
137
138 func TestUnknownOutcomeIsNonRetryError(t *testing.T) {
139 for _, fake := range []*fakeExecutor{{err: ErrUnknownOutcome}, {act: ActResult{Outcome: OutcomeUnknown}}} {
140 _, err := run(t, fake, "browser_click", clickArgs)
141 if err == nil {
142 t.Fatal("unknown outcome returned no error")
143 }
144 if _, blocked := tool.BlockedMessage(err); blocked {
145 t.Fatalf("unknown outcome rendered as blocked: %v", err)
146 }
147 if s := err.Error(); !strings.Contains(s, "outcome unknown") || !strings.Contains(s, "must not be retried") {
148 t.Fatalf("unknown outcome text = %q", s)
149 }
150 }
151 }
152
153 func TestActOutcomes(t *testing.T) {
154 fake := &fakeExecutor{act: ActResult{Outcome: OutcomeNotExecuted, Reason: "element obscured"}}
155 _, err := run(t, fake, "browser_click", clickArgs)
156 if err == nil || !strings.Contains(err.Error(), "not_executed") || !strings.Contains(err.Error(), "element obscured") {
157 t.Fatalf("not_executed err = %v", err)
158 }
159 fake = &fakeExecutor{act: ActResult{Executed: true, Outcome: OutcomeExecuted, DocumentToken: "doc-2"}}
160 out, err := run(t, fake, "browser_click", clickArgs)
161 if err != nil || !strings.Contains(out, "executed: click e12 on tab t1") || !strings.Contains(out, "documentToken: doc-2") {
162 t.Fatalf("executed out = %q, err = %v", out, err)
163 }
164 }
165
166 func TestActRequestsReachExecutor(t *testing.T) {
167 fake := &fakeExecutor{act: ActResult{Executed: true, Outcome: OutcomeExecuted}}
168 common := `"operationId":"op-1","tabId":"t1","documentToken":"doc-1"`
169 cases := []struct {
170 name string
171 args string
172 want ActRequest
173 }{
174 {"browser_click", clickArgs, ActRequest{Action: ActionClick, Ref: "e12"}},
175 {"browser_type", `{` + common + `,"ref":"e3","text":"hello","submit":true}`, ActRequest{Action: ActionType, Ref: "e3", Text: "hello", Submit: true}},
176 {"browser_press", `{` + common + `,"keys":"Control+a"}`, ActRequest{Action: ActionPress, Keys: "Control+a"}},
177 {"browser_scroll", `{` + common + `,"ref":"e7","deltaY":400}`, ActRequest{Action: ActionScroll, Ref: "e7", DeltaY: 400}},
178 {"browser_select", `{` + common + `,"ref":"e5","options":["a","b"]}`, ActRequest{Action: ActionSelect, Ref: "e5", Options: []string{"a", "b"}}},
179 {"browser_upload", `{` + common + `,"ref":"e9","files":["/tmp/x.txt"]}`, ActRequest{Action: ActionUpload, Ref: "e9", Files: []string{"/tmp/x.txt"}}},
180 }
181 for i, c := range cases {
182 out, err := run(t, fake, c.name, c.args)
183 if err != nil || !strings.HasPrefix(out, "executed: ") {
184 t.Fatalf("%s: out = %q, err = %v", c.name, out, err)
185 }
186 want := c.want
187 want.OperationID, want.TabID, want.DocumentToken = "op-1", "t1", "doc-1"
188 if !reflect.DeepEqual(fake.acts[i], want) {
189 t.Fatalf("%s: Act got %+v, want %+v", c.name, fake.acts[i], want)
190 }
191 }
192 }
193
194 func TestArgumentValidation(t *testing.T) {
195 fake := &fakeExecutor{act: ActResult{Executed: true}}
196 common := `"operationId":"op","tabId":"t1","documentToken":"d"`
197 cases := []struct{ name, args, want string }{
198 {"browser_click", `{"operationId":"bad id!","tabId":"t1","documentToken":"d","ref":"e1"}`, "operationId"},
199 {"browser_click", `{"operationId":"","tabId":"t1","documentToken":"d","ref":"e1"}`, "operationId"},
200 {"browser_click", `{"operationId":"` + strings.Repeat("a", 101) + `","tabId":"t1","documentToken":"d","ref":"e1"}`, "operationId"},
201 {"browser_click", `{` + common + `}`, "ref is required"},
202 {"browser_type", `{` + common + `,"text":"x"}`, "ref is required"},
203 {"browser_select", `{` + common + `,"options":["a"]}`, "ref is required"},
204 {"browser_upload", `{` + common + `,"files":["a"]}`, "ref is required"},
205 {"browser_click", `{"operationId":"op","tabId":"t1","ref":"e1"}`, "documentToken is required"},
206 {"browser_click", `{"operationId":"op","documentToken":"d","ref":"e1"}`, "tabId is required"},
207 {"browser_click", `{` + common + `,"ref":"e1","text":"x"}`, `unknown field "text"`},
208 {"browser_type", `{` + common + `,"ref":"e1","text":""}`, "text is required"},
209 {"browser_press", `{` + common + `,"keys":" "}`, "keys is required"},
210 {"browser_scroll", `{` + common + `}`, "deltaX or deltaY"},
211 {"browser_select", `{` + common + `,"ref":"e1","options":[]}`, "options must list"},
212 {"browser_upload", `{` + common + `,"ref":"e1","files":[""]}`, "files must not contain"},
213 {"browser_navigate", `{"operationId":"op","tabId":"t1","action":"jump"}`, "action must be one of"},
214 {"browser_navigate", `{"operationId":"op","tabId":"t1","action":"url"}`, "url is required when action is url"},
215 {"browser_navigate", `{"operationId":"op","tabId":"t1","action":"back","url":"https://x"}`, "url is only accepted"},
216 {"browser_open", `{"operationId":"op"}`, "url is required"},
217 {"browser_open", `{"operationId":"op","url":"https://x","extra":1}`, "unknown field"},
218 {"browser_close", `{"tabId":"t1"}`, "operationId"},
219 {"browser_snapshot", `{}`, "tabId is required"},
220 {"browser_download", `{"tabId":"t1","waitSeconds":301}`, "waitSeconds"},
221 {"browser_tabs", `{"tabId":"t1"}`, "unknown field"},
222 }
223 for _, c := range cases {
224 _, err := run(t, fake, c.name, c.args)
225 if err == nil || !strings.Contains(err.Error(), c.want) {
226 t.Errorf("%s %s: err = %v, want containing %q", c.name, c.args, err, c.want)
227 }
228 if _, blocked := tool.BlockedMessage(err); blocked {
229 t.Errorf("%s %s: validation rendered as blocked", c.name, c.args)
230 }
231 }
232 if len(fake.calls) != 0 {
233 t.Fatalf("invalid args reached the executor: %v", fake.calls)
234 }
235 }
236
237 func TestReadResults(t *testing.T) {
238 fake := &fakeExecutor{
239 tabs: []Tab{{ID: "t1", URL: "https://a.example", Title: "A", Loading: true}, {ID: "t2", URL: "https://b.example", Temporary: true}},
240 snapshot: Snapshot{DocumentToken: "doc-7", URL: "https://a.example", Title: "A", Tree: "document\n button \"Go\" ref=e1", Refs: 1},
241 downloads: []Download{{ID: "d1", URL: "https://a.example/f.zip", Path: "/tmp/f.zip", State: "completed", Bytes: 12}},
242 }
243 out, err := run(t, fake, "browser_tabs", ``)
244 if err != nil || !strings.Contains(out, "2 tab(s)") || !strings.Contains(out, `tab t1: https://a.example "A" [loading]`) || !strings.Contains(out, "tab t2: https://b.example [temporary]") {
245 t.Fatalf("tabs out = %q, err = %v", out, err)
246 }
247 out, err = run(t, fake, "browser_snapshot", `{"tabId":"t1","selector":"main"}`)
248 if err != nil || !strings.HasPrefix(out, "documentToken: doc-7\n") || !strings.Contains(out, "refs: 1") || !strings.Contains(out, "ref=e1") {
249 t.Fatalf("snapshot out = %q, err = %v", out, err)
250 }
251 if !reflect.DeepEqual(fake.snaps, []SnapshotRequest{{TabID: "t1", Selector: "main"}}) {
252 t.Fatalf("snapshot requests = %+v", fake.snaps)
253 }
254 out, err = run(t, fake, "browser_download", `{"tabId":"t1","waitSeconds":5}`)
255 if err != nil || !strings.Contains(out, "download d1: completed https://a.example/f.zip -> /tmp/f.zip (12 bytes)") {
256 t.Fatalf("download out = %q, err = %v", out, err)
257 }
258 if !reflect.DeepEqual(fake.dls, []DownloadsRequest{{TabID: "t1", WaitFor: 5 * time.Second}}) {
259 t.Fatalf("download requests = %+v", fake.dls)
260 }
261 if out, err := run(t, &fakeExecutor{}, "browser_tabs", `{}`); err != nil || !strings.Contains(out, "no tabs are open") {
262 t.Fatalf("empty tabs out = %q, err = %v", out, err)
263 }
264 }
265
266 func TestTabWrites(t *testing.T) {
267 fake := &fakeExecutor{}
268 out, err := run(t, fake, "browser_open", `{"operationId":"op-1","url":"https://c.example","temporary":true}`)
269 if err != nil || !strings.Contains(out, "opened tab t-new: https://c.example [temporary]") || !strings.Contains(out, "browser_snapshot") {
270 t.Fatalf("open out = %q, err = %v", out, err)
271 }
272 if !reflect.DeepEqual(fake.opens, []OpenRequest{{OperationID: "op-1", URL: "https://c.example", Temporary: true}}) {
273 t.Fatalf("open requests = %+v", fake.opens)
274 }
275 out, err = run(t, fake, "browser_navigate", `{"operationId":"op-2","tabId":"t1","action":"reload"}`)
276 if err != nil || !strings.Contains(out, "navigated (reload)") || !strings.Contains(out, "now invalid") {
277 t.Fatalf("navigate out = %q, err = %v", out, err)
278 }
279 if !reflect.DeepEqual(fake.navs, []NavigateRequest{{OperationID: "op-2", TabID: "t1", Action: NavigateReload}}) {
280 t.Fatalf("navigate requests = %+v", fake.navs)
281 }
282 out, err = run(t, fake, "browser_close", `{"operationId":"op-3","tabId":"t2"}`)
283 if len(fake.closes) != 1 || fake.closes[0].OperationID != "op-3" {
284 t.Fatalf("close operationId lost: %+v", fake.closes)
285 }
286 if err != nil || out != "closed tab t2" || !reflect.DeepEqual(fake.closed, []string{"t2"}) {
287 t.Fatalf("close out = %q, err = %v, closed = %v", out, err, fake.closed)
288 }
289 }
290
290 lines GO