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