├── .gitignore ├── LICENSE ├── README.md ├── cohttp ├── dune ├── webdriver_cohttp_async.ml ├── webdriver_cohttp_async.mli ├── webdriver_cohttp_lwt_unix.ml └── webdriver_cohttp_lwt_unix.mli ├── dune-project ├── examples ├── dune ├── github_visit.ml └── google_search.ml ├── makefile ├── src ├── dune ├── webdriver.ml ├── webdriver.mli └── webdriver_sig.ml ├── test ├── a.html ├── b.html ├── dune ├── test_any.ml ├── test_async.ml └── test_lwt.ml ├── vendor ├── get_chromedriver.sh ├── get_geckodriver.sh ├── get_selenium.sh └── run_selenium.sh ├── webdriver.opam ├── webdriver_cohttp-async.opam └── webdriver_cohttp-lwt-unix.opam /.gitignore: -------------------------------------------------------------------------------- 1 | *.annot 2 | *.cmo 3 | *.cma 4 | *.cmi 5 | *.a 6 | *.o 7 | *.cmx 8 | *.cmxs 9 | *.cmxa 10 | 11 | .merlin 12 | *.install 13 | *.coverage 14 | *.sw[lmnop] 15 | 16 | _build/ 17 | _doc/ 18 | _opam/ 19 | 20 | odoc.css 21 | 22 | vendor/chromedriver 23 | vendor/geckodriver 24 | vendor/*.jar 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Arthur Wendling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WebDriver is a [W3C specification] to remote control a web browser. This allow 2 | you to simulate and test user interactions on your website in real life 3 | conditions, with javascript enabled, on as many browsers and operating systems 4 | you can get your hands on. 5 | 6 | You can use this library to: 7 | 8 | - Create and manage browser windows and cookies 9 | - Visit urls and inspect the DOM properties of HTML elements in the document 10 | - Click and type to interact with the page, then check that it produced the 11 | desired outcome. 12 | 13 | **[Online Documentation]** 14 | 15 | You will need to download and run a WebDriver compatible server of your 16 | favorite browser: [Chrome], [Firefox], [Internet Explorer], [Microsoft Edge], 17 | [Opera], [Safari], and optionally with the help of [Selenium]. 18 | 19 | As a quick example, the `geckodriver` for Firefox listens on 20 | `http://127.0.0.1:4444` by default: 21 | 22 | ```shell 23 | $ ./geckodriver -v 24 | ... webdriver::httpapi DEBUG Creating routes 25 | ... geckodriver DEBUG Listening on 127.0.0.1:4444 26 | ``` 27 | 28 | We can connect to this driver and visit this github url to fetch the number 29 | of commits with: 30 | 31 | ```ocaml 32 | module W = Webdriver_cohttp_lwt_unix 33 | open W.Infix 34 | 35 | let test = 36 | let* () = W.goto "https://github.com/art-w/ocaml-webdriver" in 37 | let* commits = 38 | W.find_first 39 | `xpath 40 | "//a[@href='/art-w/ocaml-webdriver/commits/master']//strong" 41 | in 42 | let* nb = W.text commits in 43 | let nb = int_of_string nb in 44 | Printf.printf "number of commits = %i\n%!" nb ; 45 | W.return () 46 | 47 | let host = "http://127.0.0.1:4444" 48 | let () = Lwt_main.run (W.run ~host Capabilities.firefox_headless test) 49 | ``` 50 | 51 | See the `examples` and `test` folders for more interactions. 52 | 53 | [W3C specification]: https://www.w3.org/TR/webdriver/ 54 | [Online Documentation]: https://art-w.github.io/ocaml-webdriver 55 | [Chrome]: https://chromedriver.chromium.org/ 56 | [Firefox]: https://github.com/mozilla/geckodriver 57 | [Internet Explorer]: https://github.com/SeleniumHQ/selenium/wiki/InternetExplorerDriver 58 | [Microsoft Edge]: https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/ 59 | [Opera]: https://github.com/operasoftware/operachromiumdriver 60 | [Safari]: https://developer.apple.com/documentation/webkit/testing_with_webdriver_in_safari 61 | [Selenium]: https://www.selenium.dev/ 62 | -------------------------------------------------------------------------------- /cohttp/dune: -------------------------------------------------------------------------------- 1 | (library 2 | (name webdriver_cohttp_lwt_unix) 3 | (public_name webdriver_cohttp-lwt-unix) 4 | (modules webdriver_cohttp_lwt_unix) 5 | (libraries webdriver cohttp-lwt-unix)) 6 | 7 | (library 8 | (name webdriver_cohttp_async) 9 | (public_name webdriver_cohttp-async) 10 | (modules webdriver_cohttp_async) 11 | (libraries webdriver async cohttp-async)) 12 | -------------------------------------------------------------------------------- /cohttp/webdriver_cohttp_async.ml: -------------------------------------------------------------------------------- 1 | module Client = struct 2 | open Async 3 | open Cohttp_async 4 | 5 | type 'a t = 'a Deferred.t 6 | 7 | let return = Deferred.return 8 | let map f t = Deferred.map ~f t 9 | let bind f m = Deferred.( >>= ) m f 10 | 11 | let ( let* ) m fn = Deferred.( >>= ) m fn 12 | 13 | let fail e = raise e 14 | let catch f handle = 15 | let* x = Async.try_with f in 16 | match x with 17 | | Ok x -> return x 18 | | Error e -> handle e 19 | 20 | let get url = 21 | let* _resp, body = Client.get (Uri.of_string url) in 22 | Cohttp_async.Body.to_string body 23 | 24 | let post url body = 25 | let* _resp, body = 26 | Client.post 27 | ~headers:(Cohttp.Header.init_with "Content-Type" "application/json") 28 | ~body:(Cohttp_async__Body.of_string body) 29 | (Uri.of_string url) 30 | in 31 | Cohttp_async.Body.to_string body 32 | 33 | let delete url = 34 | let* _resp, body = Client.delete (Uri.of_string url) in 35 | Cohttp_async.Body.to_string body 36 | end 37 | 38 | module W = Webdriver.Make (Client) 39 | include W 40 | -------------------------------------------------------------------------------- /cohttp/webdriver_cohttp_async.mli: -------------------------------------------------------------------------------- 1 | include Webdriver.S with type 'a io = 'a Async.Deferred.t 2 | -------------------------------------------------------------------------------- /cohttp/webdriver_cohttp_lwt_unix.ml: -------------------------------------------------------------------------------- 1 | module Client = struct 2 | open Cohttp_lwt_unix 3 | 4 | type 'a t = 'a Lwt.t 5 | 6 | let return = Lwt.return 7 | let map = Lwt.map 8 | let bind f m = Lwt.( >>= ) m f 9 | let ( let* ) m fn = Lwt.( >>= ) m fn 10 | 11 | let fail = Lwt.fail 12 | let catch = Lwt.catch 13 | 14 | let get url = 15 | let* _resp, body = Client.get (Uri.of_string url) in 16 | Cohttp_lwt.Body.to_string body 17 | 18 | let post url body = 19 | let* _resp, body = 20 | Client.post 21 | ~chunked:false 22 | ~headers:(Cohttp.Header.init_with "Content-Type" "application/json") 23 | ~body:(Cohttp_lwt__Body.of_string body) 24 | (Uri.of_string url) 25 | in 26 | Cohttp_lwt.Body.to_string body 27 | 28 | let delete url = 29 | let* _resp, body = Client.delete (Uri.of_string url) in 30 | Cohttp_lwt.Body.to_string body 31 | end 32 | 33 | module W = Webdriver.Make (Client) 34 | include W 35 | -------------------------------------------------------------------------------- /cohttp/webdriver_cohttp_lwt_unix.mli: -------------------------------------------------------------------------------- 1 | include Webdriver.S with type 'a io = 'a Lwt.t 2 | -------------------------------------------------------------------------------- /dune-project: -------------------------------------------------------------------------------- 1 | (lang dune 2.7) 2 | (generate_opam_files true) 3 | 4 | (name webdriver) 5 | (source (github art-w/ocaml-webdriver)) 6 | (license MIT) 7 | (authors "Arthur Wendling") 8 | (maintainers "art.wendling@gmail.com") 9 | (version 0.1) 10 | 11 | (package 12 | (name webdriver) 13 | (synopsis "Remote control interface to web browsers") 14 | (depends (ocaml (>= "4.08")) yojson base64)) 15 | 16 | (package 17 | (name webdriver_cohttp-lwt-unix) 18 | (synopsis "Remote control interface to web browsers (using Cohttp_lwt_unix)") 19 | (depends webdriver cohttp-lwt-unix)) 20 | 21 | (package 22 | (name webdriver_cohttp-async) 23 | (synopsis "Remote control interface to web browsers (using Cohttp_async)") 24 | (depends webdriver cohttp-async)) 25 | -------------------------------------------------------------------------------- /examples/dune: -------------------------------------------------------------------------------- 1 | (executable 2 | (name github_visit) 3 | (modules github_visit) 4 | (libraries webdriver_cohttp-lwt-unix)) 5 | 6 | (executable 7 | (name google_search) 8 | (modules google_search) 9 | (libraries webdriver_cohttp-lwt-unix)) 10 | -------------------------------------------------------------------------------- /examples/github_visit.ml: -------------------------------------------------------------------------------- 1 | open Webdriver_cohttp_lwt_unix 2 | open Infix 3 | 4 | let test = 5 | let* () = goto "https://github.com/art-w/ocaml-webdriver" in 6 | let* commits = 7 | find_first 8 | `xpath 9 | "//a[@href='/art-w/ocaml-webdriver/commits/master']//strong" 10 | in 11 | let* nb = text commits in 12 | let nb = int_of_string nb in 13 | Printf.printf "number of commits = %i\n%!" nb ; 14 | return () 15 | 16 | let host = "http://127.0.0.1:4444" 17 | let () = 18 | try Lwt_main.run (run ~host Capabilities.firefox_headless test) 19 | with Webdriver e -> 20 | Printf.fprintf stderr "[FAIL] Webdriver error: %s\n%!" (Error.to_string e) ; 21 | Printexc.print_backtrace stderr ; 22 | Printf.fprintf stderr "\n%!" 23 | -------------------------------------------------------------------------------- /examples/google_search.ml: -------------------------------------------------------------------------------- 1 | open Webdriver_cohttp_lwt_unix 2 | open Webdriver_cohttp_lwt_unix.Infix 3 | 4 | let sleep dt = 5 | Printf.printf ".%!" ; 6 | lift (Lwt_unix.sleep dt) 7 | 8 | let rec wait cmd = 9 | Error.catch (fun () -> cmd) 10 | ~errors:[`no_such_element] 11 | (fun _ -> sleep 0.1 >>= fun () -> wait cmd) 12 | 13 | let wait_for_error cmd = 14 | Error.catch 15 | (fun () -> 16 | let rec loop () = 17 | let* _ = cmd in 18 | let* () = sleep 0.1 in 19 | loop () 20 | in 21 | loop ()) 22 | ~errors:[`no_such_element] 23 | (fun _ -> return ()) 24 | 25 | 26 | let rec wait_for_condition fn = 27 | let* b = fn () in 28 | if b 29 | then return () 30 | else begin 31 | let* () = sleep 0.1 in 32 | wait_for_condition fn 33 | end 34 | 35 | let rec list_iter f = function 36 | | [] -> return () 37 | | x :: xs -> 38 | let* () = f x in 39 | list_iter f xs 40 | 41 | (* 42 | let accept_cookies = 43 | let* frame = wait (find_first `tag_name "iframe") in 44 | let* () = switch_to_frame (`elt frame) in 45 | let* btn = wait (find_first `css "#introAgreeButton") in 46 | let* () = click btn in 47 | let* () = switch_to_frame `top in 48 | let* () = wait_for_error (find_first `tag_name "iframe") in 49 | Cookie.all 50 | *) 51 | 52 | let accept_cookies = 53 | let* popup = wait (find_first `css "#xe7COe") in 54 | let* btn = wait (find_first `css "#L2AGLb") in 55 | let* disp = is_displayed btn in 56 | assert (disp = true) ; 57 | let* () = click btn in 58 | let* () = 59 | wait_for_condition 60 | (fun () -> 61 | let* disp = is_displayed btn in 62 | let+ visible = css popup "display" in 63 | let is_visible = visible = "none" in 64 | assert (is_visible = disp) ; 65 | disp) 66 | in 67 | Cookie.all 68 | 69 | let search query = 70 | let* input = find_first `css "input[name='q']" in 71 | let* () = send_keys input (query ^ Key.enter) in 72 | let* results = wait (find_first `xpath "//div[@id='search']") in 73 | let* links = find_all ~from:results `tag_name "h3" in 74 | Printf.printf "found %i results\n%!" (List.length links) ; 75 | list_iter 76 | (fun link -> 77 | let* txt = text link in 78 | Printf.printf "- %s\n%!" txt ; 79 | return ()) 80 | links 81 | 82 | let search_without_cookies = 83 | let* () = goto "https://google.com" in 84 | let* cookies = accept_cookies in 85 | let* () = search "webdriver" in 86 | return cookies 87 | 88 | let search_with_cookies cookies = 89 | let* () = goto "https://google.com" in 90 | let* () = list_iter Cookie.add cookies in 91 | let* () = goto "https://google.com" in 92 | search "webdriver" 93 | 94 | 95 | let host = "http://localhost:4444/wd/hub" 96 | let run cmd = Lwt_main.run (run ~host Capabilities.firefox_headless cmd) 97 | 98 | let () = 99 | Printf.printf "### Accept the cookies\n%!" ; 100 | let cookies = run search_without_cookies in 101 | Printf.printf "\n%!" ; 102 | Printf.printf "### Reuse the %i cookies\n%!" (List.length cookies) ; 103 | run (search_with_cookies cookies) 104 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: test_lwt 3 | test_lwt: 4 | dune exec --force test/test_lwt.exe 5 | 6 | .PHONY: test_async 7 | test_async: 8 | dune exec --force test/test_async.exe 9 | 10 | .PHONY: selenium 11 | selenium: 12 | cd vendor && ./run_selenium.sh 13 | 14 | .PHONY: doc 15 | doc: odoc.css 16 | rm -rf _doc 17 | dune build @doc 18 | cp -r _build/default/_doc _doc 19 | [ -f odoc.css ] && cp -f odoc.css _doc/_html/odoc.css 20 | 21 | .PHONY: clean 22 | clean: 23 | dune clean 24 | rm -rf _doc 25 | 26 | .PHONY: width80 27 | width80: 28 | find . -name '*.ml' | grep -v _build | xargs grep --color -E -e '^.{80,}| $$' \ 29 | || echo 'OK' 30 | 31 | .PHONY: github_visit 32 | github_visit: 33 | dune exec --force examples/github_visit.exe 34 | 35 | .PHONY: google_search 36 | google_search: 37 | dune exec --force examples/google_search.exe 38 | 39 | -------------------------------------------------------------------------------- /src/dune: -------------------------------------------------------------------------------- 1 | (library 2 | (name webdriver) 3 | (public_name webdriver) 4 | (libraries str yojson base64)) 5 | -------------------------------------------------------------------------------- /src/webdriver.ml: -------------------------------------------------------------------------------- 1 | include Webdriver_sig 2 | module Json = Yojson.Safe 3 | 4 | module Make (Client : HTTP_CLIENT) = struct 5 | 6 | type 'a io = 'a Client.t 7 | 8 | type json = Json.t 9 | let ( .%() ) json key = Json.Util.member key json 10 | 11 | type session = string 12 | type 'a cmd = session:session -> 'a io 13 | type elt = string 14 | 15 | exception Webdriver of Webdriver_sig.Error.t 16 | 17 | module Error = struct 18 | 19 | type error = Webdriver_sig.Error.error 20 | 21 | type t = Webdriver_sig.Error.t = 22 | { error : error 23 | ; message : string 24 | ; stacktrace : string 25 | ; data : json 26 | } 27 | 28 | let protocol_fail message data = 29 | let t = 30 | { error = `ocaml_protocol_failure 31 | ; message 32 | ; stacktrace = "wrong assumption in ocaml-webdriver" 33 | ; data 34 | } 35 | in 36 | raise (Webdriver t) 37 | 38 | let kind_of_string = function 39 | | "element click intercepted" -> `element_click_intercepted 40 | | "element not interactable" -> `element_not_interactable 41 | | "insecure certificate" -> `insecure_certificate 42 | | "invalid argument" -> `invalid_argument 43 | | "invalid cookie domain" -> `invalid_cookie_domain 44 | | "invalid element state" -> `invalid_element_state 45 | | "invalid selector" -> `invalid_selector 46 | | "invalid session id" -> `invalid_session_id 47 | | "javascript error" -> `javascript_error 48 | | "move target out of bounds" -> `move_target_out_of_bounds 49 | | "no such alert" -> `no_such_alert 50 | | "no such cookie" -> `no_such_cookie 51 | | "no such element" -> `no_such_element 52 | | "no such frame" -> `no_such_frame 53 | | "no such window" -> `no_such_window 54 | | "script timeout" -> `script_timeout 55 | | "session not created" -> `session_not_created 56 | | "stale element reference" -> `stale_element_reference 57 | | "timeout" -> `timeout 58 | | "unable to set cookie" -> `unable_to_set_cookie 59 | | "unable to capture screen" -> `unable_to_capture_screen 60 | | "unexpected alert open" -> `unexpected_alert_open 61 | | "unknown command" -> `unknown_command 62 | | "unknown error" -> `unknown_error 63 | | "unknown method" -> `unknown_method 64 | | "unsupported operation" -> `unsupported_operation 65 | | msg -> `unspecified msg 66 | 67 | let string_of_kind = function 68 | | `element_click_intercepted -> "element click intercepted" 69 | | `element_not_interactable -> "element not interactable" 70 | | `insecure_certificate -> "insecure certificate" 71 | | `invalid_argument -> "invalid argument" 72 | | `invalid_cookie_domain -> "invalid cookie domain" 73 | | `invalid_element_state -> "invalid element state" 74 | | `invalid_selector -> "invalid selector" 75 | | `invalid_session_id -> "invalid session id" 76 | | `javascript_error -> "javascript error" 77 | | `move_target_out_of_bounds -> "move target out of bounds" 78 | | `no_such_alert -> "no such alert" 79 | | `no_such_cookie -> "no such cookie" 80 | | `no_such_element -> "no such element" 81 | | `no_such_frame -> "no such frame" 82 | | `no_such_window -> "no such window" 83 | | `script_timeout -> "script timeout" 84 | | `session_not_created -> "session not created" 85 | | `stale_element_reference -> "stale element reference" 86 | | `timeout -> "timeout" 87 | | `unable_to_set_cookie -> "unable to set cookie" 88 | | `unable_to_capture_screen -> "unable to capture screen" 89 | | `unexpected_alert_open -> "unexpected alert open" 90 | | `unknown_command -> "unknown command" 91 | | `unknown_error -> "unknown error" 92 | | `unknown_method -> "unknown method" 93 | | `unsupported_operation -> "unsupported operation" 94 | | `unspecified msg -> "(unspecified) " ^ msg 95 | | `ocaml_protocol_failure -> "(ocaml-webdriver) procotol failure" 96 | 97 | let to_string e = 98 | Printf.sprintf "{ error = %S ; message = %S ; data = %s ; _ }" 99 | (string_of_kind e.error) 100 | e.message 101 | (Yojson.Safe.to_string e.data) 102 | 103 | let check json = 104 | match json.%("error") with 105 | | `Null -> () 106 | | `String error -> 107 | let e = 108 | { error = kind_of_string error 109 | ; message = Json.to_string json.%("message") 110 | ; stacktrace = Json.to_string json.%("stacktrace") 111 | ; data = json.%("data") 112 | } 113 | in 114 | raise (Webdriver e) 115 | | v -> protocol_fail "expected null or string" v 116 | 117 | let is_match e kinds = List.mem e.error kinds 118 | 119 | let catch m ?(errors = []) f = fun ~session -> 120 | Client.catch 121 | (fun () -> m () ~session) 122 | (function Webdriver e when is_match e errors -> f e ~session 123 | | exn -> Client.fail exn) 124 | 125 | let fail e ~session:_ = Client.fail e 126 | end 127 | 128 | module J = struct 129 | let unit = function 130 | | `Null -> () 131 | | json -> Error.protocol_fail "expected null" json 132 | let bool ?default json = match json, default with 133 | | `Bool b, _ -> b 134 | | `Null, Some default -> default 135 | | _ -> Error.protocol_fail "expected bool" json 136 | let int = function 137 | | `Int i -> i 138 | | json -> Error.protocol_fail "expected int" json 139 | let float = function 140 | | `Int i -> float_of_int i 141 | | `Float i -> i 142 | | json -> Error.protocol_fail "expected float" json 143 | let string ?default json = match json, default with 144 | | `String s, _ -> s 145 | | `Null, Some default -> default 146 | | _ -> Error.protocol_fail "expected string" json 147 | let string_option = function 148 | | `Null -> None 149 | | `String s -> Some s 150 | | json -> Error.protocol_fail "expected null or string" json 151 | let list = function 152 | | `List lst -> lst 153 | | json -> Error.protocol_fail "expected list" json 154 | let base64 x = Base64.decode_exn (string x) 155 | end 156 | 157 | module Capabilities = struct 158 | type t = json 159 | 160 | let capabilities json = `Assoc ["capabilities", json] 161 | let first_match browsers = `Assoc [("firstMatch", `List browsers)] 162 | let browser_name name = `Assoc ["browserName", `String name] 163 | 164 | let chrome = capabilities @@ first_match [browser_name "chrome"] 165 | 166 | let chrome_headless = 167 | capabilities 168 | @@ first_match 169 | [ `Assoc [ "browserName", `String "chrome" 170 | ; "goog:chromeOptions", 171 | `Assoc [ "args", `List [ `String "--headless" 172 | ; `String "--disable-gpu" 173 | ; `String "--no-sandbox" 174 | ; `String "--disable-dev-shm-usage" 175 | ; `String "--window-size=1920,1080" 176 | ] 177 | ] 178 | ] 179 | ] 180 | 181 | let firefox = capabilities @@ first_match [browser_name "firefox"] 182 | 183 | let firefox_headless = 184 | capabilities 185 | @@ first_match 186 | [ `Assoc [ "browserName", `String "firefox" 187 | ; "moz:firefoxOptions", 188 | `Assoc [ "args", `List [ `String "-headless" ] ] 189 | ] 190 | ] 191 | end 192 | 193 | let json_value body = 194 | match (Json.from_string body).%("value") with 195 | | (`Assoc _) as result -> 196 | Error.check result ; 197 | result 198 | | result -> result 199 | 200 | let client_value t = Client.map json_value t 201 | 202 | let get ~session path = 203 | client_value (Client.get (session ^ path)) 204 | 205 | let post_raw ~session path body = 206 | client_value (Client.post (session ^ path) body) 207 | 208 | let post ~session path json = 209 | let body = match json with 210 | | `Null -> "{}" 211 | | json -> Json.to_string json 212 | in 213 | post_raw ~session path body 214 | 215 | let delete ~session path = 216 | client_value (Client.delete (session ^ path)) 217 | 218 | module Infix = struct 219 | let lift t = fun ~session:_ -> t 220 | 221 | let return x = fun ~session:_ -> Client.return x 222 | let ( let+ ) f g = fun ~session -> Client.map g (f ~session) 223 | let ( let* ) f g = 224 | fun ~session -> Client.bind (fun x -> g x ~session) (f ~session) 225 | 226 | let ( >>| ) = ( let+ ) 227 | let ( >>= ) = ( let* ) 228 | 229 | let ( |<< ) f m = let+ x = m in f x 230 | let ( =<< ) f m = let* x = m in f x 231 | 232 | let map = ( |<< ) 233 | let bind = ( =<< ) 234 | 235 | let map2 f x y = 236 | let* x = x in 237 | let+ y = y in 238 | f x y 239 | 240 | let ( <*> ) f x = 241 | let* f = f in 242 | let+ x = x in 243 | f x 244 | 245 | let ( and* ) x y = 246 | let* x = x in 247 | let+ y = y in 248 | (x, y) 249 | 250 | let ( and+ ) = ( and* ) 251 | end 252 | 253 | open Infix 254 | 255 | module Session = struct 256 | let absolute_path path = 257 | let len = String.length path in 258 | if len > 0 && path.[len - 1] = '/' 259 | then path 260 | else path ^ "/" 261 | 262 | let make ~host capabilities = 263 | let ( let+ ) m f = Client.map f m in 264 | let session = absolute_path host in 265 | let+ json = post ~session "session" capabilities in 266 | let capabilities = json.%("capabilities") in 267 | let id = J.string json.%("sessionId") in 268 | session ^ "session/" ^ id, capabilities 269 | 270 | let delete ~session = Client.map J.unit (delete "" ~session) 271 | end 272 | 273 | let run ~host capabilities t = 274 | let ( >>= ) m f = Client.bind f m in 275 | Session.make ~host capabilities >>= fun (session, _) -> 276 | Client.catch 277 | (fun () -> Client.map (fun x -> Ok x) (t ~session)) 278 | (fun e -> Client.return (Error e)) 279 | >>= fun result -> 280 | Session.delete ~session >>= fun () -> 281 | match result with 282 | | Ok x -> Client.return x 283 | | Error e -> Client.fail e 284 | 285 | let title = J.string |<< get "/title" 286 | let source = J.string |<< get "/source" 287 | let print = J.base64 |<< post "/print" `Null 288 | 289 | let execute script = 290 | let json = `Assoc ["script", `String script ; "args", `List []] in 291 | post "/execute/sync" json 292 | 293 | let execute_async script = 294 | let json = `Assoc ["script", `String script ; "args", `List []] in 295 | post "/execute/async" json 296 | 297 | let current_url = J.string |<< get "/url" 298 | let goto url = J.unit |<< post "/url" (`Assoc ["url", `String url]) 299 | let back = J.unit |<< post "/back" `Null 300 | let forward = J.unit |<< post "/forward" `Null 301 | let refresh = J.unit |<< post "/refresh" `Null 302 | 303 | type rect = { x : float ; y : float ; width : float ; height : float } 304 | 305 | let rect_of_json json = 306 | { x = J.float json.%("x") 307 | ; y = J.float json.%("y") 308 | ; width = J.float json.%("width") 309 | ; height = J.float json.%("height") 310 | } 311 | 312 | module Window = struct 313 | type t = string 314 | type hint = [`tab | `window] 315 | 316 | let string_of_hint = function 317 | | `tab -> "tab" 318 | | `window -> "window" 319 | 320 | let make hint = 321 | let json = `Assoc ["hint", `String (string_of_hint hint)] in 322 | let+ json = post "/window/new" json in 323 | let handle = J.string json.%("handle") in 324 | let kind = match json.%("kind") with 325 | | `String "window" -> `window 326 | | `String "tab" -> `tab 327 | | json -> `other json 328 | in 329 | handle, kind 330 | 331 | let current = J.string |<< get "/window" 332 | 333 | let switch_to window = 334 | J.unit |<< post "/window" (`Assoc ["handle", `String window]) 335 | 336 | let handle_list json = List.map Json.Util.to_string (J.list json) 337 | 338 | let close = handle_list |<< delete "/window" 339 | let all = handle_list |<< get "/window/handles" 340 | 341 | type rect = { x : int ; y : int ; width : int ; height : int } 342 | let rect_of_json json = 343 | { x = J.int json.%("x") 344 | ; y = J.int json.%("y") 345 | ; width = J.int json.%("width") 346 | ; height = J.int json.%("height") 347 | } 348 | let json_of_rect r = 349 | `Assoc [ "x", `Int r.x 350 | ; "y", `Int r.y 351 | ; "width", `Int r.width 352 | ; "height", `Int r.height 353 | ] 354 | 355 | let get_rect = rect_of_json |<< get "/window/rect" 356 | let set_rect r = rect_of_json |<< post "/window/rect" (json_of_rect r) 357 | 358 | let maximize = rect_of_json |<< post "/window/maximize" `Null 359 | let minimize = rect_of_json |<< post "/window/minimize" `Null 360 | let fullscreen = rect_of_json |<< post "/window/fullscreen" `Null 361 | 362 | end 363 | 364 | module Key = struct 365 | type t = string 366 | 367 | let arrow_down = "\\uE015" 368 | let arrow_left = "\\uE012" 369 | let arrow_right = "\\uE014" 370 | let arrow_up = "\\uE013" 371 | let enter = "\\uE007" 372 | let return = "\\uE006" 373 | let tab = "\\uE004" 374 | let alt = "\\uE00A" 375 | let meta = "\\uE03D" 376 | let shift = "\\uE008" 377 | let control = "\\uE009" 378 | let escape = "\\uE00C" 379 | let f1 = "\\uE031" 380 | let f10 = "\\uE03A" 381 | let f11 = "\\uE03B" 382 | let f12 = "\\uE03C" 383 | let f2 = "\\uE032" 384 | let f3 = "\\uE033" 385 | let f4 = "\\uE034" 386 | let f5 = "\\uE035" 387 | let f6 = "\\uE036" 388 | let f7 = "\\uE037" 389 | let f8 = "\\uE038" 390 | let f9 = "\\uE039" 391 | let help = "\\uE002" 392 | let home = "\\uE011" 393 | let end_ = "\\uE010" 394 | let insert = "\\uE016" 395 | let backspace = "\\uE003" 396 | let delete = "\\uE017" 397 | let cancel = "\\uE001" 398 | let clear = "\\uE005" 399 | let numpad0 = "\\uE01A" 400 | let numpad1 = "\\uE01B" 401 | let numpad2 = "\\uE01C" 402 | let numpad3 = "\\uE01D" 403 | let numpad4 = "\\uE01E" 404 | let numpad5 = "\\uE01F" 405 | let numpad6 = "\\uE020" 406 | let numpad7 = "\\uE021" 407 | let numpad8 = "\\uE022" 408 | let numpad9 = "\\uE023" 409 | let numpad_add = "\\uE025" 410 | let numpad_comma = "\\uE026" 411 | let numpad_decimal = "\\uE028" 412 | let numpad_divide = "\\uE029" 413 | let numpad_enter = "\\uE007" 414 | let numpad_multiply = "\\uE024" 415 | let numpad_subtract = "\\uE027" 416 | let page_down = "\\uE00F" 417 | let page_up = "\\uE00E" 418 | let pause = "\\uE00B" 419 | let unidentified = "\\uE000" 420 | let zenkaku_hankaku = "\\uE040" 421 | 422 | let re_unicode = Str.regexp "\\\\\\(\\uE0[0-9A-F][0-9A-F]\\)" 423 | let escape_unicode str = 424 | Str.global_replace re_unicode "\\1" (Printf.sprintf "%S" str) 425 | end 426 | 427 | 428 | type using = 429 | [ `css 430 | | `link_text 431 | | `partial_link_text 432 | | `tag_name 433 | | `xpath 434 | ] 435 | 436 | let string_of_using = function 437 | | `css -> "css selector" 438 | | `link_text -> "link text" 439 | | `partial_link_text -> "partial link text" 440 | | `tag_name -> "tag name" 441 | | `xpath -> "xpath" 442 | 443 | let strategy using selector = 444 | `Assoc [ "using", `String (string_of_using using) 445 | ; "value", `String selector 446 | ] 447 | 448 | let web_element_id = "element-6066-11e4-a52e-4f735466cecf" 449 | 450 | let expect_id = function 451 | | `Assoc [ key, `String id ] -> 452 | assert (key = web_element_id) ; 453 | id 454 | | json -> 455 | Error.protocol_fail "expected web element identifier" json 456 | 457 | let from id = "/element/" ^ id 458 | 459 | let from_opt = function 460 | | None -> "" 461 | | Some id -> from id 462 | 463 | let find_first ?from using selector = 464 | let query = strategy using selector in 465 | expect_id |<< post (from_opt from ^ "/element") query 466 | 467 | let find_all ?from using selector = 468 | let query = strategy using selector in 469 | let+ json = post (from_opt from ^ "/elements") query in 470 | List.map expect_id (J.list json) 471 | 472 | let active = expect_id |<< get "/element/active" 473 | 474 | let is_selected elt = J.bool |<< get (from elt ^ "/selected") 475 | let is_enabled elt = J.bool |<< get (from elt ^ "/enabled") 476 | let is_displayed elt = J.bool |<< get (from elt ^ "/displayed") 477 | 478 | let attribute elt attr = 479 | J.string |<< get (from elt ^ "/attribute/" ^ attr) 480 | 481 | let property elt prop = 482 | J.string_option |<< get (from elt ^ "/property/" ^ prop) 483 | 484 | let css elt prop = 485 | J.string |<< get (from elt ^ "/css/" ^ prop) 486 | 487 | let text elt = J.string |<< get (from elt ^ "/text") 488 | let tag_name elt = J.string |<< get (from elt ^ "/name") 489 | let rect elt = rect_of_json |<< get (from elt ^ "/rect") 490 | 491 | let aria_role elt = J.string |<< get (from elt ^ "/computedrole") 492 | let aria_label elt = get (from elt ^ "/computedlabel") 493 | 494 | let submit elt = J.unit |<< post (from elt ^ "/submit") `Null 495 | let click elt = J.unit |<< post (from elt ^ "/click") `Null 496 | let clear elt = J.unit |<< post (from elt ^ "/clear") `Null 497 | 498 | let send_keys elt keys = 499 | let json = `Assoc [ "text", `Stringlit (Key.escape_unicode keys) ] in 500 | let body = Yojson.Raw.to_string json in 501 | J.unit |<< post_raw (from elt ^ "/value") body 502 | 503 | let switch_to_frame id = 504 | let id = match id with 505 | | `top -> `Null 506 | | `id i -> `Int i 507 | | `elt e -> `Assoc [ web_element_id, `String e ] 508 | in 509 | let json = `Assoc [ "id", id ] in 510 | J.unit |<< post "/frame" json 511 | 512 | let switch_to_parent_frame = 513 | J.unit |<< post "/frame/parent" `Null 514 | 515 | let screenshot ?elt () = 516 | J.base64 |<< get (from_opt elt ^ "/screenshot") 517 | 518 | type pause = 519 | [ `noop 520 | | `pause of int 521 | ] 522 | 523 | type key = 524 | [ pause 525 | | `down of string 526 | | `up of string 527 | ] 528 | 529 | type button = int 530 | 531 | type move = 532 | { move_duration : int 533 | ; move_origin : [`viewport | `pointer | `elt of elt] 534 | ; move_x : int 535 | ; move_y : int 536 | } 537 | 538 | let absolute ?(duration = 0) (x, y) = 539 | { move_duration = duration 540 | ; move_origin = `viewport 541 | ; move_x = x 542 | ; move_y = y 543 | } 544 | 545 | let relative ?(duration = 0) (x, y) = 546 | { move_duration = duration 547 | ; move_origin = `pointer 548 | ; move_x = x 549 | ; move_y = y 550 | } 551 | 552 | let center ?(duration = 0) ?(offset = (0, 0)) elt = 553 | { move_duration = duration 554 | ; move_origin = `elt elt 555 | ; move_x = fst offset 556 | ; move_y = snd offset 557 | } 558 | 559 | type pointer = 560 | [ pause 561 | | `cancel 562 | | `down of button 563 | | `up of button 564 | | `move of move 565 | ] 566 | 567 | type scroll = 568 | { scroll_duration : int 569 | ; scroll_origin : [`viewport | `elt of elt] 570 | ; scroll_x : int 571 | ; scroll_y : int 572 | } 573 | 574 | let scroll_absolute ?(duration = 0) ?(x = 0) ?(y = 0) () = 575 | { scroll_duration = duration 576 | ; scroll_origin = `viewport 577 | ; scroll_x = x 578 | ; scroll_y = y 579 | } 580 | 581 | let scroll_to ?(duration = 0) ?(dx = 0) ?(dy = 0) elt = 582 | { scroll_duration = duration 583 | ; scroll_origin = `elt elt 584 | ; scroll_x = dx 585 | ; scroll_y = dy 586 | } 587 | 588 | type wheel = 589 | [ pause 590 | | `scroll of scroll 591 | ] 592 | 593 | type 'a kind = 594 | | Null : [`noop | `pause of int] kind 595 | | Key : key kind 596 | | Wheel : wheel kind 597 | | Pointer : [`mouse | `pen | `touch] -> pointer kind 598 | 599 | type 'a source = string * 'a kind 600 | type action = Do : 'a source * 'a list -> action 601 | 602 | let str s = `Stringlit (Printf.sprintf "%S" s) 603 | let int i = `Intlit (Printf.sprintf "%i" i) 604 | 605 | let json_of_pause_tick = function 606 | | `noop -> `Assoc [ "type", str "pause" ] 607 | | `pause d -> `Assoc [ "type", str "pause" ; "duration", int d ] 608 | 609 | let json_of_key_tick = function 610 | | (`noop | `pause _) as pause -> json_of_pause_tick pause 611 | | `down key -> 612 | `Assoc [ "type", str "keyDown" 613 | ; "value", `Stringlit (Key.escape_unicode key) 614 | ] 615 | | `up key -> 616 | `Assoc [ "type", str "keyUp" 617 | ; "value", `Stringlit (Key.escape_unicode key) 618 | ] 619 | 620 | let json_of_origin = function 621 | | `viewport -> str "viewport" 622 | | `pointer -> str "pointer" 623 | | `elt id -> `Assoc [web_element_id, str id] 624 | 625 | let string_of_origin = function 626 | | `viewport -> "viewport" 627 | | `elt id -> id 628 | 629 | let json_of_pointer_tick = function 630 | | (`noop | `pause _) as pause -> json_of_pause_tick pause 631 | | `cancel -> `Assoc ["type", str "pointerCancel"] 632 | | `down btn -> `Assoc ["type", str "pointerDown" ; "button", int btn] 633 | | `up btn -> `Assoc ["type", str "pointerUp" ; "button", int btn] 634 | | `move m -> 635 | `Assoc [ "type", str "pointerMove" 636 | ; "duration", int m.move_duration 637 | ; "origin", json_of_origin m.move_origin 638 | ; "x", int m.move_x 639 | ; "y", int m.move_y 640 | ] 641 | 642 | let json_of_wheel_tick = function 643 | | (`noop | `pause _) as pause -> json_of_pause_tick pause 644 | | `scroll m -> 645 | `Assoc [ "type", str "scroll" 646 | ; "duration", int m.scroll_duration 647 | ; "origin", str (string_of_origin m.scroll_origin) 648 | ; "x", int m.scroll_x 649 | ; "y", int m.scroll_y 650 | ] 651 | 652 | let string_of_pointer_kind = function 653 | | `mouse -> "mouse" 654 | | `pen -> "pen" 655 | | `touch -> "touch" 656 | 657 | let assoc_of_kind 658 | : type a. a kind -> a list -> (string * Yojson.Raw.t) list 659 | = fun kind lst -> match kind with 660 | | Null -> 661 | [ "type", str "none" 662 | ; "actions", `List (List.map json_of_pause_tick lst) 663 | ] 664 | | Key -> 665 | [ "type", str "key" 666 | ; "actions", `List (List.map json_of_key_tick lst) 667 | ] 668 | | Wheel -> 669 | [ "type", str "wheel" 670 | ; "actions", `List (List.map json_of_wheel_tick lst) 671 | ] 672 | | Pointer kind -> 673 | [ "type", str "pointer" 674 | ; "pointerType", str (string_of_pointer_kind kind) 675 | ; "actions", `List (List.map json_of_pointer_tick lst) 676 | ] 677 | 678 | let json_of_action (Do ((id, kind), lst)) = 679 | `Assoc (("id", str id) :: assoc_of_kind kind lst) 680 | 681 | let button_left = 0 682 | let button_middle = 1 683 | let button_right = 2 684 | 685 | let none ?(name = "none") actions = Do ((name, Null), actions) 686 | let keyboard ?(name = "keyboard") actions = Do ((name, Key), actions) 687 | let mouse ?(name = "mouse") actions = Do ((name, Pointer `mouse), actions) 688 | let touch ?(name = "touch0") actions = Do ((name, Pointer `touch), actions) 689 | let pen ?(name = "pen") actions = Do ((name, Pointer `pen), actions) 690 | let wheel ?(name = "wheel") actions = Do ((name, Wheel), actions) 691 | 692 | let perform actions = 693 | let actions = List.map json_of_action actions in 694 | let json = `Assoc [ "actions", `List actions ] in 695 | let body = Yojson.Raw.to_string json in 696 | J.unit |<< post_raw "/actions" body 697 | 698 | let release = J.unit |<< delete "/actions" 699 | 700 | let double_click_action = 701 | let click = [ `down button_left ; `up button_left ] in 702 | click @ click 703 | 704 | let double_click elt = 705 | perform [ mouse (`move (center elt) :: double_click_action) ] 706 | 707 | let sleep duration = 708 | perform [ none [ `pause duration ] ] 709 | 710 | let key_modifiers = [ Key.control ; Key.shift ; Key.alt ; Key.meta ] 711 | let is_modifier k = List.mem k key_modifiers 712 | 713 | let typing keys = 714 | let rec go acc i = 715 | if i >= String.length keys 716 | then acc 717 | else if Str.string_match Key.re_unicode keys i 718 | then let key = String.sub keys i 6 in 719 | if is_modifier key 720 | then `down key :: go (`up key :: acc) (i + 6) 721 | else `down key :: `up key :: acc @ go [] (i + 6) 722 | else let key = String.sub keys i 1 in 723 | `down key :: `up key :: acc @ go [] (i + 1) 724 | in 725 | go [] 0 726 | 727 | module Cookie = struct 728 | 729 | type same_site = [ `Lax | `Strict | `Undef ] 730 | 731 | type t = 732 | { name : string 733 | ; value : string 734 | ; path : string 735 | ; domain : string option 736 | ; secure : bool 737 | ; http_only : bool 738 | ; expiry : int 739 | ; same_site : same_site 740 | } 741 | 742 | let make ?(path = "/") 743 | ?domain 744 | ?(secure = false) 745 | ?(http_only = false) 746 | ?(expiry = 999) 747 | ?(same_site = `Undef) 748 | ~name 749 | ~value 750 | () 751 | = { name 752 | ; value 753 | ; path 754 | ; domain 755 | ; secure 756 | ; http_only 757 | ; expiry 758 | ; same_site 759 | } 760 | 761 | let samesite_of_json = function 762 | | `String "Lax" -> `Lax 763 | | `String "Strict" -> `Strict 764 | | _ -> `Undef 765 | 766 | let t_of_json json = 767 | { name = J.string json.%("name") 768 | ; value = J.string json.%("value") 769 | ; path = J.string ~default:"/" json.%("path") 770 | ; domain = J.string_option json.%("domain") 771 | ; secure = J.bool ~default:false json.%("secure") 772 | ; http_only = J.bool ~default:false json.%("httpOnly") 773 | ; expiry = J.int json.%("expiry") 774 | ; same_site = samesite_of_json json.%("sameSite") 775 | } 776 | 777 | let json_of_samesite = function 778 | | `Lax -> `String "Lax" 779 | | `Strict -> `String "Strict" 780 | | `Undef -> `Null 781 | 782 | let nullable_string = function 783 | | None -> `Null 784 | | Some str -> `String str 785 | 786 | let json_of_t t = 787 | `Assoc [ "name", `String t.name 788 | ; "value", `String t.value 789 | ; "path", `String t.path 790 | ; "domain", nullable_string t.domain 791 | ; "secure", `Bool t.secure 792 | ; "httpOnly", `Bool t.http_only 793 | ; "expiry", `Int t.expiry 794 | ; "sameSite", json_of_samesite t.same_site 795 | ] 796 | 797 | let all = 798 | let+ json = get "/cookie" in 799 | List.map t_of_json (J.list json) 800 | 801 | let get name = t_of_json |<< get ("/cookie/" ^ name) 802 | 803 | let add cookie = 804 | let json = (`Assoc ["cookie", json_of_t cookie]) in 805 | J.unit |<< post "/cookie" json 806 | end 807 | 808 | module Alert = struct 809 | let dismiss = J.unit |<< post "/alert/dismiss" `Null 810 | let accept = J.unit |<< post "/alert/accept" `Null 811 | let get_text = J.string_option |<< get "/alert/text" 812 | let set_text txt = 813 | J.unit |<< post "/alert/text" (`Assoc ["text", `String txt]) 814 | end 815 | 816 | module Timeouts = struct 817 | 818 | type t = 819 | { script : [`Never | `After of int] 820 | ; page_load : int 821 | ; implicit_wait : int 822 | } 823 | 824 | let get = 825 | let+ json = get "/timeouts" in 826 | let script = match json.%("script") with 827 | | `Null -> `Never 828 | | `Int ms -> `After ms 829 | | json -> Error.protocol_fail "expected null or int" json 830 | in 831 | { script 832 | ; page_load = J.int json.%("pageLoad") 833 | ; implicit_wait = J.int json.%("implicit") 834 | } 835 | 836 | let set ?script ?page_load ?implicit_wait () = 837 | let script = match script with 838 | | None -> [] 839 | | Some `Never -> ["script", `Null] 840 | | Some (`After ms) -> ["script", `Int ms] 841 | in 842 | let page_load = match page_load with 843 | | None -> [] 844 | | Some ms -> ["pageLoad", `Int ms] 845 | in 846 | let implicit_wait = match implicit_wait with 847 | | None -> [] 848 | | Some ms -> ["implicit", `Int ms] 849 | in 850 | let json = `Assoc (script @ page_load @ implicit_wait) in 851 | J.unit |<< post "/timeouts" json 852 | end 853 | 854 | module Wait = struct 855 | 856 | let recoverable_errors = 857 | [ `element_not_interactable 858 | ; `invalid_element_state 859 | ; `move_target_out_of_bounds 860 | ; `no_such_alert 861 | ; `no_such_cookie 862 | ; `no_such_element 863 | ; `no_such_frame 864 | ; `no_such_window 865 | ] 866 | 867 | let wait dt = sleep dt 868 | 869 | let retry ?(max = 3000) ?(sleep = 50) ?(errors = recoverable_errors) cmd = 870 | let rec go fuel = 871 | if fuel < 0 872 | then cmd 873 | else Error.catch 874 | (fun () -> cmd) 875 | ~errors 876 | (fun _ -> let* () = wait sleep in go (fuel - sleep)) 877 | in 878 | go max 879 | 880 | 881 | let condition_error = `unspecified "condition" 882 | let fail_condition = 883 | { Error.error = condition_error 884 | ; message = "Wait.until condition is unsatisfied" 885 | ; data = (`Null : json) 886 | ; stacktrace = "" 887 | } 888 | 889 | let until ?max ?sleep ?(errors = recoverable_errors) condition = 890 | let fail () = Error.fail (Webdriver fail_condition) in 891 | let cmd = 892 | let* ok = condition in 893 | if ok 894 | then return () 895 | else fail () 896 | in 897 | retry ?max ?sleep ~errors:(condition_error :: errors) cmd 898 | end 899 | 900 | end 901 | -------------------------------------------------------------------------------- /src/webdriver.mli: -------------------------------------------------------------------------------- 1 | (** The raw WebDriver library, not yet specialized to an I/O monad or an HTTP client. 2 | See {! Webdriver_cohttp_lwt_unix} or {! Webdriver_cohttp_async}. *) 3 | 4 | module type HTTP_CLIENT = sig include Webdriver_sig.HTTP_CLIENT (** @inline *) end 5 | 6 | module type S = sig include Webdriver_sig.S (** @inline *) end 7 | 8 | module Make (IO : HTTP_CLIENT) : S with type 'a io = 'a IO.t 9 | -------------------------------------------------------------------------------- /src/webdriver_sig.ml: -------------------------------------------------------------------------------- 1 | module type HTTP_CLIENT = sig 2 | 3 | type 'a t 4 | 5 | val return : 'a -> 'a t 6 | val map : ('a -> 'b) -> 'a t -> 'b t 7 | val bind : ('a -> 'b t) -> 'a t -> 'b t 8 | 9 | (** Interactions may raise exceptions: *) 10 | 11 | val fail : exn -> 'a t 12 | val catch : (unit -> 'a t) -> (exn -> 'a t) -> 'a t 13 | 14 | (** The following HTTP methods are required: *) 15 | 16 | val get : string -> string t 17 | (** [get url] returns the body of the response. *) 18 | 19 | val post : string -> string -> string t 20 | (** [post url body] returns the body of the response. 21 | The [Content-Type] header should be [application/json]. *) 22 | 23 | val delete : string -> string t 24 | (** [delete url] returns the body of the response. *) 25 | end 26 | 27 | module Error = struct 28 | type error = 29 | [ `element_click_intercepted 30 | | `element_not_interactable 31 | | `insecure_certificate 32 | | `invalid_argument 33 | | `invalid_cookie_domain 34 | | `invalid_element_state 35 | | `invalid_selector 36 | | `invalid_session_id 37 | | `javascript_error 38 | | `move_target_out_of_bounds 39 | | `no_such_alert 40 | | `no_such_cookie 41 | | `no_such_element 42 | | `no_such_frame 43 | | `no_such_window 44 | | `script_timeout 45 | | `session_not_created 46 | | `stale_element_reference 47 | | `timeout 48 | | `unable_to_set_cookie 49 | | `unable_to_capture_screen 50 | | `unexpected_alert_open 51 | | `unknown_command 52 | | `unknown_error 53 | | `unknown_method 54 | | `unsupported_operation 55 | | `unspecified of string (** browser specific failure *) 56 | | `ocaml_protocol_failure (** a bug in this library, please report! *) 57 | ] 58 | 59 | type t = 60 | { error : error (** the WebDriver error *) 61 | ; message : string (** a human readable explanation *) 62 | ; stacktrace : string (** browser stacktrace *) 63 | ; data : Yojson.Safe.t (** additional metadatas *) 64 | } 65 | end 66 | 67 | module type S = sig 68 | 69 | (** *) 70 | 71 | (** 72 | WebDriver is a {{: https://www.w3.org/TR/webdriver/} W3C specification} to 73 | remote control a web browser. This allow you to simulate and test user 74 | interactions on your website in real life conditions, with javascript 75 | enabled, on as many browsers and operating systems you can get your hands on. 76 | *) 77 | 78 | type json = Yojson.Safe.t 79 | 80 | type 'a io 81 | (** The client I/O monad, within which communication happens 82 | with the WebDriver server. *) 83 | 84 | (** {1 Commands} *) 85 | 86 | type session 87 | (** A connection to the WebDriver server. 88 | A session statefully holds the active windows, tabs, cookies, and other 89 | state required by the browser. *) 90 | 91 | type 'a cmd = session:session -> 'a io 92 | (** Every browser command takes place within a [session]. *) 93 | 94 | (** Since the [~session] parameter is generally constant, 95 | this module provides a reader monad to sequence multiple commands within 96 | the same session. You can either pass explicitly the [~session] argument 97 | or open this module. 98 | *) 99 | module Infix : sig 100 | 101 | val lift : 'a io -> 'a cmd 102 | (** [lift io] wraps an I/O action as a WebDriver command. *) 103 | 104 | val return : 'a -> 'a cmd 105 | 106 | val map : ('a -> 'b) -> 'a cmd -> 'b cmd 107 | 108 | val map2 : ('a -> 'b -> 'c) -> 'a cmd -> 'b cmd -> 'c cmd 109 | 110 | val bind : ('a -> 'b cmd) -> 'a cmd -> 'b cmd 111 | 112 | val ( >>| ) : 'a cmd -> ('a -> 'b) -> 'b cmd 113 | (** [cmd >>| fn] is [map fn cmd]. *) 114 | 115 | val ( |<< ) : ('a -> 'b) -> 'a cmd -> 'b cmd 116 | (** [fn |<< cmd] is [map fn cmd]. *) 117 | 118 | val ( <*> ) : ('a -> 'b) cmd -> 'a cmd -> 'b cmd 119 | (** [fn <*> arg] is [map2 (fun f x -> f x) fn arg]. *) 120 | 121 | val ( >>= ) : 'a cmd -> ('a -> 'b cmd) -> 'b cmd 122 | (** [cmd >>= fn] is [bind fn cmd]. *) 123 | 124 | val ( =<< ) : ('a -> 'b cmd) -> 'a cmd -> 'b cmd 125 | (** [fn =<< cmd] is [bind fn cmd]. *) 126 | 127 | val ( let+ ) : 'a cmd -> ('a -> 'b) -> 'b cmd 128 | (** [let+ x = cmd in e] is [map (fun x -> e) cmd] *) 129 | 130 | val ( let* ) : 'a cmd -> ('a -> 'b cmd) -> 'b cmd 131 | (** [let* x = cmd in e] is [bind (fun x -> e) cmd] *) 132 | 133 | val ( and* ) : 'a cmd -> 'b cmd -> ('a * 'b) cmd 134 | val ( and+ ) : 'a cmd -> 'b cmd -> ('a * 'b) cmd 135 | end 136 | 137 | (** All potentital errors raised by the WebDriver protocol. *) 138 | module Error : sig 139 | 140 | (** See the 141 | {{: https://www.w3.org/TR/webdriver/#errors} WebDriver specification} 142 | for an explanation of the source of an error: *) 143 | 144 | include module type of Error (** @inline *) 145 | 146 | val to_string : t -> string 147 | (** [to_string err] returns a debug-friendly string 148 | of the WebDriver error. *) 149 | 150 | val catch : (unit -> 'a cmd) 151 | -> ?errors:error list 152 | -> (t -> 'a cmd) 153 | -> 'a cmd 154 | (** [catch (fun () -> cmd) ?errors (fun err -> catch)] runs [cmd], catching 155 | any WebDriver exceptions from [?errors] (or all if unspecified), 156 | then runs [catch] if necessary with the error [err] as an argument. 157 | 158 | Some errors are less fatal than others: it's common to catch 159 | [`no_such_element], sleep and retry when the element in question is 160 | created by some (slow) javascript. See {! Timeouts} to add an implicit 161 | wait for this situation. 162 | *) 163 | 164 | val fail : exn -> 'a cmd 165 | (** [fail e] raises the exception [e]. *) 166 | end 167 | 168 | exception Webdriver of Error.t 169 | (** Every command that fails raises this exception, which contains some hint 170 | as to what went wrong. *) 171 | 172 | 173 | (** {1 Sessions} 174 | 175 | In order to create a [session], a connection to a WebDriver-compatible 176 | browser must be established: 177 | 178 | - The [host] is a url where the WebDriver server can be accessed, 179 | for example ["http://localhost:4444"]. 180 | - The [capabilities] describe the requested browser settings. 181 | 182 | *) 183 | 184 | (** The requested capabilities when creating a new session. *) 185 | module Capabilities : sig 186 | 187 | (** See the 188 | {{: https://www.w3.org/TR/webdriver/#capabilities} 189 | WebDriver specification} 190 | and the documentation of the chosen browser for more informations. *) 191 | 192 | type t = json 193 | (** A json describing the required capabilities for the session. *) 194 | 195 | (** The capabilities are highly specific to the targetted browser. Here are 196 | some examples to get you started: *) 197 | 198 | val chrome : t 199 | (** The default [chrome] configuration: 200 | {[ 201 | { "capabilities": 202 | { "firstMatch": [{ "browser_name": "chrome" } ] } } 203 | ]} 204 | *) 205 | 206 | val chrome_headless : t 207 | (** Same as [chrome], 208 | but runs in the background without a graphical window: 209 | {[ 210 | { "capabilities": 211 | { "firstMatch": 212 | [{ "browser_name": "chrome", 213 | "goog:chromeOptions": { "args": [ "--headless" 214 | , "--disable-gpu" 215 | , "--no-sandbox" 216 | , "--disable-dev-shm-usage" 217 | , "--window-size=1920,1080" 218 | ] } 219 | } 220 | ] 221 | } 222 | } 223 | ]} 224 | *) 225 | 226 | val firefox : t 227 | (** The default [firefox] configuration: 228 | {[ 229 | { "capabilities": 230 | { "firstMatch": [{ "browser_name": "firefox" } ] } } 231 | ]} 232 | *) 233 | 234 | val firefox_headless : t 235 | (** Same as [firefox], 236 | but runs in the background without a graphical window: 237 | {[ 238 | { "capabilities": 239 | { "firstMatch": 240 | [{ "browser_name": "firefox", 241 | "moz:firefoxOptions": { "args": ["-headless"] } 242 | } 243 | ] 244 | } 245 | } 246 | ]} 247 | *) 248 | end 249 | 250 | (** For creating and deleting sessions manually. *) 251 | module Session : sig 252 | (** A Session is a connection to the remote WebDriver browser (server). *) 253 | 254 | val make : host:string -> Capabilities.t -> (session * json) io 255 | (** [make ~host cap] creates a new session on [host] with the required 256 | [cap]abilities. It returns the created [session] and a [json] 257 | describing the available capabilities of the session. 258 | *) 259 | 260 | val delete : unit cmd 261 | (** [delete ~session] closes the [session] and remove all of its windows, 262 | cookies, etc. *) 263 | end 264 | 265 | val run : host:string -> Capabilities.t -> 'a cmd -> 'a io 266 | (** [run ~host capabilities cmd] is a helper function to create a new 267 | session on [host] with the required [capabilities], execute the 268 | [cmd] within that session, and finally ensure that the session is 269 | deleted on termination of [cmd] (from its natural death 270 | or an exception.) 271 | *) 272 | 273 | (** Configure the timeouts for page loads, script execution and the 274 | implicit wait when searching for elements on a page. *) 275 | module Timeouts : sig 276 | 277 | (** 278 | - The [script] timeout specifies when to interrupt a script that is being 279 | evaluated. 280 | 281 | - The [page_load] limits the time it takes to navigate 282 | and load an url. 283 | 284 | - The [implicit_wait] adds a delay to every {! find_first} 285 | and {! find_all}, to leave time for the searched element to be created. 286 | *) 287 | 288 | type t = 289 | { script : [`Never | `After of int] 290 | (** default is [`After 30_000] ms (= 3s) *) 291 | ; page_load : int 292 | (** default is [300_000] ms (= 300s) *) 293 | ; implicit_wait : int 294 | (** default is [0] ms *) 295 | } 296 | 297 | val get : t cmd 298 | (** [get] the current timeouts configuration. *) 299 | 300 | val set : ?script:[`Never | `After of int] 301 | -> ?page_load: int 302 | -> ?implicit_wait: int 303 | -> unit 304 | -> unit cmd 305 | (** [set ?script ?page_load ?implicit_wait ()] updates the configuration 306 | for the provided fields. *) 307 | end 308 | 309 | (** Even though the browser attempts to complete most operations before 310 | giving back control, some commands might trigger too soon and raise 311 | an error. The recommended strategy is to sleep and retry the 312 | operation repeatedly until it succeeds. 313 | *) 314 | module Wait : sig 315 | (** The default parameters are: 316 | - [?sleep = 50ms] is the amount of time to {! sleep} 317 | before retrying the command. 318 | - [?max = 3000ms] is the maximum total time spend waiting 319 | in between retries (in milliseconds). 320 | - [?errors] are the recoverable errors. By default: 321 | 322 | {[ 323 | [ `element_not_interactable 324 | ; `invalid_element_state 325 | ; `move_target_out_of_bounds 326 | ; `no_such_alert 327 | ; `no_such_cookie 328 | ; `no_such_element 329 | ; `no_such_frame 330 | ; `no_such_window 331 | ] 332 | ]} 333 | 334 | The others WebDriver errors are more indicative of an issue in your 335 | automation script. 336 | *) 337 | 338 | val retry : ?max:int -> ?sleep:int -> ?errors:Error.error list 339 | -> 'a cmd -> 'a cmd 340 | (** [retry cmd] attempts to execute [cmd]. If it fails with a recoverable 341 | error, the execution sleeps for a bit to give the browser a chance to 342 | catch up, then the operation is retried until it succeeds or the [max] 343 | time is reached. 344 | 345 | {[ let* e = find_first `css "#id" in (* might raise `no_such_element *) 346 | (* vs *) 347 | let* e = Wait.retry @@ find_first `css "#id" in (* deterministic *) 348 | ]} 349 | *) 350 | 351 | val until : ?max:int -> ?sleep:int -> ?errors:Error.error list 352 | -> bool cmd -> unit cmd 353 | (** [until condition] behaves like [retry condition], but the predicate 354 | must also be satisfied at the end. 355 | 356 | Raises [`unspecified "condition"] if the [max] time is reached and 357 | the condition is still unsatisfied. 358 | 359 | {[ let* url = current_url in 360 | let* () = send_keys input ("hello" ^ Key.Enter) in 361 | let* () = Wait.until @@ map (( <> ) url ) current_url in 362 | (* blocks until the form is actually submitted. *) 363 | ]} 364 | *) 365 | end 366 | 367 | (** {1 Navigation} *) 368 | 369 | val goto : string -> unit cmd 370 | (** [goto url] ask the browser to visit the page at [url]. *) 371 | 372 | val current_url : string cmd 373 | (** The current url. *) 374 | 375 | val back : unit cmd 376 | (** Click the browser [back] button, 377 | to reload the previous url in history. *) 378 | 379 | val forward : unit cmd 380 | (** Click the [forward] button, 381 | to move forward to the next url in history. *) 382 | 383 | val refresh : unit cmd 384 | (** Refresh the current url. *) 385 | 386 | (** Cookies management. *) 387 | module Cookie : sig 388 | 389 | type same_site = [`Lax | `Strict | `Undef] 390 | 391 | type t = 392 | { name : string 393 | ; value : string 394 | ; path : string 395 | ; domain : string option 396 | ; secure : bool 397 | ; http_only : bool 398 | ; expiry : int 399 | ; same_site : same_site 400 | } 401 | 402 | val make : ?path:string 403 | -> ?domain:string 404 | -> ?secure:bool 405 | -> ?http_only:bool 406 | -> ?expiry:int 407 | -> ?same_site: same_site 408 | -> name:string 409 | -> value:string 410 | -> unit 411 | -> t 412 | (** A helper function to create a new cookie with some optional parameters. 413 | The resulting cookie will not be set in the browser 414 | ands needs to be {! add}ed. *) 415 | 416 | val add : t -> unit cmd 417 | (** [add cookie] creates or updates the cookie in the browser. *) 418 | 419 | val all : t list cmd 420 | (** Returns the list of all cookies set in the browser. *) 421 | 422 | val get : string -> t cmd 423 | (** [get cookie_name] returns the configuration of the cookie with the 424 | specified name. Raises [`no_such_cookie] otherwise. *) 425 | end 426 | 427 | (** {1 HTML Elements} *) 428 | 429 | type elt (** An HTML element. *) 430 | 431 | type using = 432 | [ `css (** CSS selectors, like ["h1 span"] *) 433 | | `link_text (** The exact text in an [...] *) 434 | | `partial_link_text (** The partial text present in a link *) 435 | | `tag_name (** the HTML tag name, like ["h1"] or ["div"] *) 436 | | `xpath (** XPath query *) 437 | ] 438 | (** A strategy to find an element on a page. *) 439 | 440 | val find_first : ?from:elt -> using -> string -> elt cmd 441 | (** [find_first `using "query"] returns the first element 442 | that matches the [query] (interpreted with [using]). 443 | The element is searched inside the current frame of the current window, 444 | and if a [?from] parent element is provided, the search takes place 445 | inside it. 446 | 447 | [raise (Webdriver { error = `no_such_element ; _ })] otherwise. 448 | 449 | {[let* elt = find_first `css "h1 a" in ... 450 | let* elt = find_first `xpath "//h1//a" in ...]} 451 | 452 | *) 453 | 454 | val find_all : ?from:elt -> using -> string -> elt list cmd 455 | (** [find_all `using "query"] behaves like {! find_first}, but 456 | returns a list of all elements matching the [query]. 457 | *) 458 | 459 | val active : elt cmd 460 | (** The currently focused element on the page. *) 461 | 462 | (** {1 Inspecting HTML elements} *) 463 | 464 | val text : elt -> string cmd 465 | (** The inner [text] of an element, ignoring any other markup. *) 466 | 467 | val tag_name : elt -> string cmd 468 | (** The HTML tag of an element, for example ["div"]. *) 469 | 470 | val attribute : elt -> string -> string cmd 471 | (** [attribute e attr] returns the value of the HTML attribute [attr] of 472 | the element [e]. *) 473 | 474 | val property : elt -> string -> string option cmd 475 | (** [property e prop] returns [Some] value of the DOM property [prop] of 476 | the element [e], or [None] if undefined. *) 477 | 478 | val is_selected : elt -> bool cmd 479 | (** The boolean status of a checkbox, a radio or an option in a select. *) 480 | 481 | val is_enabled : elt -> bool cmd 482 | (** The boolean status of an interactive element. *) 483 | 484 | val is_displayed : elt -> bool cmd 485 | (** Determines if the element is visible. *) 486 | 487 | val css : elt -> string -> string cmd 488 | (** [css e prop] returns the computed value of the css property [prop] 489 | for the element [e]. *) 490 | 491 | val aria_role : elt -> string cmd 492 | (** [aria_role e] returns the accessibility role of the element [e]. 493 | See {{: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles} 494 | ARIA Roles on MDN} *) 495 | 496 | val aria_label : elt -> json cmd 497 | (** [aria_role e] returns the accessibility label of the element [e]. 498 | See {{: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-label_attribute} 499 | ARIA Labels on MDN} *) 500 | 501 | type rect = { x : float ; y : float ; width : float ; height : float } 502 | val rect : elt -> rect cmd 503 | (** The position and size of an element on the page. *) 504 | 505 | (** {1 Interactions} *) 506 | 507 | val click : elt -> unit cmd 508 | (** Performs a mouse click on this element. *) 509 | 510 | val double_click : elt -> unit cmd 511 | (** Performs a mouse double click on this element. *) 512 | 513 | val submit : elt -> unit cmd 514 | (** Submit a form element and its descendents. *) 515 | 516 | val clear : elt -> unit cmd 517 | (** Clears the content of an input element. *) 518 | 519 | val send_keys : elt -> string -> unit cmd 520 | (** [send_keys e str] sends the string [str] to an input element, as if typed 521 | from a keyboard. For special keys like [enter] or [backspace], use the 522 | predefined values in the module [Key]: 523 | {[send_keys my_input ("hello" ^ Key.enter)]} 524 | *) 525 | 526 | (** Special keys on a keyboard. *) 527 | module Key : sig 528 | type t = string 529 | val arrow_down : t 530 | val arrow_left : t 531 | val arrow_right : t 532 | val arrow_up : t 533 | val enter : t 534 | val return : t 535 | val tab : t 536 | val alt : t 537 | val meta : t 538 | val shift : t 539 | val control : t 540 | val escape : t 541 | val f1 : t 542 | val f2 : t 543 | val f3 : t 544 | val f4 : t 545 | val f5 : t 546 | val f6 : t 547 | val f7 : t 548 | val f8 : t 549 | val f9 : t 550 | val f10 : t 551 | val f11 : t 552 | val f12 : t 553 | val help : t 554 | val home : t 555 | val end_ : t 556 | val insert : t 557 | val backspace : t 558 | val delete : t 559 | val cancel : t 560 | val clear : t 561 | val numpad0 : t 562 | val numpad1 : t 563 | val numpad2 : t 564 | val numpad3 : t 565 | val numpad4 : t 566 | val numpad5 : t 567 | val numpad6 : t 568 | val numpad7 : t 569 | val numpad8 : t 570 | val numpad9 : t 571 | val numpad_add : t 572 | val numpad_comma : t 573 | val numpad_decimal : t 574 | val numpad_divide : t 575 | val numpad_enter : t 576 | val numpad_multiply : t 577 | val numpad_subtract : t 578 | val page_down : t 579 | val page_up : t 580 | val pause : t 581 | val unidentified : t 582 | val zenkaku_hankaku : t 583 | end 584 | 585 | type action (** A sequence of interactions from a user device. *) 586 | 587 | val perform : action list -> unit cmd 588 | (** [perform actions] executes the sequence of actions 589 | for each input source. 590 | The actions are synchronized vertically, such that: 591 | 592 | {[ 593 | perform [ mouse [ `down button0 ; `pause ; `up button0 ] 594 | ; keyboard [ `down "a" ; `up "a" ; `pause ] 595 | ] 596 | ]} 597 | 598 | - The mouse starts a left click and the keyboard presses "a" 599 | simultaneously; 600 | - Then the keyboard releases the "a" key; 601 | - And finally the mouse releases the left click. 602 | 603 | The [`pause] action does nothing and is used for synchronization. 604 | 605 | The pressed keys and buttons stay pressed at the end of 606 | the interaction, unless explicitly released. 607 | *) 608 | 609 | val release : unit cmd 610 | (** [release] any pending keys or button from previous interactions. *) 611 | 612 | (** {2 Timing actions} *) 613 | 614 | type pause = 615 | [ `noop (** do nothing *) 616 | | `pause of int (** wait for duration (in ms) *) 617 | ] 618 | 619 | val none : ?name:string -> pause list -> action 620 | (** An inoperative device, that can be used to time the duration 621 | of each vertical frame. *) 622 | 623 | val sleep : int -> unit cmd 624 | (** [sleep duration] waits for [duration] in milliseconds before 625 | continuing. *) 626 | 627 | (** {2 Keyboard actions} *) 628 | 629 | type key = 630 | [ pause 631 | | `down of Key.t (** press down the key *) 632 | | `up of Key.t (** release a pressed key *) 633 | ] 634 | (** A typing interaction from a keyboard. *) 635 | 636 | val keyboard : ?name:string -> key list -> action 637 | 638 | (** [keyboard keys] is an action that simulates the typing of [keys] from a 639 | keyboard. The currently {! active} element will receive the key events. 640 | *) 641 | 642 | val typing : string -> key list 643 | (** [typing keys] is a helper function to produce an alternating sequence of 644 | [`down key] and [`up key] to simulate the typing of [keys]: 645 | 646 | {[ typing "ab" = [`down "a" ; `up "a" ; `down "b" ; `up "b"] ]} 647 | 648 | The modifier {! Key.alt}, {! Key.control}, {! Key.meta} and {! Key.shift} 649 | will be pressed differently to trigger the desired shortcut: 650 | 651 | {[ typing (Key.control ^ "a") (* CTRL-A *) 652 | = [`down Key.control ; `down "a" ; `up "a" ; `up Key.control] 653 | ]} 654 | *) 655 | 656 | (** {2 Pointer actions} *) 657 | 658 | type move 659 | (** A pointer movement to a new location, taking some [duration] of time 660 | (in milliseconds). The default [duration] is a teleportation in [0]ms. 661 | *) 662 | 663 | val absolute : ?duration:int -> int * int -> move 664 | (** [absolute (x, y)] moves the pointer to the position [(x, y)] 665 | measured from the top left of the document. *) 666 | 667 | val relative : ?duration:int -> int * int -> move 668 | (** [relative (dx, dy)] moves the pointer by [(dx, dy)] 669 | from its current location. *) 670 | 671 | val center : ?duration:int -> ?offset:int * int -> elt -> move 672 | (** [center ~offset:(dx, dy) elt] moves the pointer to the center 673 | of the element [elt] offsetted by [offset]. 674 | The default [offset] is [(0, 0)]. *) 675 | 676 | type button = int (** An integer representing the nth button on a mouse. *) 677 | 678 | val button_left : button 679 | (** The left mouse button (at position [0]). *) 680 | 681 | val button_middle : button 682 | (** The middle mouse button (at position [1]). *) 683 | 684 | val button_right : button 685 | (** The right mouse button (at position [2]). *) 686 | 687 | type pointer = 688 | [ pause 689 | | `cancel (** cancel the pointer current action *) 690 | | `down of button (** press down the button *) 691 | | `up of button (** release a pressed button *) 692 | | `move of move (** move the pointer *) 693 | ] 694 | (** An action from a mouse/touch/pen device *) 695 | 696 | val mouse : ?name:string -> pointer list -> action 697 | (** [mouse actions] describes the movement, click, etc of a mouse pointer. *) 698 | 699 | val touch : ?name:string -> pointer list -> action 700 | (** [touch actions] describes the interactions of a touch finger device. 701 | If multiple touch devices are used in the same {! perform}, they must 702 | have different names. *) 703 | 704 | val pen : ?name:string -> pointer list -> action 705 | (** [pen actions] describes the interactions of a pencil. *) 706 | 707 | (** {2 Scroll wheel actions} *) 708 | 709 | type scroll 710 | (** A scroll movement, taking some [duration] of time in milliseconds. *) 711 | 712 | val scroll_absolute : ?duration:int -> ?x:int -> ?y:int -> unit -> scroll 713 | (** [scroll_absolute ~x ~y ()] resets the scrollbar such that the 714 | position [x, y] falls into view, as measured from the top of the page. 715 | The default value of [x] and [y] is 0. *) 716 | 717 | val scroll_to : ?duration:int -> ?dx:int -> ?dy:int -> elt -> scroll 718 | (** [scroll_to ~dx ~dy elt] resets the scrollbar such that the center of the 719 | element [elt], offsetted by [(dx, dy)], is inside the view. 720 | The default offset of [dx] and [dy] is 0. *) 721 | 722 | type wheel = 723 | [ pause 724 | | `scroll of scroll (** scroll to a position *) 725 | ] 726 | (** An action from the scroll wheel. *) 727 | 728 | val wheel : ?name:string -> wheel list -> action 729 | (** [wheel scrolls] performs the scrolling actions. *) 730 | 731 | 732 | (** {1 Document} *) 733 | 734 | val title : string cmd 735 | (** The page [title] of the current document. *) 736 | 737 | val source : string cmd 738 | (** The HTML [source] code of the current document. *) 739 | 740 | val print : string cmd 741 | (** The current page, printed as a PDF. *) 742 | 743 | val screenshot : ?elt:elt -> unit -> string cmd 744 | (** Returns a PNG screenshot of the current page, 745 | or of the provided [?elt]. *) 746 | 747 | val switch_to_frame : [`top | `id of int | `elt of elt] -> unit cmd 748 | (** Focus the selected frame inside the current document. 749 | 750 | - The [`top] frame is the current document root. 751 | - The [`id n] frame is the [n]th frame in the page. 752 | - The [`elt e] frame is the frame associated with the HTML element [e]. 753 | *) 754 | 755 | val switch_to_parent_frame : unit cmd 756 | (** Focus the parent of the currently selected frame. *) 757 | 758 | (** Windows and tabs management. *) 759 | module Window : sig 760 | type t 761 | (** A handle to a browser window (or a tab) *) 762 | 763 | type hint = [`tab | `window] 764 | 765 | val make : hint -> (t * [hint | `other of json]) cmd 766 | (** Create a new window or tab, using [hint] as a suggestion. Returns a 767 | handle to the created window/tab and its actual kind. *) 768 | 769 | val current : t cmd 770 | (** The [current] browser window/tab. *) 771 | 772 | val switch_to : t -> unit cmd 773 | (** [switch_to w] sets the [current] window to [w]. *) 774 | 775 | val close : t list cmd 776 | (** [close ws] closes a list of windows. *) 777 | 778 | val all : t list cmd 779 | (** List of all the opened browser windows/tabs. *) 780 | 781 | type rect = { x : int ; y : int ; width : int ; height : int } 782 | 783 | val get_rect : rect cmd 784 | (** The size and position of the [current] window. *) 785 | 786 | val set_rect : rect -> rect cmd 787 | (** [set_rect r] attempts to resize the [current] window 788 | to match the rectangle [r]. *) 789 | 790 | val maximize : rect cmd 791 | (** Maximizes the current window. *) 792 | 793 | val minimize : rect cmd 794 | (** Minimizes the current window. *) 795 | 796 | val fullscreen : rect cmd 797 | (** Fullscreen the current window. *) 798 | end 799 | 800 | (** Popup management: alert, confirm and prompt *) 801 | module Alert : sig 802 | val accept : unit cmd 803 | val dismiss : unit cmd 804 | val get_text : string option cmd 805 | val set_text : string -> unit cmd 806 | end 807 | 808 | (** {1 Javascript execution} *) 809 | 810 | val execute : string -> json cmd 811 | (** [excute "js"] runs the [js] on the current page, 812 | returning its result in [json]. *) 813 | 814 | val execute_async : string -> json cmd 815 | (** [excute_async "js"] runs the [js] asynchronously on the current page. 816 | 817 | This function terminates when the javascript callback [arguments[0]] is 818 | called, and returns its parameter as json. This can be used to block 819 | until some component has initialized: 820 | 821 | {[ 822 | let* _ = 823 | execute_async 824 | {| var k = arguments[0]; something.onload(k); |} 825 | in 826 | (* blocks until onload triggers k *) 827 | ]} 828 | *) 829 | 830 | end 831 | -------------------------------------------------------------------------------- /test/a.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |13 | first link to B 14 | another link to B 15 |
16 | 17 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/b.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |