| 1 | package browser |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "maps" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | func openTool(exec Executor) tool.Tool { |
| 15 | return writeTool{base: base{exec: exec, name: "browser_open", |
| 16 | description: "Open a new tab at a URL for this task and return its tabId. Tabs share the task's login partition unless temporary is set. Follow with browser_snapshot before acting on the page.", |
| 17 | schema: objectSchema([]string{"operationId", "url"}, operationIDProp(), str("url", "Absolute URL to open."), boolean("temporary", "Open in an in-memory partition that shares no cookies or logins and is discarded when the tab closes.")), |
| 18 | snip: shortSnip, |
| 19 | }, run: runOpen} |
| 20 | } |
| 21 | |
| 22 | func runOpen(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 23 | var p struct { |
| 24 | OperationID string `json:"operationId"` |
| 25 | URL string `json:"url"` |
| 26 | Temporary bool `json:"temporary"` |
| 27 | } |
| 28 | if err := decode(args, &p); err != nil { |
| 29 | return "", err |
| 30 | } |
| 31 | if err := requireOperationID(p.OperationID); err != nil { |
| 32 | return "", err |
| 33 | } |
| 34 | if strings.TrimSpace(p.URL) == "" { |
| 35 | return "", fmt.Errorf("url is required") |
| 36 | } |
| 37 | tab, err := exec.Open(ctx, OpenRequest{OperationID: p.OperationID, URL: p.URL, Temporary: p.Temporary}) |
| 38 | if err != nil { |
| 39 | return "", translate(err, "browser_open "+p.URL) |
| 40 | } |
| 41 | return "opened " + formatTab(tab) + "\nTake a browser_snapshot before acting on it.", nil |
| 42 | } |
| 43 | |
| 44 | func navigateTool(exec Executor) tool.Tool { |
| 45 | return writeTool{base: base{exec: exec, name: "browser_navigate", |
| 46 | description: "Navigate a tab: go to a URL, or move back, forward, or reload. Navigation invalidates every earlier ref and documentToken for the tab, so take a new browser_snapshot afterwards.", |
| 47 | schema: objectSchema([]string{"action", "operationId", "tabId"}, operationIDProp(), tabIDProp(), |
| 48 | enum("action", "url goes to url; back, forward, and reload move through the tab's history.", NavigateURL, NavigateBack, NavigateForward, NavigateReload), |
| 49 | str("url", "Absolute URL; required when action is url and not accepted otherwise.")), |
| 50 | snip: shortSnip, |
| 51 | }, run: runNavigate} |
| 52 | } |
| 53 | |
| 54 | func runNavigate(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 55 | var p struct { |
| 56 | OperationID string `json:"operationId"` |
| 57 | TabID string `json:"tabId"` |
| 58 | Action string `json:"action"` |
| 59 | URL string `json:"url"` |
| 60 | } |
| 61 | if err := decode(args, &p); err != nil { |
| 62 | return "", err |
| 63 | } |
| 64 | if err := requireOperationID(p.OperationID); err != nil { |
| 65 | return "", err |
| 66 | } |
| 67 | if err := requireTab(p.TabID); err != nil { |
| 68 | return "", err |
| 69 | } |
| 70 | switch p.Action { |
| 71 | case NavigateURL: |
| 72 | if strings.TrimSpace(p.URL) == "" { |
| 73 | return "", fmt.Errorf("url is required when action is url") |
| 74 | } |
| 75 | case NavigateBack, NavigateForward, NavigateReload: |
| 76 | if p.URL != "" { |
| 77 | return "", fmt.Errorf("url is only accepted when action is url") |
| 78 | } |
| 79 | default: |
| 80 | return "", fmt.Errorf("action must be one of url, back, forward, reload") |
| 81 | } |
| 82 | tab, err := exec.Navigate(ctx, NavigateRequest{OperationID: p.OperationID, TabID: p.TabID, URL: p.URL, Action: p.Action}) |
| 83 | if err != nil { |
| 84 | return "", translate(err, "browser_navigate "+p.Action+" on tab "+p.TabID) |
| 85 | } |
| 86 | return fmt.Sprintf("navigated (%s): %s\nEarlier refs and documentTokens for this tab are now invalid; take a new browser_snapshot.", p.Action, formatTab(tab)), nil |
| 87 | } |
| 88 | |
| 89 | func closeTool(exec Executor) tool.Tool { |
| 90 | return writeTool{base: base{exec: exec, name: "browser_close", |
| 91 | description: "Close a tab. A temporary tab's partition is discarded with its last tab.", |
| 92 | schema: objectSchema([]string{"operationId", "tabId"}, operationIDProp(), tabIDProp()), |
| 93 | snip: shortSnip, |
| 94 | }, run: runClose} |
| 95 | } |
| 96 | |
| 97 | func runClose(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 98 | var p struct { |
| 99 | OperationID string `json:"operationId"` |
| 100 | TabID string `json:"tabId"` |
| 101 | } |
| 102 | if err := decode(args, &p); err != nil { |
| 103 | return "", err |
| 104 | } |
| 105 | if err := requireOperationID(p.OperationID); err != nil { |
| 106 | return "", err |
| 107 | } |
| 108 | if err := requireTab(p.TabID); err != nil { |
| 109 | return "", err |
| 110 | } |
| 111 | if err := exec.Close(ctx, CloseRequest{OperationID: p.OperationID, TabID: p.TabID}); err != nil { |
| 112 | return "", translate(err, "browser_close tab "+p.TabID) |
| 113 | } |
| 114 | return "closed tab " + p.TabID, nil |
| 115 | } |
| 116 | |
| 117 | // actArgs is the union of every reference-bound write's arguments; decodeAct |
| 118 | // still rejects fields the particular tool's schema does not declare. |
| 119 | type actArgs struct { |
| 120 | OperationID string `json:"operationId"` |
| 121 | TabID string `json:"tabId"` |
| 122 | DocumentToken string `json:"documentToken"` |
| 123 | Ref string `json:"ref"` |
| 124 | Text string `json:"text"` |
| 125 | Keys string `json:"keys"` |
| 126 | Options []string `json:"options"` |
| 127 | Files []string `json:"files"` |
| 128 | Submit bool `json:"submit"` |
| 129 | DeltaX int `json:"deltaX"` |
| 130 | DeltaY int `json:"deltaY"` |
| 131 | } |
| 132 | |
| 133 | // actSpec describes one reference-bound write beyond the operationId, tabId, |
| 134 | // and documentToken every such write carries. |
| 135 | type actSpec struct { |
| 136 | name string |
| 137 | action string |
| 138 | description string |
| 139 | required []string |
| 140 | props []property |
| 141 | validate func(actArgs) error |
| 142 | describe func(actArgs) string |
| 143 | } |
| 144 | |
| 145 | func actTool(exec Executor, spec actSpec) tool.Tool { |
| 146 | required := append([]string{"operationId", "tabId", "documentToken"}, spec.required...) |
| 147 | props := append([]property{operationIDProp(), tabIDProp(), documentTokenProp()}, spec.props...) |
| 148 | allowed := make(map[string]bool, len(props)) |
| 149 | for _, p := range props { |
| 150 | allowed[p.name] = true |
| 151 | } |
| 152 | run := func(ctx context.Context, exec Executor, args json.RawMessage) (string, error) { |
| 153 | a, err := decodeAct(args, allowed) |
| 154 | if err != nil { |
| 155 | return "", err |
| 156 | } |
| 157 | if err := spec.validate(a); err != nil { |
| 158 | return "", err |
| 159 | } |
| 160 | what := spec.describe(a) + " on tab " + a.TabID |
| 161 | res, err := exec.Act(ctx, ActRequest{ |
| 162 | OperationID: a.OperationID, TabID: a.TabID, DocumentToken: a.DocumentToken, Action: spec.action, |
| 163 | Ref: a.Ref, Text: a.Text, Keys: a.Keys, Options: a.Options, Files: a.Files, Submit: a.Submit, |
| 164 | DeltaX: a.DeltaX, DeltaY: a.DeltaY, |
| 165 | }) |
| 166 | if err != nil { |
| 167 | return "", translate(err, what) |
| 168 | } |
| 169 | return actOutcome(res, what) |
| 170 | } |
| 171 | return writeTool{base: base{exec: exec, name: spec.name, description: spec.description, schema: objectSchema(required, props...), snip: shortSnip}, run: run} |
| 172 | } |
| 173 | |
| 174 | func decodeAct(args json.RawMessage, allowed map[string]bool) (actArgs, error) { |
| 175 | var raw map[string]json.RawMessage |
| 176 | if err := decode(args, &raw); err != nil { |
| 177 | return actArgs{}, err |
| 178 | } |
| 179 | for _, name := range slices.Sorted(maps.Keys(raw)) { |
| 180 | if !allowed[name] { |
| 181 | return actArgs{}, fmt.Errorf("invalid args: unknown field %q", name) |
| 182 | } |
| 183 | } |
| 184 | var a actArgs |
| 185 | if err := json.Unmarshal(args, &a); err != nil { |
| 186 | return actArgs{}, fmt.Errorf("invalid args: %w", err) |
| 187 | } |
| 188 | if err := requireOperationID(a.OperationID); err != nil { |
| 189 | return actArgs{}, err |
| 190 | } |
| 191 | if err := requireTab(a.TabID); err != nil { |
| 192 | return actArgs{}, err |
| 193 | } |
| 194 | if err := requireDocumentToken(a.DocumentToken); err != nil { |
| 195 | return actArgs{}, err |
| 196 | } |
| 197 | return a, nil |
| 198 | } |
| 199 | |
| 200 | func actOutcome(res ActResult, what string) (string, error) { |
| 201 | switch { |
| 202 | case res.Outcome == OutcomeUnknown: |
| 203 | return "", unknownOutcome(what) |
| 204 | case res.Executed || res.Outcome == OutcomeExecuted: |
| 205 | s := "executed: " + what |
| 206 | if res.DocumentToken != "" { |
| 207 | s += "\ndocumentToken: " + res.DocumentToken |
| 208 | } |
| 209 | return s, nil |
| 210 | } |
| 211 | return "", notExecuted(what, res.Reason) |
| 212 | } |
| 213 | |
| 214 | func clickTool(exec Executor) tool.Tool { |
| 215 | return actTool(exec, actSpec{name: "browser_click", action: ActionClick, |
| 216 | description: "Click an element by its snapshot ref with trusted mouse events at its centre. Requires the documentToken of the snapshot the ref came from and a fresh operationId; if the page changed since, the call is blocked as stale and you must snapshot again.", |
| 217 | required: []string{"ref"}, |
| 218 | props: []property{refProp("Element ref from browser_snapshot, for example e12.")}, |
| 219 | validate: func(a actArgs) error { return requireRef(a.Ref) }, |
| 220 | describe: func(a actArgs) string { return "click " + a.Ref }, |
| 221 | }) |
| 222 | } |
| 223 | |
| 224 | func typeTool(exec Executor) tool.Tool { |
| 225 | return actTool(exec, actSpec{name: "browser_type", action: ActionType, |
| 226 | description: "Type text into an element by its snapshot ref using trusted key events, so controlled inputs and custom widgets behave as they would for a user. Set submit to press Enter afterwards. Never type credentials: login pages are handed over to the user.", |
| 227 | required: []string{"ref", "text"}, |
| 228 | props: []property{refProp("Element ref from browser_snapshot that accepts text."), str("text", "Text to type."), boolean("submit", "Press Enter after typing.")}, |
| 229 | validate: func(a actArgs) error { |
| 230 | if err := requireRef(a.Ref); err != nil { |
| 231 | return err |
| 232 | } |
| 233 | if a.Text == "" && !a.Submit { |
| 234 | return fmt.Errorf("text is required unless submit is set") |
| 235 | } |
| 236 | return nil |
| 237 | }, |
| 238 | describe: func(a actArgs) string { return fmt.Sprintf("type %d character(s) into %s", len(a.Text), a.Ref) }, |
| 239 | }) |
| 240 | } |
| 241 | |
| 242 | func pressTool(exec Executor) tool.Tool { |
| 243 | return actTool(exec, actSpec{name: "browser_press", action: ActionPress, |
| 244 | description: "Press a key or chord (for example Enter, Escape, Tab, Control+a) as trusted key events. Give ref to focus an element first; omit it to press on the active element.", |
| 245 | required: []string{"keys"}, |
| 246 | props: []property{str("keys", "Key name or '+'-joined chord, such as Enter or Control+a."), refProp("Optional element ref from browser_snapshot to focus first.")}, |
| 247 | validate: func(a actArgs) error { |
| 248 | if strings.TrimSpace(a.Keys) == "" { |
| 249 | return fmt.Errorf("keys is required") |
| 250 | } |
| 251 | return nil |
| 252 | }, |
| 253 | describe: func(a actArgs) string { return "press " + a.Keys }, |
| 254 | }) |
| 255 | } |
| 256 | |
| 257 | func scrollTool(exec Executor) tool.Tool { |
| 258 | return actTool(exec, actSpec{name: "browser_scroll", action: ActionScroll, |
| 259 | description: "Scroll the viewport, or the element named by ref, by deltaX and deltaY pixels; positive values scroll right and down. Take a new browser_snapshot afterwards to see newly revealed elements.", |
| 260 | props: []property{refProp("Optional scrollable element ref from browser_snapshot; omit to scroll the viewport."), integer("deltaX", "Horizontal pixels; positive scrolls right."), integer("deltaY", "Vertical pixels; positive scrolls down.")}, |
| 261 | validate: func(a actArgs) error { |
| 262 | if a.DeltaX == 0 && a.DeltaY == 0 { |
| 263 | return fmt.Errorf("deltaX or deltaY must be non-zero") |
| 264 | } |
| 265 | return nil |
| 266 | }, |
| 267 | describe: func(a actArgs) string { return fmt.Sprintf("scroll by (%d, %d)", a.DeltaX, a.DeltaY) }, |
| 268 | }) |
| 269 | } |
| 270 | |
| 271 | func selectTool(exec Executor) tool.Tool { |
| 272 | return actTool(exec, actSpec{name: "browser_select", action: ActionSelect, |
| 273 | description: "Choose options in a select element by its snapshot ref. Give option values or visible labels; more than one only for a multi-select.", |
| 274 | required: []string{"ref", "options"}, |
| 275 | props: []property{refProp("Select element ref from browser_snapshot."), strList("options", "Option values or labels to select.")}, |
| 276 | validate: func(a actArgs) error { |
| 277 | if err := requireRef(a.Ref); err != nil { |
| 278 | return err |
| 279 | } |
| 280 | return requireStrings("options", a.Options) |
| 281 | }, |
| 282 | describe: func(a actArgs) string { return fmt.Sprintf("select %s in %s", strings.Join(a.Options, ", "), a.Ref) }, |
| 283 | }) |
| 284 | } |
| 285 | |
| 286 | func uploadTool(exec Executor) tool.Tool { |
| 287 | return actTool(exec, actSpec{name: "browser_upload", action: ActionUpload, |
| 288 | description: "Attach files to a file input by its snapshot ref. Only files this task owns can be attached; a remote task stages them through the task's temporary directory first.", |
| 289 | required: []string{"ref", "files"}, |
| 290 | props: []property{refProp("File input ref from browser_snapshot."), strList("files", "Paths of task-owned files to attach.")}, |
| 291 | validate: func(a actArgs) error { |
| 292 | if err := requireRef(a.Ref); err != nil { |
| 293 | return err |
| 294 | } |
| 295 | return requireStrings("files", a.Files) |
| 296 | }, |
| 297 | describe: func(a actArgs) string { return fmt.Sprintf("upload %d file(s) to %s", len(a.Files), a.Ref) }, |
| 298 | }) |
| 299 | } |
| 300 |