diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 93d97723..68dafdd8 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -7,8 +7,4 @@ ;; finer grained error reporting {:macroexpand {etaoin.impl.util/defmethods etaoin.impl.util/defmethods - etaoin.impl.util/with-tmp-file etaoin.impl.util/with-tmp-file}} - - :linters - ;; etaoin is dsl-ish and does make use of :refer :all, can decide if we like that at some later date - {:refer-all {:level :off}}} + etaoin.impl.util/with-tmp-file etaoin.impl.util/with-tmp-file}}} diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 749b7af4..cc6173cb 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -65,8 +65,11 @@ Fixed. * Docs ** https://github.com/clj-commons/etaoin/issues/393[#393]: Add changelog ** https://github.com/clj-commons/etaoin/issues/426[#426]: Reorganize into separate guides -** https://github.com/clj-commons/etaoin/commit/f3f0370fb76bc353c14293243410db1641f99c70[f3f0370]: A new troubleshooting tip (thanks https://github.com/jkrasnay[@jkrasnay]!) ** https://github.com/clj-commons/etaoin/issues/396[#396]: Move from Markdown to AsciiDoc +** User guide +*** Reviewed, re-organized, hopefully clarified some things +*** Checking code blocks with https://github.com/lread/test-doc-blocks[test-doc-blocks] +*** https://github.com/clj-commons/etaoin/commit/f3f0370fb76bc353c14293243410db1641f99c70[f3f0370]: A new troubleshooting tip (thanks https://github.com/jkrasnay[@jkrasnay]!) * Internal quality ** https://github.com/clj-commons/etaoin/issues/382[#382]: Fix process fork testing on Windows ** https://github.com/clj-commons/etaoin/issues/391[#391]: Identify browser name on failed ide tests diff --git a/bb.edn b/bb.edn index 4ab632b5..80fb352d 100644 --- a/bb.edn +++ b/bb.edn @@ -27,6 +27,8 @@ "--nrepl-server"))} test {:doc "run all or a subset of tests, use --help for args" :task test/-main} + test-doc {:doc "test code blocks in user guide" + :task test-doc/-main} drivers {:doc "[list|kill] any running WebDrivers" :task drivers/-main} lint {:doc "[--rebuild] lint source code" diff --git a/deps.edn b/deps.edn index 2596beae..d6398de6 100644 --- a/deps.edn +++ b/deps.edn @@ -22,8 +22,23 @@ ;; for babashka testing, allows us to use cognitect test-runner :bb-test {:extra-deps {org.clojure/tools.namespace {:git/url "https://github.com/babashka/tools.namespace" :git/sha "a13b037215e21a2e71aa34b27e1dd52c801a2a7b"}}} + + ;; test-doc-blocks - gen tests + :test-doc-blocks {:replace-deps {org.clojure/clojure {:mvn/version "1.11.1"} + com.github.lread/test-doc-blocks {:mvn/version "1.0.166-alpha"}} + :replace-paths [] + :ns-default lread.test-doc-blocks + :exec-args {:docs ["doc/01-user-guide.adoc"]}} + + ;; test-doc-blocks - run tests + ;; usage: test:test-docs + :test-docs {:override-deps {org.clojure/clojure {:mvn/version "1.11.1"}} + :extra-paths ["target/test-doc-blocks/test"] + :main-opts ["-m" "cognitect.test-runner" + "-d" "target/test-doc-blocks/test"]} + ;; for consistent linting we use a specific version of clj-kondo through the jvm - :clj-kondo {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2022.05.29"}} + :clj-kondo {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2022.05.31"}} :main-opts ["-m" "clj-kondo.main"]} :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"} diff --git a/doc/01-user-guide.adoc b/doc/01-user-guide.adoc index b22fa8a8..6a2ab69c 100644 --- a/doc/01-user-guide.adoc +++ b/doc/01-user-guide.adoc @@ -1,20 +1,29 @@ // NOTE: release workflow automatically updates etaoin versions in this file +// NOTE: many of the clojure code blocks in this file are tested via lread/test-doc-block = User Guide :toclevels: 5 :toc: :lib-version: 0.4.6 -:url-tests: https://github.com/{project-src-coords}/blob/master/test/etaoin/api_test.clj +:project-src-coords: clj-commons/etaoin +:project-mvn-coords: etaoin/etaoin +:url-webdriver: https://www.w3.org/TR/webdriver/ +:url-sample-page: /doc/user-guide-sample.html +:url-doc: https://cljdoc.org/d/{project-mvn-coords} +:url-tests: /test/etaoin/api_test.clj +:url-slack: https://clojurians.slack.com/archives/C7KDM0EKW == Introduction Etaoin offers the Clojure community a simple way to script web browser interactions from Clojure and Babashka. +It is a thin abstraction atop the link:{url-webdriver}[W3C WebDriver protocol] that also endeavors to resolve real-world nuances and implementation differences. === History Ivan Grishaev (https://github.com/igrishaev[@igrishaev]) created Etaoin and published its first release to Clojars in Feb of 2017. -He and his band of faithful contributors grew Etaoin into a well respected goto library for browser automation. +He and his band of faithful contributors grew Etaoin into a well respected goto-library for browser automation. -In May of 2022, finding his time had gravitated more to back-end development, Ivan offered Etaoin for adoption to clj-commons where it is now currently under the loving care of https://github.com/lread[@lread] and https://github.com/borkdude[@borkdude]. +In May 2022, finding his time had gravitated more to back-end development, Ivan offered Etaoin for adoption to clj-commons. +It is now currently under the loving care of https://github.com/lread[@lread] and https://github.com/borkdude[@borkdude]. === Interesting Alternatives @@ -25,7 +34,7 @@ If Etaoin is not your cup of tea, you might also consider: [[supported-os-browser]] === Supported OSes & Browsers -Etaoin's test suite covers the following OSes and browsers run with both regular Clojure and Babashka: +Etaoin's test suite covers the following OSes and browsers for both Clojure and Babashka: |=== | OS | Chrome | Firefox | Safari | Edge @@ -50,28 +59,29 @@ Etaoin's test suite covers the following OSes and browsers run with both regular |=== -NOTE: At one point we did also test against PhantomJS, but since work has long ago stopped for this project we have dropped testing. +NOTE: We once did test against PhantomJS, but since work has long ago stopped on this project, we have dropped testing == Installation There are two steps to installation: . Add the `etaoin` library as a dependency to your project -. Install the WebDriver for each web browser you'd like control with Etaoin +. Install the WebDriver for each web browser that you want to control with Etaoin -=== Installing the etaoin library +=== Installing the Etaoin Library ==== For Clojure Users Etaoin supports Clojure v1.9 and above. -Add the following into `:dependencies` vector in your `project.clj` file: +Add the following into the `:dependencies` vector in your `project.clj` file: [source,clojure,subs="attributes+"] ---- [etaoin "{lib-version}"] ---- +//:test-doc-blocks/skip Or the following under `:deps` in your `deps.edn` file: [source,clojure,subs="attributes+"] ---- @@ -84,25 +94,28 @@ We recommend the current release of https://book.babashka.org/#_installation[bab Add the following under `:deps` to your `bb.edn` file: +//:test-doc-blocks/skip [source,clojure,subs="attributes+"] ---- etaoin/etaoin {:mvn/version "{lib-version}"} ---- -The Etaoin feature to <> employs clojure spec. If you are using this feature, you'll need to also enable clojure spec support in Babashka by adding `babashka/spec.alpha` to your `bb.edn` `:deps`: +The Etaoin feature to <> employs clojure spec. +If you are using this feature, you'll need to also enable clojure spec support in Babashka by adding `babashka/spec.alpha` to your `bb.edn` `:deps`: +//:test-doc-blocks/skip [source,clojure] ---- org.babashka/spec.alpha {:git/url "https://github.com/babashka/spec.alpha" - :sha "644a7fc216e43d5da87b07471b0f87d874107d1a"}}} + :sha "8df0712896f596680da7a32ae44bb000b7e45e68"} ---- See https://github.com/babashka/spec.alpha[babashka/spec.alpha] for current docs. :url-webdriver: https://www.w3.org/TR/webdriver/ :url-tests: https://github.com/{project-src-coords}/blob/master/test/etaoin/api_test.clj -:url-chromedriver: https://sites.google.com/a/chromium.org/chromedriver/ -:url-chromedriver-dl: https://sites.google.com/a/chromium.org/chromedriver/downloads +:url-chromedriver: https://sites.google.com/chromium.org/driver/ +:url-chromedriver-dl: https://sites.google.com/chromium.org/driver/downloads :url-geckodriver-dl: https://github.com/mozilla/geckodriver/releases :url-phantom-dl: http://phantomjs.org/download.html :url-webkit: https://webkit.org/blog/6900/webdriver-support-in-safari-10/ @@ -112,11 +125,12 @@ See https://github.com/babashka/spec.alpha[babashka/spec.alpha] for current docs === Installing the Browser WebDrivers Etaoin controls web browsers via their WebDrivers. -Each browser has its own WebDriver implementation which must be installed. +Each browser has its own WebDriver implementation that must be installed. [TIP] ==== -If it is not already installed, download the web browser you'd like to control (Chrome, Firefox, Edge) as per normal, which is usually by downloading it from its official site. +If it is not already installed, you will need to install the web browser too (Chrome, Firefox, Edge). +This is usually via a download from its official site. Safari comes bundled with macOS. ==== @@ -154,7 +168,9 @@ Edge and `msedgedriver` must match so you might need to specify the version: ** Windows: `scoop install phantomjs` ** Download: link:{url-phantom-dl}[Official PhantomJS download site] -Now, check your installation launching any of these commands. For each command, an endless process with a local HTTP server should start. +Check your WebDriver installations launching by launching these commands. +Each should start a process that includes its own local HTTP server. +Use Ctrl-C to terminate. [source,bash] ---- @@ -165,75 +181,99 @@ msedgedriver phantomjs --wd ---- -If you like, you can run Etaoin's test suite to verify your installation. -From a clone of the https://github.com/clj-commons/etaoin[Etaoin GitHub repo]: +You can optionally run the Etaoin test suite to verify your installation. + +TIP: Some Etaoin API tests rely on ImageMagick. +Install it prior to running test. + +From a clone of the https://github.com/clj-commons/etaoin[Etaoin GitHub repo] +* To check tools of interest to Etaoin: ++ +[source,bash] +---- +bb tools-versions +---- +* Run all tests: ++ [source,bash] ---- bb test all ---- - -For a smaller sanity test, you might want to run api tests against browsers you are particularly intested in. Example: - +* For a smaller sanity test, you might want to run api tests against browsers you are particularly intested in. Example: ++ [source,bash] ---- bb test api --browser chrome ---- -You'll see browser windows open and close in series. -The tests use a local HTML file with a special layout to validate most interactions. +During the test run, browser windows will open and close in series. +The tests use a local handcrafted HTML file to validate most interactions. -See below for <> if you have problems +See <> if you have problems - or reach out on link:{url-slack}[Clojurians Slack #etaoin] or https://github.com/clj-commons/etaoin/issues[GitHub issues]. -== Getting started +== Getting Started -The good news is that you can automate your browser directly from your Babashka or Clojure REPL: +The great news is that you can automate your browser directly from your Babashka or Clojure REPL. +Let's interact with Wikipedia: + +// A little invisible codeblock for some setup +ifdef::env-test-doc-blocks[] +[source,clojure] +---- +(require '[babashka.fs :as fs]) +;; for better test-doc-block reporting when running generated tests +(require '[etaoin.test-report]) +;; for screenshots save dir (dir must currently exist) +(fs/create-dirs "target/etaoin-play") +---- +endif::[] [source,clojure] ---- -(require '[etaoin.api :as api] +(require '[etaoin.api :as e] '[etaoin.keys :as k]) ;; Start WebDriver for Firefox -(def driver (api/firefox)) ;; a Firefox window should appear +(def driver (e/firefox)) ;; a Firefox window should appear ;; let's perform a quick Wiki session ;; navigate to wikipedia -(api/go driver "https://en.wikipedia.org/") +(e/go driver "https://en.wikipedia.org/") ;; wait for the search input to load -(api/wait-visible driver [{:id :simpleSearch} {:tag :input :name :search}]) +(e/wait-visible driver [{:id :simpleSearch} {:tag :input :name :search}]) ;; search for something interesting -(api/fill driver {:tag :input :name :search} "Clojure programming language") -(api/fill driver {:tag :input :name :search} k/enter) -(api/wait-visible driver {:class :mw-search-results}) +(e/fill driver {:tag :input :name :search} "Clojure programming language") +(e/fill driver {:tag :input :name :search} k/enter) +(e/wait-visible driver {:class :mw-search-results}) ;; click on first match -(api/click driver [{:class :mw-search-results} {:class :mw-search-result-heading} {:tag :a}]) -(api/wait-visible driver {:id :firstHeading}) +(e/click driver [{:class :mw-search-results} {:class :mw-search-result-heading} {:tag :a}]) +(e/wait-visible driver {:id :firstHeading}) ;; check our new url location -(api/get-url driver) +(e/get-url driver) ;; => "https://en.wikipedia.org/wiki/Clojure" ;; and our new title -(api/get-title driver) +(e/get-title driver) ;; => "Clojure - Wikipedia" ;; does page have Clojure in it? -(api/has-text? driver "Clojure") +(e/has-text? driver "Clojure") ;; => true ;; navigate through history -(api/back driver) -(api/forward driver) -(api/refresh driver) -(api/get-title driver) +(e/back driver) +(e/forward driver) +(e/refresh driver) +(e/get-title driver) ;; => "Clojure - Wikipedia" ;; stops Firefox WebDriver -(api/quit driver) ;; the Firefox Window should close +(e/quit driver) ;; the Firefox Window should close ---- Most api functions require the driver as the first argument. @@ -242,129 +282,255 @@ A portion of the above rewritten with `doto`: [source,clojure] ---- -(use '[etaoin.api :refer :all]) -(require '[etaoin.keys :as k]) +(require '[etaoin.api :as e] + '[etaoin.keys :as k]) -(def driver (firefox)) +(def driver (e/firefox)) (doto driver - (go "https://en.wikipedia.org/") - (wait-visible [{:id :simpleSearch} {:tag :input :name :search}]) - (fill {:tag :input :name :search} "Clojure programming language") - (fill {:tag :input :name :search} k/enter) - (wait-visible {:class :mw-search-results}) - (click [{:class :mw-search-results} {:class :mw-search-result-heading} {:tag :a}]) - (wait-visible {:id :firstHeading}) - (quit)) + (e/go "https://en.wikipedia.org/") + (e/wait-visible [{:id :simpleSearch} {:tag :input :name :search}]) + (e/fill {:tag :input :name :search} "Clojure programming language") + (e/fill {:tag :input :name :search} k/enter) + (e/wait-visible {:class :mw-search-results}) + (e/click [{:class :mw-search-results} {:class :mw-search-result-heading} {:tag :a}]) + (e/wait-visible {:id :firstHeading}) + (e/quit)) ---- -You can use `fill-multi` to shorten the code like: +== Playing Along in your REPL +We encourage you to try the examples in from this user guide in your REPL. + +The Interwebs is constantly changing. +This makes testing against live sites impractical. +The code in this user guide has instead been tested to work against our link:{url-sample-page}[little sample page]. + +Until we figure out something more clever, it might be easiest to clone the etaoin GitHub repository and run a REPL from there. + +Unless otherwise directed, our examples throughout the rest of this guide will assume you've already executed the equivalent of: [source,clojure] ---- -(fill driver :login "login") -(fill driver :password "pass") -(fill driver :textarea "some text") +(require '[etaoin.api :as e] + '[etaoin.api2 :as e2] + '[etaoin.keys :as k] + '[clojure.java.io :as io]) + +(def sample-page (-> "doc/user-guide-sample.html" io/file .toURI str)) + +(def driver (e/chrome)) ;; or replace chrome with your preference +(e/go driver sample-page) ---- -into +== More Getting Started + +You can use `fill-multi` to shorten the code like so: [source,clojure] ---- -(fill-multi driver {:login "login" - :password "pass" - :textarea "some text"}) +(e/fill driver :uname "username") +(e/fill driver :pw "pass") +(e/fill driver :text "some text") + +;; let's get what we just set: +(mapv #(e/get-element-value driver %) [:uname :pw :text]) +;; => ["username" "pass" "some text"] +---- + +into: + +[source,clojure]] ---- +;; issue a browser refresh +(e/refresh driver) +(e/fill-multi driver {:uname "username2" + :pw "pass2" + :text "some text2"}) -If any exception occurs during a browser session, the WebDriver process might hang forever until you kill it manually. -To prevent that, use `with-` macros as follows: +;; to get what we just set: +(mapv #(e/get-element-value driver %) [:uname :pw :text]) +;; => ["username2" "pass2" "some text2"] +---- + +If any exception occurs during a browser session, the WebDriver process might hang until you kill it manually. +To prevent that, we recommend the `with-` macros: [source,clojure] ---- -(with-firefox {} ff ;; additional options first, then bind name - (doto ff - (go "https://google.com") - ...)) +(require '[etaoin.api2 :as e2]) + +(e2/with-firefox [driver] + (doto driver + (e/go "https://google.com") + ;; ... your code here + )) ---- This will ensure that the WebDriver process is closed regardless of what happens. == Unit Tests as Docs -The sections that follow describe, how to use Etaoin. +The sections that follow describe how to use Etaoin in more depth. + +In addition to these docs, the link:{url-tests}[Etaoin api tests] are also a good reference. + +== Creating and Quitting the Driver + +Etaoin comes with many options to create a WebDriver instance. + +TIP: As previously mentioned, we recommend the `with-` convention when you need proper cleanup. + +Let's say we want to create a chrome headless driver: + +// let's not pollute our main test-doc-block ns with these driver vars: +//{:test-doc-blocks/test-ns user-guide-driver-creation-test} +[source,clojure] +---- +(require '[etaoin.api :as e]) + +;; at the base we have: +(def driver (e/boot-driver :chrome {:headless true})) +;; do stuff +(e/quit driver) -In addition to these docs the link:{url-tests}[Etaoin api tests] are also a good reference. +;; This can also be expressed as: +(def driver (e/chrome {:headless true})) +;; do stuff +(e/quit driver) -== Querying elements +;; Or... +(def driver (e/chrome-headless)) +;; do stuff +(e/quit driver) +---- -Most of the functions like `click`, `fill`, etc require a query term to discover an element on a page. -For example: +The v2 API has ergonomic `with-` functions that handle cleanup nicely: [source,clojure] ---- -(click driver {:tag :button}) -(fill driver {:id "searchInput"} "Clojure") +(require '[etaoin.api2 :as e2]) + +(e2/with-chrome [driver {:headless true}] + (e/go driver "https://clojure.org")) + +(e2/with-chrome-headless [driver] + (e/go driver "https://clojure.org")) +---- + +Replace `chrome` with `firefox`, `edge` or `safari` for other variants. +See link:{url-doc}[API docs] for details. + +See <> for all options available when creating a driver. + +== Selecting Elements [[querying]] + +Queries (aka selectors) are used to select the elements on the page that Etaoin will interact with. + +[source,clojure]] +---- +;; let's start anew by refreshing the page: +(e/refresh driver) +;; select the element with an html attribute id of 'uname' and fill it with text: +(e/fill driver {:id "uname"} "Etaoin") +;; select the first element with an html button tag and click on it: +(e/click driver {:tag :button}) +---- + +[TIP] +==== +* A query returns a unique element identifier typically meaningful only as a selector to other functions it is passed to. +* Many functions accept a query directly. For example: ++ +[source,clojure]] +---- +;; specifying query directly +(e/get-element-text driver {:tag :button}) +;; => "Submit Form" +;; specifying the result of a query (notice the `-el` fn variant here) +(e/get-element-text-el driver (e/query driver {:tag :button})) +;; => "Submit Form" ---- +==== + +[TIP] +==== +An exception is thrown if a query does not find an element. +Use exists? to check for element existence: -The library supports the following query types and values. +[source,clojure] +---- +(e/exists? driver {:tag :button}) +;; => true +(e/exists? driver {:id "wont-find-me"}) +;; => false +---- +==== -=== Simple queries, XPath, CSS +=== Simple Queries, XPath, CSS :xpath-sel: https://www.w3schools.com/xml/xpath_syntax.asp :css-sel: https://www.w3schools.com/cssref/css_selectors.asp -* `:active` stands for the current active element. -When opening Google page for example, it focuses the cursor on the main search input. -So there is no need to click on in manually. -Example: +* `:active` finds the current active element. +The Google page, for example, automatically places the focus on the search input. +So there is no need to click on it first: + [source,clojure] ---- -(fill driver :active "Let's search for something" keys/enter) +(e/go driver "https://google.com") +(e/fill driver :active "Let's search for something" k/enter) ---- -* any other keyword that indicates an element's ID. -For Google page, it is `:lst-ib` or `"lst-ib"` (strings are also supported). -The registry matters. -Example: +* any other keyword is translated to an html id attribute: + [source,clojure] ---- -(fill driver :lst-ib "What is the Matrix?" keys/enter) +(e/go driver sample-page) +(e/fill driver :uname "Etaoin" k/enter) +;; alternatively you can: +(e/fill driver {:id "uname"} "Etaoin Again" k/enter) ---- -* a string with an link:{xpath-sel}[XPath] expression. -Be careful when writing them manually. -Check the `Troubleshooting` section below. -Example: +* a string containing an link:{xpath-sel}[XPath] expression. +(Be careful when writing XPath manually, see <>.) +Here we find an `input` tag with an attribute `id` of `uname` and an attribute `name` of `username`: + [source,clojure] ---- -(fill driver ".//input[@id='lst-ib'][@name='q']" "XPath in action!" keys/enter) +(e/refresh driver) +(e/fill driver ".//input[@id='uname'][@name='username']" "XPath can be tricky") + +;; let's check if that worked as expected: +(e/get-element-value driver :uname) +;; => "XPath can be tricky" ---- -* a map with either `:xpath` or `:css` key with a string expression of corresponding syntax. -Example: +* a map with either `:xpath` or `:css` key with a string in corresponding syntax: + [source,clojure] ---- -(fill driver {:xpath ".//input[@id='lst-ib']"} "XPath selector" keys/enter) -(fill driver {:css "input#lst-ib[name='q']"} "CSS selector" keys/enter) +(e/refresh driver) +(e/fill driver {:xpath ".//input[@id='uname']"} "XPath selector") +(e/fill driver {:css "input#uname[name='username']"} " CSS selector") + +;; And here's what we should see in username input field now: +(e/get-element-value driver :uname) +;; => "XPath selector CSS selector" ---- + -See the link:{css-sel}[CSS selector] manual for more info. +This link:{css-sel}[CSS selector reference] may be of help. -=== Map syntax for querying +=== Map Syntax Queries -A query might be any other map that represents an XPath expression as data. +A query can also be a map that represents an XPath expression as data. The rules are: * A `:tag` key represents a tag's name. -It becomes `*` when not passed. -* An `:index` key expands into the trailing `[x]` clause. -Useful when you need to select a third row from a table for example. +Defaults to `*`. +* An `:index` key expands into the trailing XPath `[x]` clause. +Useful when you need to select a third row from a table, for example. * Any non-special key represents an attribute and its value. -* A special key has `:fn/` namespace and expands into something specific. +* `:fn/` is a prefix followed by a supported query function. Examples: @@ -372,113 +538,157 @@ Examples: + [source,clojure] ---- -(query driver {:tag :div}) -;; expands into .//div +(= (e/query driver {:tag :div}) + ;; equivalent via xpath: + (e/query driver ".//div")) +;; => true ---- -* find the n-th `div` tag +* find the n-th (1-based) `div` tag + [source,clojure] ---- -(query driver {:tag :div :index 1}) -;; expands into .//div[1] +(= (e/query driver {:tag :div :index 1}) + ;; equivalent via xpath: + (e/query driver ".//div[1]")) +;; => true ---- -* find the tag `a` with the class attribute equals to `active` +* find the tag `a` where the class attribute equals to `active` + [source,clojure] ---- - (query driver {:tag :a :class "active"}) - ;; ".//a[@class=\"active\"]" +(= (e/query driver {:tag :a :class "active"}) + ;; equivalent xpath: + (e/query driver ".//a[@class='active']")) ---- * find a form by its attributes: + [source,clojure] ---- -(query driver {:tag :form :method :GET :class :message}) -;; expands into .//form[@method="GET"][@class="message"] +(= (e/query driver {:tag :form :method :GET :class :formy}) + ;; equivalent in xpath: + (e/query driver ".//form[@method=\"GET\"][@class='formy']")) ---- * find a button by its text (exact match): + [source,clojure] ---- -(query driver {:tag :button :fn/text "Press Me"}) -;; .//button[text()="Press Me"] +(= (e/query driver {:tag :button :fn/text "Submit Form"}) + ;; equivalent in xpath: + (e/query driver ".//button[text() = 'Submit Form']")) ---- -* find an nth element (`p`, `a`, whatever) with "download" text: +* find an nth element (`p`, `div`, whatever, it does not matter) with "blarg" text: + [source,clojure] ---- -(query driver {:fn/has-text "download" :index 3}) -;; .//*[contains(text(), "download")][3] +(e/get-element-text driver {:fn/has-text "blarg" :index 3}) +;; => "blarg in a p" + +;; equivalent in xpath: +(e/get-element-text driver ".//*[contains(text(), 'blarg')][3]") +;; => "blarg in a p" ---- -* find an element that has the following class: +* find an element that includes a class: + [source,clojure] ---- -(query driver {:tag :div :fn/has-class "overlay"}) -;; .//div[contains(@class, "overlay")] +(e/get-element-text driver {:tag :span :fn/has-class "class1"}) +;; => "blarg in a span" + +;; equivalent xpath: +(e/get-element-text driver ".//span[contains(@class, 'class1')]") +;; => "blarg in a span" ---- -* find an element that has the following domain in a href: +* find an element that has the following domain in a `href`: + [source,clojure] ---- -(query driver {:tag :a :fn/link "google.com"}) -;; .//a[contains(@href, "google.com")] +(e/get-element-text driver {:tag :a :fn/link "clojure.org"}) +;; => "link 3 (clojure.org)" + +;; equivalent xpath: +(e/get-element-text driver ".//a[contains(@href, \"clojure.org\")]") +;; => "link 3 (clojure.org)" ---- -* find an element that has the following classes at once: +* find an element that includes all of the specified classes: + [source,clojure] ---- -(query driver {:fn/has-classes [:active :sticky :marked]}) -;; .//*[contains(@class, "active")][contains(@class, "sticky")][contains(@class, "marked")] +(e/get-element-text driver {:fn/has-classes [:class2 :class3 :class5]}) +;; => "blarg in a div" + +;; equivalent in xpath: +(e/get-element-text driver ".//*[contains(@class, 'class2')][contains(@class, 'class3')][contains(@class, 'class5')]") +;; => "blarg in a div" ---- -* find the enabled/disabled input widgets: +* find explicitly enabled/disabled input widgets: + [source,clojure] ---- -;; first input -(query driver {:tag :input :fn/disabled true}) -;; .//input[@disabled=true()] -(query driver {:tag :input :fn/enabled true}) -;; .//input[@enabled=true()] +;; first enabled input +(= (e/query driver {:tag :input :fn/enabled true}) + ;; equivalent xpath: + (e/query driver ".//input[@enabled=true()]")) +;; => true -;; all inputs -(query-all driver {:tag :input :fn/disabled true}) -;; .//input[@disabled=true()] +;; first disabled input +(= (e/query driver {:tag :input :fn/disabled true}) + ;; equivalent xpath: + (e/query driver ".//input[@disabled=true()]")) +;; => true + +;; return a vector of all disabled inputs +(= (e/query-all driver {:tag :input :fn/disabled true}) + ;; equivalent xpath: + (e/query-all driver ".//input[@disabled=true()]")) +;; => true ---- -=== Vector syntax for querying +=== Vector Syntax Queries -A query might be a vector that consists from any expressions mentioned above. -In such a query, every next term searches from a previous one recursively. +A query can be a vector of any valid query expressions. +For vector queries, every expression matches the output from the previous expression. -A simple example: +A simple, somewhat contrived, example: [source,clojure] ---- -(click driver [{:tag :html} {:tag :body} {:tag :a}]) +(e/click driver [{:tag :html} {:tag :body} {:tag :button}]) +;; our sample page shows form submits, did it work? +(e/get-element-text driver :submit-count) +;; => "1" ---- -You may combine both XPath and CSS expressions as well (pay attention at a leading dot in XPath expression: +You may combine both XPath and CSS expressions + +TIP: Reminder: the leading dot in an XPath expression means starting at the current node [source,clojure] ---- -(click driver [{:tag :html} {:css "div.class"} ".//a[@class='download']"]) +;; under the html tag (using map query syntax), +;; under a div tag with a class that includes some-links (using css query), +;; click on a tag that has +;; a class attribute equal to active (using xpath syntax): +(e/click driver [{:tag :html} {:css "div.some-links"} ".//a[@class='active']"]) +;; our sample page shows link clicks, did it work? +(e/get-element-text driver :clicked) +;; => "link 2 (active)" ---- -=== Advanced queries +=== Advanced Queries -==== Querying the _nth_ element matched +==== Querying the _nth_ Element Matched -Sometimes you may need to interact with the _nth_ element of a query, for instance when wanting to click on the second link in this example: +Sometimes you may want to interact with the _nth_ element of a query. +Maybe you want to click on the second link within: [source,html] ---- @@ -495,75 +705,114 @@ Sometimes you may need to interact with the _nth_ element of a query, for instan ---- -In this case you may either use the `:index` directive that is supported for XPath expressions like this: +You can use the `:index` like so: [source,clojure] ---- -(click driver [{:tag :li :class :search-result :index 2} {:tag :a}]) +(e/click driver [{:tag :li :class :search-result :index 2} {:tag :a}]) +;; check click tracker from our sample page: +(e/get-element-text driver :clicked) +;; => "b" ---- :nth-child: https://www.w3schools.com/CSSref/sel_nth-child.asp or you can use the link:{nth-child}[nth-child trick] with the CSS expression like this: [source,clojure] + ---- -(click driver {:css "li.search-result:nth-child(2) a"}) +;; start page anew +(e/refresh driver) +(e/click driver {:css "li.search-result:nth-child(2) a"}) +(e/get-element-text driver :clicked) +;; => "b" ---- Finally it is also possible to obtain the _nth_ element directly by using `query-all`: [source,clojure] ---- -(click-el driver (nth (query-all driver {:css "li.search-result a"}) 2)) +;; start page anew +(e/refresh driver) +(e/click-el driver (nth (e/query-all driver {:css "li.search-result a"}) 1)) +(e/get-element-text driver :clicked) +;; => "b" ---- -Note the use of `click-el` here, as `query-all` returns an element, not a selector that can be passed to `click` directly. +[NOTE] +==== +Notice: -==== Getting elements like in a tree +* The use of `click-el` here. The `query-all` function returns an element, not a selector that can be passed to `click` directly +* The nth offset of 1 instead of 2. Clojure's nth is 0-based, and our search indexes are 1-based. +==== -`query-tree` takes selectors and acts like a tree. -Every next selector queries elements from the previous ones. -The fist selector relies on find-elements, and the rest ones use find-elements-from +==== Querying a Tree -[source,clojure] +`query-tree` pipes selectors. +Every selector queries elements from the previous one. +The first selector finds elements from the root, subsquent selectors find elements downward from each of the previous found elements. + +Given the following HTML: +[source,html] ---- - (query-tree driver {:tag :div} {:tag :a}) +
+
+ a1 + a2 + a3 +
+
+ a4 + a5 + a6 +
+
+ a7 + a8 + a9 +
+
---- -means +The following query will find a vector of `div` tags, then return a set of all `a` tags under those `div` tags: [source,clojure] ---- - {:tag :div} -> [div1 div2 div3] - div1 -> [a1 a2 a3] - div2 -> [a4 a5 a6] - div3 -> [a7 a8 a9] +(->> (e/query-tree driver :query-tree-example {:tag :div} {:tag :a}) + (map #(e/get-element-text-el driver %)) + sort) +;; => ("a1" "a2" "a3" "a4" "a5" "a6" "a7" "a8" "a9") ---- -so the result will be `[a1 ... a9]` +=== Interacting with Queried Elements -=== Interacting with queried elements - -To interact with elements found via a query you have to pass the query result to either `click-el` or `fill-el`: +To interact with elements found via a `query` or `query-all` function call you have to pass the query result to either `click-el` or `fill-el` (note the `-el` suffix): [source,clojure] ---- -(click-el driver (first (query-all driver {:tag :a}))) +(e/click-el driver (first (e/query-all driver {:tag :a}))) ---- -So you may collect elements into a vector and arbitrarily interact with them at any time: +You can collect elements into a vector and arbitrarily interact with them at any time: [source,clojure] ---- -(def elements (query-all driver {:tag :input :type :text}) +(e/refresh driver) +(def elements (e/query-all driver {:tag :input :type :text :fn/disabled false})) -(fill-el driver (first elements) "This is a test") -(fill-el driver (rand-nth elements) "I like tests!") +(e/fill-el driver (first elements) "This is a test") +(e/fill-el driver (rand-nth elements) "I like tests!") ---- -== Emulation of human input +== Interactions + +Some basic interactions are covered under <>, here we go into other types of interactions and more detail. -For the purpose of emulating human input, you can use the `fill-human` function. +=== Emulate how a Real Person Might Type + +Real people type slowly and make mistakes. +To emulate these characteristics, you can use the `fill-human` function. The following options are enabled by default: [source,clojure] @@ -572,35 +821,38 @@ The following options are enabled by default: :pause-max 0.2} ;; max typing delay in seconds ---- -and you can redefine them: +which you can choose to override if you wish: [source,clojure] ---- -(fill-human driver q text {:mistake-prob 0.5 - :pause-max 1}) +(e/refresh driver) +(e/fill-human driver :uname "soslowsobad" + {:mistake-prob 0.5 + :pause-max 1}) -;; or just use default opts by omitting them -(fill-human driver q text) +;; or just use default options by omitting them +(e/fill-human driver :uname " typing human defaults") ---- -for multiple input with human emulation, use `fill-human-multi` +For multiple inputs, use `fill-human-multi` [source,clojure] ---- -(fill-human-multi driver {:login "login" - :pass "password" - :textarea "some text"} - {:mistake-prob 0.5 - :pause-max 1}) +(e/refresh driver) +(e/fill-human-multi driver {:uname "login" + :pw "password" + :text "some text"} + {:mistake-prob 0.1 + :pause-max 0.1}) ---- -== Mouse clicks +=== Mouse Clicks The `click` function triggers the left mouse click on an element found by a query term: [source,clojure] ---- -(click driver {:tag :button}) +(e/click driver {:tag :button}) ---- The `click` function uses only the first element found by the query, which sometimes leads to clicking on the wrong items. @@ -609,1125 +861,1463 @@ It acts the same but raises an exception when querying the page returns multiple [source,clojure] ---- -(click-single driver {:tag :button :name "search"}) +(e/click-single driver {:tag :button :name "submit"}) ---- -A double click is used rarely in web yet is possible with the `double-click` function (Chrome, Phantom.js): +Although double-clicking is rarely purposefully employed on web sites, some naive users might think it is the correct way to click on a button or link. + +A double-click can be simulated with `double-click` function (Chrome, Phantom.js). +It can be used, for example, to check your handling of disallowing multiple form submissions. [source,clojure] ---- -(double-click driver {:tag :dbl-click-btn}) +(e/double-click driver {:tag :button :name "submit"}) ---- -There is also a bunch of "blind" clicking functions. +There are also "blind" clicking functions. They trigger mouse clicks on the current mouse position: [source,clojure] ---- -(left-click driver) -(middle-click driver) -(right-click driver) +(e/left-click driver) +(e/middle-click driver) +(e/right-click driver) ---- -Another bunch of functions do the same but move the mouse pointer to a specified element before clicking on them: +Another set of functions do the same but move the mouse pointer to a specified element before clicking on them: [source,clojure] ---- -(left-click-on driver {:tag :img}) -(middle-click-on driver {:tag :img}) -(right-click-on driver {:tag :img}) +(e/left-click-on driver {:tag :a}) +(e/middle-click-on driver {:tag :a}) +(e/right-click-on driver {:tag :a}) ---- -A middle mouse click is useful when opening a link in a new background tab. +A middle mouse click can open a link in a new background tab. The right click sometimes is used to imitate a context menu in web applications. -== Actions +=== Keyboard Chords -The library supports link:{actions}[Webdriver Actions]. -In general, actions are commands describing virtual input devices. - -[source,clojure] ----- -{:actions [{:type "key" - :id "some name" - :actions [{:type "keyDown" :value cmd} - {:type "keyDown" :value "a"} - {:type "keyUp" :value "a"} - {:type "keyUp" :value cmd} - {:type "pause" :duration 100}]} - {:type "pointer" - :id "UUID or some name" - :parameters {:pointerType "mouse"} - :actions [{:type "pointerMove" :origin "pointer" :x 396 :y 323} - ;; double click - {:type "pointerDown" :duration 0 :button 0} - {:type "pointerUp" :duration 0 :button 0} - {:type "pointerDown" :duration 0 :button 0} - {:type "pointerUp" :duration 0 :button 0}]}]} ----- +There is an option to input a series of keys simultaneously. +This useful to imitate holding a system key like Control, Shift or whatever when typing. -You can create a map manually and send it to the `perform-actions` method: +The namespace `etaoin.keys` includes key constants as well as a set of functions related to keyboard input. [source,clojure] ---- -(def keyboard-input {:type "key" - :id "some name" - :actions [{:type "keyDown" :value cmd} - {:type "keyDown" :value "a"} - {:type "keyUp" :value "a"} - {:type "keyUp" :value cmd} - {:type "pause" :duration 100}]}) - -(perform-actions driver keyboard-input) +(require '[etaoin.keys :as k]) ---- -or use wrappers. -First you need to create a virtual input devices, for example: +A quick example of entering ordinary characters while holding Shift: [source,clojure] ---- -(def keyboard (make-key-input)) +(e/refresh driver) +(e/wait 1) ;; maybe we need a sec for active element to focus +(e/fill-active driver (k/with-shift "caps is great")) +(e/get-element-value driver :active) +;; => "CAPS IS GREAT" ---- -and then fill it with the necessary actions: +The main input gets populated with "CAPS IS GREAT". +Now maybe you'd like to delete the last word. +Assuming you are using Chrome, this is done by pressing backspace holding Alt. +Let's do that: [source,clojure] ---- -(-> keyboard - (add-key-down keys/shift-left) - (add-key-down "a") - (add-key-up "a") - (add-key-up keys/shift-left)) +(e/fill-active driver (k/with-alt k/backspace)) +(e/get-element-value driver :active) +;; => "CAPS IS " ---- -extended example: +Consider a more complex example which repeats real user behaviour. +You'd like to delete everything from the input. +First, you move the cursor to the very beginning of the input field. +Then move it to the end holding shift so everything gets selected. +Finally, you press delete to clear the selected text: [source,clojure] ---- -(let [driver (chrome) - _ (go driver "https://google.com") - search-box (query driver {:name :q}) - mouse (-> (make-mouse-input) - (add-pointer-click-el search-box)) - keyboard (-> (make-key-input) - add-pause - (with-key-down keys/shift-left - (add-key-press "e")) - (add-key-press "t") - (add-key-press "a") - (add-key-press "o") - (add-key-press "i") - (add-key-press "n") - (add-key-press keys/enter))] - (perform-actions driver keyboard mouse) - (quit driver)) +(e/fill-active driver k/home (k/with-shift k/end) k/delete) +(e/get-element-value driver :active) +;; => "" ---- -To clear the state of virtual input devices, release all pressed keys etc, use the `release-actions` method: +There are also `with-ctrl` and `with-command` functions that act as you would expect. -[source,clojure] ----- -(release-actions driver) ----- +NOTE: These functions do not apply to the global browser's shortcuts. +For example, neither "Command + R" nor "Command + T" reload the page or open a new tab. -== File uploading +All the `etaoin.keys/with-*` functions are just wrappers upon the `etaoin.keys/chord` function that might be used for complex cases. -Clicking on a file input button opens an OS-specific dialog that you are not allowed to interact with using WebDriver protocol. +=== File Uploading + +Clicking on a file input button opens an OS-specific dialog. +You technically cannot interact with this dialog using the WebDriver protocol. Use the `upload-file` function to attach a local file to a file input widget. -The function takes a selector that points to a file input and either a full path as a string or a native `java.io.File` instance. -The file should exist or you'll get an exception otherwise. -Usage example: +An exception will be thrown if the local file is not found. [source,clojure] ---- -(def driver (chrome)) - ;; open a web page that serves uploaded files -(go driver "http://nervgh.github.io/pages/angular-file-upload/examples/simple/") +(e/go driver "http://nervgh.github.io/pages/angular-file-upload/examples/simple/") -;; bound selector to variable; you may also specify an id, class, etc -(def input {:tag :input :type :file}) +;; bind element selector to variable; you may also specify an id, class, etc +(def file-input {:tag :input :type :file}) -;; upload an image with the first one file input -(def my-file "/Users/ivan/Downloads/sample.png") -(upload-file driver input my-file) +;; upload a file form your system to the first file input +(def my-file "env/test/resources/html/drag-n-drop/images/document.png") +(e/upload-file driver file-input my-file) -;; or pass a native Java object: +;; or pass a native Java File object: (require '[clojure.java.io :as io]) -(def my-file (io/file "/Users/ivan/Downloads/sample.png")) -(upload-file driver input my-file) +(def my-file (io/file "env/test/resources/html/drag-n-drop/images/document.png")) +(e/upload-file driver file-input my-file) ---- -== Screenshots +=== Scrolling -Calling a `screenshot` function dumps the current page into a PNG image on your disk: +Etaoin includes functions to scroll the web page. + +The most important one, `scroll-query` jumps the the first element found with the query term: [source,clojure] ---- -(screenshot driver "page.png") ;; relative path -(screenshot driver "/Users/ivan/page.png") ;; absolute path +(e/go driver sample-page) +;; scroll to the 5th h2 heading +(e/scroll-query driver {:tag :h2} {:index 5}) + +;; and back up to first h1 +(e/scroll-query driver {:tag :h1}) ---- -A native Java File object is also supported: +To jump to the absolute pixel positions, use `scroll`: [source,clojure] ---- -;; when imported as `[clojure.java.io :as io]` -(screenshot driver (io/file "test.png")) - -;; native object -(screenshot driver (java.io.File. "test-native.png")) +(e/scroll driver 100 600) +;; or pass a map with x and y keys +(e/scroll driver {:x 100 :y 600}) ---- -=== Screening elements - -With Firefox and Chrome, you may capture not the whole page but a single element, say a div, an input widget or whatever. -It doesn't work with other browsers for now. -Example: +To scroll relatively by pixels, use `scroll-by` with offset values: [source,clojure] ---- -(screenshot-element driver {:tag :div :class :smart-widget} "smart_widget.png") +;; scroll right by 100 and down by 300 +(e/scroll-by driver 100 300) +;; use map syntax to scroll left by 50 and up by 200 +(e/scroll-by driver {:x -50 :y -200}) ---- -=== Screening after each form - -With macro `with-screenshots`, you can make a screenshot after each form +There are two convenience functions to scroll vertically to the top or bottom of the page: [source,clojure] ---- -(with-screenshots driver "../screenshots" - (fill driver :simple-input "1") - (fill driver :simple-input "2") - (fill driver :simple-input "3")) +(e/scroll-bottom driver) ;; you'll see the footer... +(e/scroll-top driver) ;; ...and the header again ---- -what is equivalent to a record: +The following functions scroll the page in all directions: [source,clojure] ---- -(fill driver :simple-input "1") -(screenshot driver "../screenshots/chrome-...123.png") -(fill driver :simple-input "2") -(screenshot driver "../screenshots/chrome-...124.png") -(fill driver :simple-input "3") -(screenshot driver "../screenshots/chrome-...125.png") ----- +(e/scroll driver [0 0]) ;; let's start at top left -== Using headless drivers +(e/scroll-down driver 200) ;; scrolls down by 200 pixels +(e/scroll-down driver) ;; scrolls down by the default (100) number of pixels -Recently, Google Chrome and later Firefox started support a feature named headless mode. -When being headless, none of UI windows occur on the screen, only the stdout output goes into console. -This feature allows you to run integration tests on servers that do not have graphical output device. +(e/scroll-up driver 200) ;; the same, but scrolls up... +(e/scroll-up driver) -Ensure your browser supports headless mode by checking if it accepts `--headles` command line argument when running it from the terminal. -Phantom.js driver is headless by its nature (it has never been developed for rendering UI). +(e/scroll-right driver 200) ;; ... and right +(e/scroll-right driver) -When starting a driver, pass `:headless` boolean flag to switch into headless mode. -Note, only latest version of Chrome and Firefox are supported. -For other drivers, the flag will be ignored. +(e/scroll-left driver 200) ;; ...left +(e/scroll-left driver) -[source,clojure] ----- -(def driver (chrome {:headless true})) ;; runs headless Chrome ---- -or +NOTE: All scroll actions are carried out via Javascript. +Ensure your browser has it enabled. -[source,clojure] ----- -(def driver (firefox {:headless true})) ;; runs headless Firefox ----- +=== Working with frames and iframes -To check of any driver has been run in headless mode, use `headless?` predicate: +You can only interact with items within an individual frame or iframe by first swithing to them. -[source,clojure] +Say you have an HTML layout like this: + +[source,html] ---- -(headless? driver) ;; true + + ---- -Note, it will always return true for Phantom.js instances. - -There are several shortcuts to run Chrome or Firefox in headless mode by default: +Let's explore switching to `:frame1`. [source,clojure] ---- -(def driver (chrome-headless)) +(e/go driver sample-page) +;; we start in the main page, we can't see inside frame1: +(e/exists? driver :in-frame1) +;; => false -;; or +;; switch context to frame with id of frame1: +(e/switch-frame driver :frame1) -(def driver (firefox-headless {...})) ;; with extra settings +;; now we can interact with elements in frame1: +(e/exists? driver :in-frame1) +;; => true +(e/get-element-text driver :in-frame1) +;; => "In frame1 paragraph" -;; or +;; switch back to top frame (the main page) +(e/switch-frame-top driver) +---- -(with-chrome-headless nil driver - (go driver "http://example.com")) +To reach nested frames, you can dig down like so: -(with-firefox-headless {...} driver ;; extra settings - (go driver "http://example.com")) +[source,clojure] +---- +;; switch to the first top-level iframe with the main page: frame1 +(e/switch-frame-first driver) +;; downward to the first iframe with frame1: frame2 +(e/switch-frame-first driver) +(e/get-element-text driver :in-frame2) +;; => "In frame2 paragraph" +;; back up to frame1 +(e/switch-frame-parent driver) +;; back up to main page +(e/switch-frame-parent driver) ---- -There are also `when-headless` and `when-not-headless` macroses that allow to perform a bunch of commands only if a browser is in headless mode or not respectively: +Use the `with-frame` macro to temporarily switch to a target frame, do some work, returning its last expression, while preserving your original frame context. [source,clojure] ---- -(with-chrome nil driver - ... - (when-not-headless driver - ... some actions that might be not available in headless mode) - ... common actions for both versions) +(e/with-frame driver {:id :frame1} + (e/with-frame driver {:id :frame2} + (e/get-element-text driver :in-frame2))) +;; => "In frame2 paragraph" ---- -== Connection to remote webdriver +=== Executing Javascript -To connect to a driver already running on a local or remote host, you must specify the `:host` parameter which might be either a hostname (localhost, some.remote.host.net) or an IP address (127.0.0.1, 183.102.156.31) and the `:port`. -If the port is not specified, the default port is set. - -Example: +Use `js-execute` to evaluate a Javascript code in the browser: [source,clojure] ---- -;; Chrome -(def driver (chrome {:host "127.0.0.1" :port 9515})) ;; for connection to driver on localhost on port 9515 +(e/js-execute driver "alert('Hello from Etaoin!')") +(e/dismiss-alert driver) +---- -;; Firefox -(def driver (firefox {:host "192.168.1.11"})) ;; the default port for firefox is 4444 +Pass any additional parameters to the script with the `arguments` array-like object. +[source,clojure] +---- +(e/js-execute driver "alert(arguments[2].foo)" 1 false {:foo "hello again!"}) +(e/dismiss-alert driver) ---- -== Webdriver in Docker +We have passed 3 arguments: -To work with the driver in Docker, you can take ready-made images: +. `1` +. `false` +. `{:foo "hello again!}` which is automatically converted to JSON `{"foo": "hello again!"}` -Example for https://hub.docker.com/r/robcherry/docker-chromedriver/[Chrome]: +The alert then presents the `foo` field of the 3rd (index 2) argument, which is `"hello again!"`. +To return any data to Clojure, add `return` into your script: + +[source,clojure] ---- -docker run --name chromedriver -p 9515:4444 -d -e CHROMEDRIVER_WHITELISTED_IPS='' robcherry/docker-chromedriver:latest +(e/js-execute driver "return {foo: arguments[2].foo, bar: [1, 2, 3]}" + ;; same args as previous example: + 1 false {:foo "hello again!"}) +;; => {:bar [1 2 3], :foo "hello again!"} ---- -for https://hub.docker.com/r/instrumentisto/geckodriver[Firefox]: +Notice that the JSON has been automatically converted to edn. ----- -docker run --name geckodriver -p 4444:4444 -d instrumentisto/geckodriver ----- +==== Asynchronous Scripts + +Use `js-async` to deal with scripts that rely on async strategies such as `setTimeout`. +The WebDriver creates and passes a callback as the last argument to your script. +To indicate that work is complete, you must call this callback. -To connect to the driver you just need to specify the `:host` parameter as `localhost` or `127.0.0.1` and the `:port` on which it is running. -If the port is not specified, the default port is set. +Example: [source,clojure] ---- -(def driver (chrome-headless {:host "localhost" :port 9515 :args ["--no-sandbox"]})) -(def driver (firefox-headless {:host "localhost"})) ;; will try to connect to port 4444 +(e/js-async + driver + "var args = arguments; // preserve the global args + // WebDriver added the callback as the last arg, we grab it here + var callback = args[args.length-1]; + setTimeout(function() { + // We call the WebDriver callback passing with what we want it to return + // In this case we pass we chose to return 42 from the arg we passed in + callback(args[0].foo.bar.baz); + }, + 1000);" + {:foo {:bar {:baz 42}}}) +;; => 42 ---- -== HTTP Proxy - -To set proxy settings use environment variables `HTTP_PROXY`/`HTTPS_PROXY` or pass a map of the following type: +If you'd like to override the default script timeout, you can do so for the WebDriver session: [source,clojure] ---- -{:proxy {:http "some.proxy.com:8080" - :ftp "some.proxy.com:8080" - :ssl "some.proxy.com:8080" - :socks {:host "myproxy:1080" :version 5} - :bypass ["http://this.url" "http://that.url"] - :pac-url "localhost:8888"}} - -;; example -(chrome {:proxy {:http "some.proxy.com:8080" - :ssl "some.proxy.com:8080"}}) +;; optionally save the current value for later restoration +(def orig-script-timeout (e/get-script-timeout driver)) +(e/set-script-timeout driver 5) ;; in seconds +;; do some stuff +(e/set-script-timeout driver orig-script-timeout) ---- -NOTE: A :pac-url for a https://en.wikipedia.org/wiki/Proxy_auto-config#The_PAC_File[proxy autoconfiguration file]. -Used with Safari as the other proxy options do not work in that browser. - -To fine tune the proxy you can use the original https://www.w3.org/TR/webdriver/#proxy[object] and pass it to capabilities: +or for a block of code via `with-script-timeout`: +//:test-doc-blocks/skip [source,clojure] ---- -{:capabilities {:proxy {:proxyType "manual" - :proxyAutoconfigUrl "some.proxy.com:8080" - :ftpProxy "some.proxy.com:8080" - :httpProxy "some.proxy.com:8080" - :noProxy ["http://this.url" "http://that.url"] - :sslProxy "some.proxy.com:8080" - :socksProxy "some.proxy.com:1080" - :socksVersion 5}}} - -(chrome {:capabilities {:proxy {...}}}) +(e/with-script-timeout driver 30 + (e/js-async driver "var callback = arguments[arguments.length-1]; + //some long operation here + callback('phew,done!');")) +;; => "phew,done!" ---- -== Devtools: tracking HTTP requests, XHR (Ajax) +=== Wait Functions -With recent updates, the library brings a great feature. -Now you can trace events which come from the DevTools panel. -It means, everything you see in the developer console now is available through API. -That works only with Google Chrome now. +The main difference between a program and a human being is that the first one operates very fast. +A computer operates so fast, that sometimes a browser cannot render new HTML in time. +After each action, you might consider including a `wait-` function that polls a browser until the predicate evaluates to true. +Or just `(wait )` if you don't care about optimization. -To start a driver with special development settings specified, just pass an empty map to the `:dev` field when running a driver: +The `with-wait` macro might be helpful when you need to prepend each action with `(wait n)`. +For example, the following form: [source,clojure] ---- -(def c (chrome {:dev {}})) +(e/with-wait 1 + (e/refresh driver) + (e/fill driver :uname "my username") + (e/fill driver :text "some text")) ---- -The value must not be `nil`. -When it's an empty map, a special function takes defaults. -Here is a full version of dev settings with all the possible values specified. +is executed something along the lines of: [source,clojure] ---- -(def c (chrome {:dev - {:perf - {:level :all - :network? true - :page? true - :interval 1000 - :categories [:devtools - :devtools.network - :devtools.timeline]}}})) +(e/wait 1) +(e/refresh driver) +(e/wait 1) +(e/fill driver :uname "my username") +(e/wait 1) +(e/fill driver :text "some text") ---- -Under the hood, it fills a special `perfLoggingPrefs` dictionary inside the `chromeOptions` object. +and thus returns the result of the last form of the original body. -Now that your browser accumulates these events, you can read them using a special `dev` namespace. +The `(doto-wait n driver & body)` acts like the standard `doto` but prepends each form with `(wait n)`. +The above example re-expressed with `doto-wait`: [source,clojure] ---- -(go c "http://google.com") -;; wait until the page gets loaded - -;; load the namespace -(require '[etaoin.dev :as dev]) +(e/doto-wait 1 driver + (e/refresh) + (e/fill :uname "my username") + (e/fill :text "some text")) ---- -Let's have a list of ALL the HTTP requests happened during the page was loading. +This is effectively the same as: [source,clojure] ---- -(def reqs (dev/get-requests c)) - -;; reqs is a vector of maps -(count reqs) -;; 19 - -;; what were their types? -(set (map :type reqs)) -;; #{:script :other :document :image :xhr} -;; we've got Js requests, images, AJAX and other stuff +(doto driver + (e/wait 1) + (e/refresh) + (e/wait 1) + (e/fill :uname "my username") + (e/wait 1) + (e/fill :text "some text")) ---- +In addition to `with-wait` and `do-wait` there are a number of waiting functions: `wait-visible`, `wait-has-alert`, `wait-predicate`, etc (see the full list in the link:{url-doc}/CURRENT/api/etaoin.api#wait[API docs]. +They accept default timeout/interval values that can be redefined using the `with-wait-timeout` and `with-wait-interval` macros, respectively. +They all throw if the wait timeout is exceeded. + [source,clojure] ---- -;; check the last one request, it's an image named tia.png -(-> reqs last clojure.pprint/pprint) - -{:state 4, - :id "1000052292.8", - :type :image, - :xhr? false, - :url "https://www.gstatic.com/inputtools/images/tia.png", - :with-data? nil, - :request - {:method :get, - :headers - {:Referer "https://www.google.com/", - :User-Agent - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"}}, - :response - {:status 200, - :headers {}, ;; truncated - :mime "image/png", - :remote-ip "173.194.73.94"}, - :done? true} +(e/with-wait-timeout 15 ;; time in seconds + (doto driver + (e/refresh) + (e/wait-visible {:id :last-section}) + (e/click {:tag :a}) + (e/wait-has-text :clicked "link 1"))) ---- -Since we're mostly interested in AJAX requests, there is a function `get-ajax` that does the same but filters XHR requests: +Wait text: +* `wait-has-text` waits until an element has text anywhere inside it (including inner HTML). ++ [source,clojure] ---- -(-> c dev/get-ajax last clojure.pprint/pprint) - -{:state 4, - :id "1000051989.41", - :type :xhr, - :xhr? true, - :url - "https://www.google.com/complete/search?q=clojure%20spec&cp=12&client=psy-ab&xssi=t&gs_ri=gws-wiz&hl=ru&authuser=0&psi=4iUbXdapJsbmrgTVt7H4BA.1562060259137&ei=4iUbXdapJsbmrgTVt7H4BA", - :with-data? nil, - :request - {:method :get, - :headers - {:Referer "https://www.google.com/", - :User-Agent - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"}}, - :response - {:status 200, - :headers {}, ;; truncated - :mime "application/json", - :remote-ip "74.125.131.99"}, - :done? true} +(e/click driver {:tag :a}) +(e/wait-has-text driver :clicked "link 1") ---- -A typical pattern of `get-ajax` usage is the following. -You'd like to check if a certain request has been fired to the server. -So you press a button, wait for a while and then read the requests made by your browser. - -Having a list of requests, you search for the one you need (e.g. -by its URL) and then check its state. -The `:state` field's got the same semantics like the `XMLHttpRequest.readyState` has. -It's an integer from 1 to 4 with the same behavior. - -To check if a request has been finished, done or failed, use these predicates: - +* `wait-has-text-everywhere` like `wait-has-text` but searches for text across the entire page ++ [source,clojure] ---- -(def req (last reqs)) - -(dev/request-done? req) -;; true +(e/wait-has-text-everywhere driver "ipsum") +---- -(dev/request-failed? req) -;; false +=== Load Strategy [[load-strategy]] -(dev/request-success? req) -;; true ----- +When you navigate to a page, the driver waits until the whole page has been completely loaded. +That's fine in most cases but doesn't reflect the way human beings interact with the Internet. -Note that `request-done?` doesn't mean the request has succeeded. -It only means its pipeline has reached a final step. +Change this default behavior with the `:load-strategy` option: -WARNING: when you read dev logs, you consume them from an internal buffer which gets flushed. -The second call to `get-requests` or `get-ajax` will return an empty list. +* `:normal` (the default) wait for full page load (everything, include images, etc) +* `:none` don't wait at all +* `:eager` wait for only DOM content to load -Perhaps you want to collect these logs by your own. -A function `dev/get-performance-logs` return a list of logs so you accumulate them in an atom or whatever: +For example, the default `:normal` strategy: [source,clojure] ---- -(def logs (atom [])) - -;; repeat that form from time to time -(do (swap! logs concat (dev/get-performance-logs c)) - true) - -(count @logs) -;; 76 +(e2/with-chrome [driver] + (e/go driver sample-page) + ;; by default you'll hang on this line until the page loads + ;; (do-something) +) ---- -There are `+logs->requests+` and `+logs->ajax+` functions that convert logs into requests. -Unlike `get-requests` and `get-ajax`, they are pure functions and won't flush anything. +Load strategy option of `:none`: [source,clojure] ---- -(dev/logs->requests @logs) +(e2/with-chrome [driver {:load-strategy :none}] + (e/go driver sample-page) + ;; no pause, no waiting, acts immediately + ;; (do-something) +) ---- -When working with logs and requests, pay attention it their count and size. -The maps have got plenty of keys and the amount of items in collections might be huge. -Printing a whole bunch of events might freeze your editor. -Consider using `clojure.pprint/pprint` function as it relies on max level and length limits. +The `:eager` option only works with Firefox at the moment. -== Postmortem: auto-save artifacts in case of exception +=== Actions -Sometimes, it might be difficult to discover what went wrong during the last UI tests session. -A special macro `with-postmortem` saves some useful data on disk before the exception was triggered. -Those data are a screenshot, HTML code and JS console logs. -Note: not all browsers support getting JS logs. +Etaoin supports link:{actions}[Webdriver Actions]. +They are described as "virtual input devices". +They act as little device input scripts that run simultaneously. -Example: +Here, in raw form, we have an example of two actions. +One controls the keyboard, the other the pointer (mouse). [source,clojure] ---- -(def driver (chrome)) -(with-postmortem driver {:dir "/Users/ivan/artifacts"} - (click driver :non-existing-element)) +;; a keyboard input +{:type "key" + :id "some name" + :actions [{:type "keyDown" :value "a"} + {:type "keyUp" :value "a"} + {:type "pause" :duration 100}]} +;; some pointer input +{:type "pointer" + :id "UUID or some name" + :parameters {:pointerType "mouse"} + :actions [{:type "pointerMove" :origin "pointer" :x 396 :y 323} + ;; double click + {:type "pointerDown" :duration 0 :button 0} + {:type "pointerUp" :duration 0 :button 0} + {:type "pointerDown" :duration 0 :button 0} + {:type "pointerUp" :duration 0 :button 0}]} ---- -An exception will rise, but in `/Users/ivan/artifacts` there will be three files named by a template `---.`: +You can create a map manually and send it to the `perform-actions` method: -* `firefox-127.0.0.1-4444-2017-03-26-02-45-07.png`: an actual screenshot of the browser's page; -* `firefox-127.0.0.1-4444-2017-03-26-02-45-07.html`: the current browser's HTML content; -* `firefox-127.0.0.1-4444-2017-03-26-02-45-07.json`: a JSON file with console logs; -those are a vector of objects. +[source,clojure] +---- +(def keyboard-input {:type "key" + :id "some name" + :actions [{:type "keyDown" :value "e"} + {:type "keyUp" :value "e"} + {:type "keyDown" :value "t"} + {:type "keyUp" :value "t"} + ;; duration is in ms + {:type "pause" :duration 100}]}) +;; refresh so that we'll be at the active input field +(e/refresh driver) +;; perform our keyboard input action +(e/perform-actions driver keyboard-input) +---- -The handler takes a map of options with the following keys. -All of them might be absent. +Or you might choose to use Etaoin's action helpers. +First you create the virtual input device: [source,clojure] ---- -{;; default directory where to store artifacts - ;; might not exist, will be created otherwise. pwd is used when not passed - :dir "/home/ivan/UI-tests" +(def keyboard (e/make-key-input)) +---- - ;; a directory where to store screenshots; :dir is used when not passed - :dir-img "/home/ivan/UI-tests/screenshots" +and then fill it with the actions: - ;; the same but for HTML sources - :dir-src "/home/ivan/UI-tests/HTML" +[source,clojure] +---- +(-> keyboard + (e/add-key-down k/shift-left) + (e/add-key-down "a") + (e/add-key-up "a") + (e/add-key-up k/shift-left)) +---- - ;; the same but for console logs - :dir-log "/home/ivan/UI-tests/console" +Here's a slightly larger working annotated example: - ;; a string template to format a date; See SimpleDateFormat Java class - :date-format "yyyy-MM-dd-HH-mm-ss"} +[source,clojure] ---- +;; virtual inputs run simultaneously so we'll create a little helper to generate n pauses +(defn add-pauses [input n] + (->> (iterate e/add-pause input) + (take (inc n)) + last)) -== Reading browser's logs +(let [username (e/query driver :uname) + submit-button (e/query driver {:tag :button}) + mouse (-> (e/make-mouse-input) + ;; click on username + (e/add-pointer-click-el + username k/mouse-left) + ;; pause 10 clicks to allow keyboard action to enter username + ;; (key up and down for each of keypress for etaoin) + (add-pauses 10) + ;; click on submit button + (e/add-pointer-click-el + submit-button k/mouse-left)) + keyboard (-> (e/make-key-input) + ;; pause 2 ticks to allow mouse action to first click on username + ;; (move to username element + click on it) + (add-pauses 2) + (e/with-key-down k/shift-left + (e/add-key-press "e")) + (e/add-key-press "t") + (e/add-key-press "a") + (e/add-key-press "o") + (e/add-key-press "i") + (e/add-key-press "n")) ] + (e/perform-actions driver keyboard mouse)) +---- -Function `(get-logs driver)` returns the browser's logs as a vector of maps. -Each map has the following structure: +To clear the state of virtual input devices, release all currently pressed keys etc, use the `release-actions` method: [source,clojure] ---- -{:level :warning, - :message "1,2,3,4 anonymous (:1)", - :timestamp 1511449388366, - :source nil, - :datetime #inst "2017-11-23T15:03:08.366-00:00"} +(e/release-actions driver) ---- -Currently, logs are available in Chrome and Phantom.js only. -Please note, the message text and the source type highly depend on the browser. -Chrome wipes the logs once they have been read. -Phantom.js keeps them but only until you change the page. - -== Additional parameters +== Capturing Screenshots -When running a driver instance, a map of additional parameters might be passed to tweak the browser's behaviour: +Calling the `screenshot` function dumps the current visible page into a PNG image file on your disk. +Specify any absolute or relative path. +Specify a string: [source,clojure] ---- -(def driver (chrome {:path "/path/to/driver/binary"})) +(e/screenshot driver "target/etaoin-play/page.png") ---- -Below, here is a map of parameters the library support. -All of them might be skipped or have nil values. -Some of them, if not passed, are taken from the `defaults` map. +or a `File` object: [source,clojure] ---- -{;; Host and port for webdriver's process. Both are taken from defaults - ;; when are not passed. If you pass a port that has been already taken, - ;; the library will try to take a random one instead. - :host "127.0.0.1" - :port 9999 +(require '[clojure.java.io :as io]) +(e/screenshot driver (io/file "target/etaoin-play/test.png")) +---- - ;; Path to webdriver's binary file. Taken from defaults when not passed. - :path-driver "/Users/ivan/Downloads/geckodriver" +=== Screenshots for Specific Elements - ;; Path to the driver's binary file. When not passed, the driver discovers it - ;; by its own. - :path-browser "/Users/ivan/Downloads/firefox/firefox" +With Firefox and Chrome, you can also capture a single element within a page, say a div, an input widget, or whatever. +It doesn't work with other browsers at this time. - ;; Extra command line arguments sent to the browser's process. See your browser's - ;; supported flags. - :args ["--incognito" "--app" "http://example.com"] +[source,clojure] +---- +(e/screenshot-element driver {:tag :form :class :formy} "target/etaoin-play/form-element.png") +---- - ;; Extra command line arguments sent to the webdriver's process. - :args-driver ["-b" "/path/to/firefox/binary"] +=== Screenshots after each form - ;; Sets browser's minimal logging level. Only messages with level above - ;; that one will be collected. Useful for fetching Javascript logs. Possible - ;; values are: nil (aliases :off, :none), :debug, :info, :warn (alias :warning), - ;; :err (aliases :error, :severe, :crit, :critical), :all. When not passed, - ;; :all is set. - :log-level :err ;; to show only errors but not debug +Use `with-screenshots` to take a screenshot to the specified directory after each form is executed in the code block. +The file naming convention is `-.png` - ;; Sets driver's log level. - ;; The value is a string. Possible values are: - ;; chrome: [ALL, DEBUG, INFO, WARNING, SEVERE, OFF] - ;; phantomjs: [ERROR, WARN, INFO, DEBUG] (default INFO) - ;; firefox [fatal, error, warn, info, config, debug, trace] - :driver-log-level +[source,clojure] +---- +(require '[clojure.java.io :as io]) - ;; Paths to the driver's log files as strings. - ;; When not set, the output goes to /dev/null (or NUL on Windows) - :log-stdout - :log-stderr +(e/refresh driver) +(.mkdirs (io/file "target/etaoin-play/saved-screenshots")) +(e/with-screenshots driver "target/etaoin-play/saved-screenshots" + (e/fill driver :uname "et") + (e/fill driver :uname "ao") + (e/fill driver :uname "in")) +---- + +this is equivalent to something along the lines of: - ;; Path to a custorm browser profile. See the section below. - :profile "/Users/ivan/Library/Application Support/Firefox/Profiles/iy4iitbg.Test" +[source,clojure] +---- +(require '[clojure.java.io :as io]) - ;; Env variables sent to the driver's process. - :env {:MOZ_CRASHREPORTER_URL "http://test.com"} +(e/refresh driver) +(.mkdirs (io/file "target/etaoin-play/saved-screenshots")) +(e/fill driver :uname "et") +(e/screenshot driver "target/etaoin-play/saved-screenshots/chrome-1.png") +(e/fill driver :uname "ao") +(e/screenshot driver "target/etaoin-play/saved-screenshots/chrome-2.png") +(e/fill driver :uname "in") +(e/screenshot driver "target/etaoin-play/saved-screenshots/chrome-3.png") +---- - ;; Initial window size. - :size [1024 680] +== Peeking deeper - ;; Default URL to open. Works only in FF for now. - :url "http://example.com" +Sometimes it is useful to peek a little deeper. - ;; Override the default User-Agent. Useful for headless mode. - :user-agent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)" +=== Reading a Browser's Console Logs [[console-logs]] - ;; Where to download files. - :download-dir "/Users/ivan/Desktop" +Function `get-logs` returns the browser's console logs as a vector of maps. +Each map has the following structure: - ;; Driver-specific options. Make sure you have read the docs before setting them. - :capabilities {:chromeOptions {:args ["--headless"]}}} +// note that we do not verify get-logs output with test-doc-blocks by omitting => +[source,clojure] ---- +(e/js-execute driver "console.log('foo')") +(e/get-logs driver) +;; [{:level :info, +;; :message "console-api 2:32 \"foo\"", +;; :source :console-api, +;; :timestamp 1654358994253, +;; :datetime #inst "2022-06-04T16:09:54.253-00:00"}] -== Eager page load +;; on the 2nd call, for chrome, we'll find the logs empty +(e/get-logs driver) +;; => [] +---- -When you navigate to a certain page, the driver waits until the whole page has been completely loaded. -What's fine in most of the cases yet doesn't reflect the way human beings interact with the Internet. +Currently, logs are available in Chrome and Phantom.js only. +The message text and the source type will vary by browser vendor. +Chrome wipes the logs once they have been read. +Phantom.js wipes the logs when the page location changes. + +=== DevTools: Tracking HTTP Requests, XHR (Ajax) -Change this default behavior with the `:load-strategy` option. -There are three possible values for that: `:none`, `:eager` and `:normal` which is the default when not passed. +You can trace events that come from the DevTools panel. +This means that everything you see in the developer console now is available through the Etaoin API. +This currently only works for Google Chrome. -When you pass `:none`, the driver responds immediately so you are welcome to execute next instructions. -For example: +To start a driver with devtools support enabled specify a `:dev` map. +//let's put this drier in its own namespace +//{:test-doc-blocks/test-ns user-guide-devtools-test} [source,clojure] ---- -(def c (chrome)) -(go c "http://some.slow.site.com") -;; you'll hang on this line until the page loads -(do-something) +(require '[etaoin.api2 :as e2]) + +(e2/with-chrome [driver {:dev {}}] + ;; do some stuff +) ---- -Now when passing the load strategy option: +The value must not be a map (not `nil`). +When `:dev` an empty map, the following defaults are used. [source,clojure] ---- -(def c (chrome {:load-strategy :none})) -(go c "http://some.slow.site.com") -;; no pause, acts immediately -(do-something) +{:perf + {:level :all + :network? true + :page? false + :categories [:devtools.network] + :interval 1000}} ---- -For the `:eager` option, it works only with Firefox at the moment of adding the feature to the library. - -== Keyboard chords - -There is an option to input a series of keys simultaneously. -That is useful to imitate holding a system key like Control, Shift or whatever when typing. - -The namespace `etaoin.keys` carries a bunch of key constants as well as a set of functions related to input. +We'll work with a driver that enables everything: +//{:test-doc-blocks/test-ns user-guide-devtools-test} [source,clojure] ---- -(require '[etaoin.keys :as keys]) +(require '[etaoin.api :as e]) + +(def driver (e/chrome {:dev + {:perf + {:level :all + :network? true + :page? true + :interval 1000 + :categories [:devtools + :devtools.network + :devtools.timeline]}}})) ---- -A quick example of entering ordinary characters holding Shift: +Under the hood, Etaoin sets up a special `perfLoggingPrefs` dictionary inside the `chromeOptions` object. + +Now that your browser is accumulating these events, you can read them using a special `dev` namespace. + +The results will be different when you try this, but here's what I experienced: +//{:test-doc-blocks/test-ns user-guide-devtools-test} [source,clojure] ---- -(def c (chrome)) -(go c "http://google.com") +(require '[etaoin.dev :as dev]) -(fill-active c (keys/with-shift "caps is great")) ----- +(e/go driver "https://google.com") -The main input gets populated with "CAPS IS GREAT". -Now you'd like to delete the last word. -In Chrome, this is done by pressing backspace holding Alt. -Let's do that: +(def reqs (dev/get-requests driver)) -[source,clojure] ----- -(fill-active c (keys/with-alt keys/backspace)) +;; reqs is a vector of maps +(count reqs) +;; 23 + +;; what were the request types? +(frequencies (map :type reqs)) +;; {:script 6, +;; :other 2, +;; :xhr 4, +;; :image 5, +;; :stylesheet 1, +;; :ping 3, +;; :document 1, +;; :manifest 1} + +;; Interesting, we've got Js requests, images, AJAX and other stuff +---- + +//{:test-doc-blocks/test-ns user-guide-devtools-test} +[source,clojure] +---- +;; let's take a peek at the last image: +(last (filter #(= :image (:type %)) reqs)) +;; {:state 4, +;; :id "14535.6", +;; :type :image, +;; :xhr? false, +;; :url +;; "https://www.google.com/images/searchbox/desktop_searchbox_sprites318_hr.webp", +;; :with-data? nil, +;; :request +;; {:method :get, +;; :headers +;; {:Referer "https://www.google.com/?gws_rd=ssl", +;; :sec-ch-ua-full-version-list +;; "\" Not A;Brand\";v=\"99.0.0.0\", \"Chromium\";v=\"102.0.5005.61\", \"Google Chrome\";v=\"102.0.5005.61\"", +;; :sec-ch-viewport-width "1200", +;; :sec-ch-ua-platform-version "\"10.15.7\"", +;; :sec-ch-ua +;; "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"", +;; :sec-ch-ua-platform "\"macOS\"", +;; :sec-ch-ua-full-version "\"102.0.5005.61\"", +;; :sec-ch-ua-wow64 "?0", +;; :sec-ch-ua-model "", +;; :sec-ch-ua-bitness "\"64\"", +;; :sec-ch-ua-mobile "?0", +;; :sec-ch-dpr "1", +;; :sec-ch-ua-arch "\"x86\"", +;; :User-Agent +;; "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"}}, +;; :response +;; {:status nil, +;; :headers +;; {:date "Sat, 04 Jun 2022 00:11:36 GMT", +;; :x-xss-protection "0", +;; :x-content-type-options "nosniff", +;; :server "sffe", +;; :cross-origin-opener-policy-report-only +;; "same-origin; report-to=\"static-on-bigtable\"", +;; :last-modified "Wed, 22 Apr 2020 22:00:00 GMT", +;; :expires "Sat, 04 Jun 2022 00:11:36 GMT", +;; :cache-control "private, max-age=31536000", +;; :content-length "660", +;; :report-to +;; "{\"group\":\"static-on-bigtable\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/static-on-bigtable\"}]}", +;; :alt-svc +;; "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"", +;; :cross-origin-resource-policy "cross-origin", +;; :content-type "image/webp", +;; :accept-ranges "bytes"}, +;; :mime "image/webp", +;; :remote-ip "142.251.41.68"}, +;; :done? true} +---- + +TIP: The details of these responses come from Chrome and are subject to changes to Chrome. + +Since we're mostly interested in AJAX requests, there is a function `get-ajax` that does the same but filters XHR requests: + +//{:test-doc-blocks/test-ns user-guide-devtools-test} +[source,clojure] +---- +;; refresh to fill the logs again +(e/go driver "https://google.com") +(e/wait 2) ;; give ajax requests a chance to finish + +(last (dev/get-ajax driver)) +;; {:state 4, +;; :id "14535.59", +;; :type :xhr, +;; :xhr? true, +;; :url +;; "https://www.google.com/complete/search?q&cp=0&client=gws-wiz&xssi=t&hl=en-CA&authuser=0&psi=OtuaYq-xHNeMtQbkjo6gBg.1654315834852&nolsbt=1&dpr=1", +;; :with-data? nil, +;; :request +;; {:method :get, +;; :headers +;; {:Referer "https://www.google.com/", +;; :sec-ch-ua-full-version-list +;; "\" Not A;Brand\";v=\"99.0.0.0\", \"Chromium\";v=\"102.0.5005.61\", \"Google Chrome\";v=\"102.0.5005.61\"", +;; :sec-ch-viewport-width "1200", +;; :sec-ch-ua-platform-version "\"10.15.7\"", +;; :sec-ch-ua +;; "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"", +;; :sec-ch-ua-platform "\"macOS\"", +;; :sec-ch-ua-full-version "\"102.0.5005.61\"", +;; :sec-ch-ua-wow64 "?0", +;; :sec-ch-ua-model "", +;; :sec-ch-ua-bitness "\"64\"", +;; :sec-ch-ua-mobile "?0", +;; :sec-ch-dpr "1", +;; :sec-ch-ua-arch "\"x86\"", +;; :User-Agent +;; "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"}}, +;; :response +;; {:status nil, +;; :headers +;; {:bfcache-opt-in "unload", +;; :date "Sat, 04 Jun 2022 04:10:35 GMT", +;; :content-disposition "attachment; filename=\"f.txt\"", +;; :x-xss-protection "0", +;; :server "gws", +;; :expires "Sat, 04 Jun 2022 04:10:35 GMT", +;; :accept-ch +;; "Sec-CH-Viewport-Width, Sec-CH-Viewport-Height, Sec-CH-DPR, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version, Sec-CH-UA-Arch, Sec-CH-UA-Model, Sec-CH-UA-Bitness, Sec-CH-UA-Full-Version-List, Sec-CH-UA-WoW64", +;; :cache-control "private, max-age=3600", +;; :report-to +;; "{\"group\":\"gws\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/gws/cdt1\"}]}", +;; :x-frame-options "SAMEORIGIN", +;; :strict-transport-security "max-age=31536000", +;; :content-security-policy +;; "object-src 'none';base-uri 'self';script-src 'nonce-xM7BqmSpeu5Zd6usKOP4JA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/cdt1", +;; :alt-svc +;; "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"", +;; :content-type "application/json; charset=UTF-8", +;; :cross-origin-opener-policy "same-origin-allow-popups; report-to=\"gws\"", +;; :content-encoding "br"}, +;; :mime "application/json", +;; :remote-ip "142.251.41.36"}, +;; :done? true};; => nil ---- -Now you've got only "CAPS IS " in the input. +A typical pattern of `get-ajax` usage is the following. +You'd like to check if a certain request has been fired to the server. +So you press a button, wait for a while, and then read the requests made by your browser. -Consider a more complex example which repeats real users' behaviour. -You'd like to delete everything from the input. -First, you move the caret at the very beginning. -Then move it to the end holding shift so everything gets selected. -Finally, you press delete to clear the selected text. +Having a list of requests, you search for the one you need (e.g. by its URL) and then check its state. +The `:state` field has got the same semantics of the `XMLHttpRequest.readyState`. +It's an integer from 1 to 4 with the same behavior. -The combo is: +To check if a request has been finished, done or failed, use these predicates: +//{:test-doc-blocks/test-ns user-guide-devtools-test} [source,clojure] ---- -(fill-active c keys/home (keys/with-shift keys/end) keys/delete) ----- +;; fill the logs +(e/go driver "https://google.com") +(e/wait 2) ;; give ajax requests a chance to finish -There are also `with-ctrl` and `with-command` functions that act the same. +(def reqs (dev/get-ajax driver)) +;; you'd search for what you are interested in here +(def req (last reqs)) -Pay attention, these functions do not apply to the global browser's shortcuts. -For example, neither "Command + R" nor "Command + T" reload the page or open a new tab. +(dev/request-done? req) +;; => true + +(dev/request-failed? req) +;; => nil + +(dev/request-success? req) +;; => true +---- -All the `keys/with-*` functions are just wrappers upon the `keys/chord` function that might be used for complex cases. +Note that `request-done?` doesn't mean the request has succeeded. +It only means its pipeline has reached a final step. -== File download directory +TIP: when you read dev logs, you consume them from an internal buffer that gets flushed. +The second call to `get-requests` or `get-ajax` will return an empty list. -To specify your own directory where to download files, pass `:download-dir` parameter into an option map when running a driver: +Perhaps you want to collect these logs. +A function `dev/get-performance-logs` return a list of logs so you accumulate them in an atom or whatever: +//{:test-doc-blocks/test-ns user-guide-devtools-test} [source,clojure] ---- -(def driver (chrome {:download-dir "/Users/ivan/Desktop"})) +;; setup a collector +(def logs (atom [])) + +;; make requests +(e/refresh driver) + +;; collect as needed +(do (swap! logs concat (dev/get-performance-logs driver)) + true) + +(count @logs) +;; 136 ---- -Now, once you click on a link, a file should be put into that folder. -Currently, only Chrome and Firefox are supported. +The `+logs->requests+` and `+logs->ajax+` functions convert already fetched logs into requests. +Unlike `get-requests` and `get-ajax`, they are pure functions and won't flush anything. -Firefox requires to specify MIME-types of those files that should be downloaded without showing a system dialog. -By default, when the `:download-dir` parameter is passed, the library adds the most common MIME-types: archives, media files, office documents, etc. -If you need to add your own one, override that preference manually: +//{:test-doc-blocks/test-ns user-guide-devtools-test} +[source,clojure] +---- +;; convert our fetched requests from our collector atom +(dev/logs->requests @logs) +(last (dev/logs->requests @logs)) +;; {:state 4, +;; :id "14535.162", +;; :type :ping, +;; :xhr? false, +;; :url +;; "https://www.google.com/gen_204?atyp=i&r=1&ei=Zd2aYsrzLozStQbzgbqIBQ&ct=slh&v=t1&m=HV&pv=0.48715273690818806&me=1:1654316389931,V,0,0,1200,1053:0,B,1053:0,N,1,Zd2aYsrzLozStQbzgbqIBQ:0,R,1,1,0,0,1200,1053:93,x:42832,e,U&zx=1654316432856", +;; :with-data? true, +;; :request +;; {:method :post, +;; :headers +;; {:Referer "https://www.google.com/", +;; :sec-ch-ua-full-version-list +;; "\" Not A;Brand\";v=\"99.0.0.0\", \"Chromium\";v=\"102.0.5005.61\", \"Google Chrome\";v=\"102.0.5005.61\"", +;; :sec-ch-viewport-width "1200", +;; :sec-ch-ua-platform-version "\"10.15.7\"", +;; :sec-ch-ua +;; "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"", +;; :sec-ch-ua-platform "\"macOS\"", +;; :sec-ch-ua-full-version "\"102.0.5005.61\"", +;; :sec-ch-ua-wow64 "?0", +;; :sec-ch-ua-model "", +;; :sec-ch-ua-bitness "\"64\"", +;; :sec-ch-ua-mobile "?0", +;; :sec-ch-dpr "1", +;; :sec-ch-ua-arch "\"x86\"", +;; :User-Agent +;; "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"}}, +;; :response +;; {:status nil, +;; :headers +;; {:alt-svc +;; "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"", +;; :bfcache-opt-in "unload", +;; :content-length "0", +;; :content-type "text/html; charset=UTF-8", +;; :date "Sat, 04 Jun 2022 04:20:32 GMT", +;; :server "gws", +;; :x-frame-options "SAMEORIGIN", +;; :x-xss-protection "0"}, +;; :mime "text/html", +;; :remote-ip "142.251.41.36"}, +;; :done? true} +---- + +When working with logs and requests, pay attention to their count and size. +The maps have plenty of keys and the number of items in collections can become very large. +Printing a slew of events might freeze your editor. +Consider using `clojure.pprint/pprint` as it relies on max level and length limits. + +// hidden cleanup of our devtools driver +ifdef::env-test-doc-blocks[] +//{:test-doc-blocks/test-ns user-guide-devtools-test} +[source,clojure] +---- +(e/quit driver) +---- +endif::[] + +=== Postmortem: Auto-save Artifacts in Case of Exception [[postmortem]] + +Sometimes, it can be difficult to diagnose what went wrong during a failed UI test run. +Use the `with-postmortem` to save useful data to disk before the exception was triggered: + +* a screenshot of the visible browser page +* HTML code of the current browser page +* JS console logs, <> + +Example: [source,clojure] ---- -(def driver (firefox {:download-dir "/Users/ivan/Desktop" - :prefs {:browser.helperApps.neverAsk.saveToDisk - "some-mime/type-1;other-mime/type-2"}})) +(try + (e/with-postmortem driver {:dir "target/etaoin-play/postmortem"} + (e/click driver :non-existing-element)) + (catch Exception _e + "yup, we threw!")) +;; => "yup, we threw!" ---- -To check whether a file was downloaded during UI tests, see the testing section below. +An exception will occur. Under `target/etaoin-postmortem` you will find three postmortem files named like so: `---.`, for example: -== Managing User-Agent +[source,shell] +---- +$ tree target +target +└── etaoin-postmortem + ├── chrome-127.0.0.1-49766-2022-06-04-12-26-31.html + ├── chrome-127.0.0.1-49766-2022-06-04-12-26-31.json + └── chrome-127.0.0.1-49766-2022-06-04-12-26-31.png +---- -Set a custom User-Agent header with the `:user-agent` option when creating a driver, for example: +The available `with-postmortem` options are: [source,clojure] ---- -(def f (firefox {:user-agent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"})) +{;; directory to save artifacts + ;; will be created if it does not already exist, defaults to current working directory + :dir "/home/ivan/UI-tests" + + ;; directory to save screenshots; defaults to :dir + :dir-img "/home/ivan/UI-tests/screenshots" + + ;; the same but for HTML sources + :dir-src "/home/ivan/UI-tests/HTML" + + ;; the same but for console logs + :dir-log "/home/ivan/UI-tests/console" + + ;; a string template to format a timestamp; See SimpleDateFormat Java class + :date-format "yyyy-MM-dd-HH-mm-ss"} ---- -To get the current value of the header in runtime, use the function: +== Additional Driver Parameters [[parameters]] + +When creating a driver instance, a map of additional parameters can be passed to tweak the WebDriver and web browser behaviour. +Here, for example, we set an explicit path to the chrome WebDriver binary: + +//:test-doc-blocks/skip [source,clojure] ---- -(get-user-agent f) -;; Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1) +(def driver (e/chrome {:path-driver "/Users/ivan/downloads/chromedriver"})) ---- -Setting that header is quite important for headless browsers as most of the sites check if the User-Agent includes the "headless" string. -This could lead to 403 response or some weird behavior of the site. +[cols="70,30"] +|=== +| Option | Defaults -== Setting browser profile +a|`:host` for *WebDriver* process. When: -When running Chrome or Firefox, you may specify a special profile made for test purposes. -A profile is a folder that keeps browser settings, history, bookmarks and other user-specific data. +* omitted, creates a new local WebDriver process. +* specified, attempts to connect to an existing running WebDriver process. +See <>. -Imagine you'd like to run your integration tests against a user that turned off Javascript execution or image rendering. -To prepare a special profile for that task would be a good choice. +Example: `:host "192.68.1.12"` +| -=== Create and find a profile in Chrome +|`:port` for *WebDriver* process. +If `:port` is found to already in use when creating a new local WebDriver process (see `:host`), a random port will be automatically selected. + +See also <>. -. In the right top corner of the main window, click on a user button. -. In the dropdown, select "Manage People". -. Click "Add person", submit a name and press "Save". -. The new browser window should appear. -Now, setup the new profile as you want. -. Open `chrome://version/` page. -Copy the file path that is beneath the `Profile Path` caption. +Example: `:port 9997` +a| Varies by vendor: -=== Create and find a profile in Firefox +* chrome `9515` +* firefox `4444` +* safari `4445` +* edge `17556` +* phantom `8910` -. Run Firefox with `-P`, `-p` or `-ProfileManager` key as the https://support.mozilla.org/en-US/kb/profile-manager-create-and-remove-firefox-profiles[official page] describes. -. Create a new profile and run the browser. -. Setup the profile as you need. -. Open `about:support` page. -Near the `Profile Folder` caption, press the `Show in Finder` button. -A new folder window should appear. -Copy its path from there. +| `:path-driver` to *WebDriver* binary. + +Typically used if your WebDriver is not on the PATH. -=== Running a driver with a profile +Example: +`:path-driver "/Users/ivan/Downloads/geckodriver"` +a| As you would expect, varies by vendor: -Once you've got a profile path, launch a driver with a special `:profile` key as follows: +* chrome `"chromedriver"` +* firefox `"geckodriver"` +* safari `"safaridriver"` +* edge `"msedgedriver"` +* phantom `"phantomjs"` -[source,clojure] ----- -;; Chrome -(def chrome-profile - "/Users/ivan/Library/Application Support/Google/Chrome/Profile 2/Default") +| `:args-driver` specifies extra command line arguments to *WebDriver*. -(def chrm (chrome {:profile chrome-profile})) +Example: `:args-driver ["-b" "/path/to/firefox/binary"]` +| -;; Firefox -(def ff-profile - "/Users/ivan/Library/Application Support/Firefox/Profiles/iy4iitbg.Test") +| `:path-browser` to *web browser* binary. + +Typically used if your browser is not on the PATH. -(def ff (firefox {:profile ff-profile})) ----- +Example: `:path-browser "/Users/ivan/Downloads/firefox/firefox"` +| By default, the WebDriver process automatically finds the web browser. -== Scrolling +| `:args` specifies extra command line arguments to *web browser*, see your web browser docs for what is available. -The library ships a set of functions to scroll the page. +Example: `:args ["--incognito" "--app" "http://example.com"]` +| -The most important one, `scroll-query` jumps the the first element found with the query term: +a| `:log-level` *web browser* minimal console log level. +Only messages with this level and above will be collected. +From least to most verbose: -[source,clojure] ----- -(def driver (chrome)) +* `nil`, `:off` or `:none` for no messages +* `:err`, `:error`, `:severe`, `:crit` or `:critical` +* `:warn` or `:warning` +* `:debug` +* `:all` for all messages. -;; the form button placed somewhere below -(scroll-query driver :button-submit) +See <> -;; the main article -(scroll-query driver {:tag :h1}) ----- +Example: `:log-level :err` -To jump to the absolute position, just use `scroll` as follows: +| `:all` -[source,clojure] ----- -(scroll driver 100 600) +a| `driver-log-level` *WebDriver* minimal log level. +values vary by browser driver vendor: -;; or pass a map with x and y keys -(scroll driver {:x 100 :y 600}) ----- +* chrome `"OFF"` `"SEVERE"` `"WARNING"` `"INFO"` or `"DEBUG"` +* firefox `"fatal"` `"error"` `"warn"` `"info"` `"config"` `"debug"` or `"trace"` +* phantomjs `"ERROR"` `"WARN"` `"INFO"` `"DEBUG"` -To scroll relatively, use `scroll-by` with offset values: +Example: `:driver-log-level "INFO"` -[source,clojure] ----- -;; keeps the same horizontal position, goes up for 100 pixels -(scroll-by driver 0 -100) ;; map parameter is also supported ----- +a| * phantomjs `"INFO"` -There are two shortcuts to jump top or bottom of the page: +a| `:log-stdout` and `:log-stderr` *WebDriver* stdout and stderr log files +Example: [source,clojure] ---- -(scroll-bottom driver) ;; you'll see the footer... -(scroll-top driver) ;; ...and the header again + :log-stdout "target/chromedriver-out.log" + :log-stderr "target/chrmoedriver-err.log" ---- +| `/dev/null`, on Windows `NUL` -The following functions scroll the page in all directions: +| `:profile` path to custom *web browser* profile, see <> -[source,clojure] ----- -(scroll-down driver 200) ;; scrolls down by 200 pixels -(scroll-down driver) ;; scrolls down by the default (100) number of pixels +Example: + +`:profile "/Users/ivan/Library/Application Support/Firefox/Profiles/iy4iitbg.Test"` -(scroll-up driver 200) ;; the same, but scrolls up... -(scroll-up driver) +| -(scroll-left driver 200) ;; ...left -(scroll-left driver) +| `:env` map of environment variables for *WebDriver* process. -(scroll-right driver 200) ;; ... and right -(scroll-right driver) ----- +Example: `:env {:MOZ_CRASHREPORTER_URL "http://test.com"}` +| -One note, in all cases the scroll actions are served with Javascript. -Ensure your browser has it enabled. +| `:size` initial *web browser* window width and height in pixels -== Working with frames and iframes +Example: `size: [640 480]` +| [1024 680] -While working with the page, you cannot interact with those items that are put into a frame or an iframe. -The functions below switch the current context on specific frame: +| `:url` default URL to open in *web browser*.+ +Only works in Firefox at this time. -[source,clojure] ----- -(switch-frame driver :frameId) ;; now you are inside an iframe with id="frameId" -(click driver :someButton) ;; click on a button inside that iframe -(switch-frame-top driver) ;; switches on the top of the page again ----- +Example: `:url "https://clojure.org"` +| -Frames could be nested one into another. -The functions take that into account. -Say you have an HTML layout like this: +| `:user-agent` overrides the *web browser* `User-Agent`. +Useful for headless mode. +See <>. -[source,html] ----- - - ----- +Example: `:user-agent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"` +| Default is governed by WebDriver vendor. -So you can reach the button with the following code: +| `:download-dir` directory for *web browser* downloads files. +See <> -[source,clojure] ----- -(switch-frame-first driver) ;; switches to the first top-level iframe -(switch-frame-first driver) ;; the same for an iframe inside the previous one -(click driver :the-goal) -(switch-frame-parent driver) ;; you are in the first iframe now -(switch-frame-parent driver) ;; you are at the top ----- +Example: `:download-dir "target/chrome-downloads"` +| Default is governed by browser vendor. -To reduce number of code lines, there is a special `with-frame` macro. -It temporary switches frames while executing the body returning its last expression and switching to the previous frame afterwards. +| `:headless` run the *web browser* without a UI. +See <>. -[source,clojure] ----- -(with-frame driver {:id :first-frame} - (with-frame driver {:id :nested-frame} - (click driver {:id :nested-button}) - 42)) ----- +Example `:headless true` +| Normally `false`, but automatically set for driver creation functions like `chrome-headless`, `with-chrome-headless` etc. + +| `:prefs` map of *web browser* specific preferences. + +Example: see one usage in <>. +| -The code above returns `42` staying at the same frame that has been before before evaluating the macros. +| `:proxy` to set *web browser* proxy. -== Executing Javascript +Example: see <>. +| -To evaluate a Javascript code in a browser, run: +| `:load-strategy` controls how long the *WebDriver* should wait before interacting with a page. +See <>. +Example: `:load-strategy :none` +| `:normal` + +| `:capabilities` *WebDriver*-specific options. +Read vendor docs for WebDriver before setting anything here. +You'll find an example usage under <>. +| + +|=== + +=== Using Headless Drivers [[headless]] + +Google Chrome, Firefox, and Microsoft Edge can be run in headless mode. +When headless, none of the UI windows appear on the screen. +Running without a UI is helpful when: + +* running integration tests on servers that do not have a graphical output device +* running local tests without having them take over your local UI + +Ensure your browser supports headless mode by checking if it accepts `--headless` command-line argument when running it from the terminal. +The Phantom.js driver is headless by its nature (it was never been developed for rendering UI). + +When starting a driver, pass the `:headless` boolean flag to switch into headless mode. +This flag is ignored for Safari which, as of June 2022, still does not support headless mode. + +//{:test-doc-blocks/test-ns user-guide-headless-test} [source,clojure] ---- -(js-execute driver "alert(1)") +(require '[etaoin.api :as e]) + +(def driver (e/chrome {:headless true})) ;; runs headless Chrome +;; do some stuff +(e/quit driver) ---- -You may pass any additional parameters into the call and cath them inside a script with the `arguments` array-like object: +or +//{:test-doc-blocks/test-ns user-guide-headless-test} [source,clojure] ---- -(js-execute driver "alert(arguments[2].foo)" 1 false {:foo "hello!"}) +(def driver (e/firefox {:headless true})) ;; runs headless Firefox +;; you can also check if a driver is in headless mode: +(e/headless? driver) +;; => true +(e/quit driver) ---- -As the result, `hello!` string will appear inside the dialog. +NOTE: PhantomJS will always be in headless mode. -To return any data into Clojure, just add `return` into your script: +There are several shortcuts to run Chrome or Firefox in headless mode: +//{:test-doc-blocks/test-ns user-guide-headless-test} [source,clojure] ---- -(js-execute driver "return {foo: arguments[2].foo, bar: [1, 2, 3]}" - 1 false {:foo "hello!"}) -;; {:bar [1 2 3], :foo "hello!"} ----- +(def driver (e/chrome-headless)) +;; do some stuff +(e/quit driver) -=== Asynchronous scripts +;; or -If your script performs AJAX requests or operates on `setTimeout` or any other async stuff, you cannot just `return` the result. -Instead, a special callback should be called against the data you'd like to achieve. -The webdriver passes this callback as the last argument for your script and might be reached with the `arguments` array-like object. +(def driver (e/firefox-headless {:log-level :all})) ;; with extra settings +;; do some stuff +(e/quit driver) -Example: +;; or + +(require '[etaoin.api2 :as e2]) + +(e2/with-chrome-headless [driver] + (e/go driver "https://clojure.org")) + +(e2/with-firefox-headless [driver {:log-level :all}] ;; extra settings + (e/go driver "https://clojure.org")) +---- + +There are also the `when-headless` and `when-not-headless` macros that conditonally execute a block of commands: +//{:test-doc-blocks/test-ns user-guide-headless-test} [source,clojure] ---- -(js-async - driver - "var args = arguments; // preserve the global args - var callback = args[args.length-1]; - setTimeout(function() { - callback(args[0].foo.bar.baz); - }, - 1000);" - {:foo {:bar {:baz 42}}}) +(e2/with-chrome [driver] + (e/when-not-headless driver + ;;... some actions that might be not available in headless mode + ) + ;;... common actions for both versions + ) ---- -returns `42` to the Clojure code. +=== File Download Directory [[download-dir]] -To evaluate an asynchronous script, you need either to setup a special timeout for that: +To specify a directory where the browser should download files, use the `:download-dir` option: +//:test-doc-blocks/skip [source,clojure] ---- -(set-script-timeout driver 5) ;; in seconds +(def driver (e/chrome {:download-dir "target/etaoin-play/chrome-downloads"})) +;; do some downloading +(e/driver quit) ---- -or wrap the code into a macros that does it temporary: +Now, when you click on a download link, the file will be saved to that folder. +Currently, only Chrome and Firefox are supported. +Firefox requires specifying MIME-types of the files that should be downloaded without showing a system dialog. +By default, when the `:download-dir` parameter is passed, the library adds the most common MIME-types: archives, media files, office documents, etc. +If you need to add your own one, override that Firefox preference manually via the `:prefs` option: + +//:test-doc-blocks/skip [source,clojure] ---- -(with-script-timeout driver 30 - (js-async driver "some long script")) +(def driver (e/firefox {:download-dir "target/etaoin-play/firefox-downloads" + :prefs {:browser.helperApps.neverAsk.saveToDisk + "some-mime/type-1;other-mime/type-2"}})) +;; do some downloading +(e/driver quit) ---- -== Wait functions +To check whether a file was downloaded during UI tests, see <>. -The main difference between a program and a human being is that the first one operates very fast. -It means so fast, that sometimes a browser cannot render new HTML in time. -So after each action you'd better to put `wait-` function that just polls a browser until the predicate evaluates into true. -Or just `(wait )` if you don't care about optimization. +=== Managing User-Agent [[user-agent]] -The `with-wait` macro might be helpful when you need to prepend each action with `(wait n)`. -For example, the following form +Set a custom `User-Agent` header with the `:user-agent` option when creating a driver, for example: [source,clojure] ---- -(with-chrome {} driver - (with-wait 3 - (go driver "http://site.com") - (click driver {:id "search_button"}))) +(e2/with-firefox [driver {:user-agent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"}] + (e/get-user-agent driver)) +;; => "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)" ---- -turns into something like this: +Setting this header is important when using <> as many websites implement some sort of blocking when the User-Agent includes the "headless" string. +This can lead to 403 response or some weird behavior of the site. +=== HTTP Proxy [[http-proxy]] + +To set proxy settings use environment variables `HTTP_PROXY`/`HTTPS_PROXY` or pass a map of the following type: + +//:test-doc-blocks/skip [source,clojure] ---- -(with-chrome {} driver - (wait 3) - (go driver "http://site.com") - (wait 3) - (click driver {:id "search_button"})) +{:proxy {:http "some.proxy.com:8080" + :ftp "some.proxy.com:8080" + :ssl "some.proxy.com:8080" + :socks {:host "myproxy:1080" :version 5} + :bypass ["http://this.url" "http://that.url"] + :pac-url "localhost:8888"}} + +;; example +(e/chrome {:proxy {:http "some.proxy.com:8080" + :ssl "some.proxy.com:8080"}}) ---- -and thus returns the result of the last form of the original body. +NOTE: A `:pac-url` is for a https://en.wikipedia.org/wiki/Proxy_auto-config#The_PAC_File[proxy autoconfiguration file]. +Used with Safari as other proxy options do not work in Safari. -There is another macro `(doto-wait n driver & body)` that acts like the standard `doto` but prepend each form with `(wait n)`. -For example: +To fine tune the proxy you use the original https://www.w3.org/TR/webdriver/#proxy[object] and pass it to capabilities: +//:test-doc-blocks/skip [source,clojure] ---- -(with-chrome {} driver - (doto-wait 1 driver - (go "http://site.com") - (click :this-link) - (click :that-button) - ...etc)) +(e/chrome {:capabilities + {:proxy + {:proxyType "manual" + :proxyAutoconfigUrl "some.proxy.com:8080" + :ftpProxy "some.proxy.com:8080" + :httpProxy "some.proxy.com:8080" + :noProxy ["http://this.url" "http://that.url"] + :sslProxy "some.proxy.com:8080" + :socksProxy "some.proxy.com:1080" + :socksVersion 5}}}) ---- +=== Connecting to an Existing Running WebDriver [[connecting-existing]] -The final form would be something like this: +To connect to an existing WebDriver, specify the `:host` parameter. -[source,clojure] ----- -(with-chrome {} driver - (doto driver - (wait 1) - (go "http://site.com") - (wait 1) - (click :this-link) - (wait 1) - (click :that-button) - ...etc)) ----- +TIP: When the `:host` parameter is not specified Etaoin will create a new WebDriver process. -In addition to `with-wait` and `do-wait` there are a number of waiting functions: `wait-visible`, `wait-has-alert`, `wait-predicate`, etc (see the full list in the link:{url-doc}/CURRENT/api/etaoin.api#wait[API docs]. -They accept default timeout/interval values that can be redefined using the `with-wait-timeout` and `with-wait-interval` macros, respectively. +The `:host` can be a hostname (localhost, some.remote.host.net) or an IP address (127.0.0.1, 183.102.156.31). +If the port is not specified, the <> `:port` is assumed. -Example from etaoin test: +Example: +//:test-doc-blocks/skip [source,clojure] ---- -(deftest test-wait-has-text - (testing "wait for text simple" - (with-wait-timeout 15 ;; time in seconds - (doto *driver* - (refresh) - (wait-visible {:id :document-end}) - (click {:id :wait-button}) - (wait-has-text :wait-span "-secret-")) - (is true "text found")))) +;; Connect to an existing chromedriver process on localhost on port 9515 +(def driver (e/chrome {:host "127.0.0.1" :port 9515})) ;; for connection to driver on localhost on port 9515 + +;; Connect to an existing geckodriver process on remote most on default port +(def driver (e/firefox {:host "192.168.1.11"})) ;; the default port for firefox is 4444 ---- -Wait text: +=== Setting the Browser Profile [[browser-profile]] -* `wait-has-text` waits until an element has text anywhere inside it (including inner HTML). -+ -[source,clojure] ----- -(wait-has-text driver :wait-span "-secret-") ----- +When running Chrome or Firefox, you may specify a special web browser profile made for test purposes. +A profile is a folder that keeps browser settings, history, bookmarks, and other user-specific data. -* `wait-has-text-everywhere` like `wait-has-text` but searches for text across the entire page -+ +Imagine, for example, that you'd like to run your integration tests against a user that turned off Javascript execution or image rendering. + +==== Create and Find a Profile in Chrome + +. In the right top corner of the main window, click on a user button. +. In the dropdown, select "Manage People". +. Click "Add person", submit a name and press "Save". +. The new browser window should appear. +Now, setup the new profile as you want. +. Open `chrome://version/` page. +Copy the file path that is beneath the `Profile Path` caption. + +==== Create and Find a Profile in Firefox + +. Run Firefox with `-P`, `-p` or `-ProfileManager` key as the https://support.mozilla.org/en-US/kb/profile-manager-create-and-remove-firefox-profiles[official page] describes. +. Create a new profile and run the browser. +. Setup the profile as you need. +. Open `about:support` page. +Near the `Profile Folder` caption, press the `Show in Finder` button. +A new folder window should appear. +Copy its path from there. + +==== Running a Driver with a Profile + +Once you've got a profile path, launch a driver with the `:profile` key as follows: + +//:test-doc-blocks/skip [source,clojure] ---- -(wait-has-text-everywhere driver "-secret-") +;; Chrome +(def chrome-profile + "/Users/ivan/Library/Application Support/Google/Chrome/Profile 2/Default") + +(def chrome-driver (e/chrome {:profile chrome-profile})) + +;; Firefox +(def ff-profile + "/Users/ivan/Library/Application Support/Firefox/Profiles/iy4iitbg.Test") + +(def firefox-driver (e/firefox {:profile ff-profile})) ---- == Writing Integration Tests For Your Application -=== Basic fixture +=== Basic Fixture -To make your test not depend on each other, you need to wrap them into a fixture that will create a new instance of a driver and shut it down properly at the end if each test. +It is desirable to have your tests be independent of one another. +One way to achieve this is through the use of a test fixture. +The fixture's job is to, for each test: -Good solution might be to have a global variable (unbound by default) that will point to the target driver during the tests. +1. create a new driver +2. run the test with the driver +3. shutdown the driver +A dynamic `+*driver*+` var might be used to hold the driver. + +//:test-doc-blocks/skip [source,clojure] ---- (ns project.test.integration "A module for integration tests" - (:require [clojure.test :refer :all] - [etaoin.api :refer :all])) + (:require [clojure.test :refer [deftest is use-fixtures]] + [etaoin.api :as e])) (def ^:dynamic *driver*) @@ -1735,7 +2325,7 @@ Good solution might be to have a global variable (unbound by default) that will "Executes a test running a driver. Bounds a driver with the global *driver* variable." [f] - (with-chrome {} driver + (e/with-chrome [driver] (binding [*driver* driver] (f)))) @@ -1748,37 +2338,39 @@ Good solution might be to have a global variable (unbound by default) that will (deftest ^:integration test-some-case (doto *driver* - (go url-project) - (click :some-button) - (refresh) + (e/go url-project) + (e/click :some-button) + (e/refresh) ... )) ---- -If for some reason you want to use a single instance, you can use fixtures like this: +If for some reason you want to reuse a single driver instance for all tests: +//:test-doc-blocks/skip [source,clojure] ---- (ns project.test.integration "A module for integration tests" - (:require [clojure.test :refer :all] - [etaoin.api :refer :all])) + (:require [clojure.test :refer [deftest is use-fixtures]] + [etaoin.api :as e] + [etaoin.api :as e2])) (def ^:dynamic *driver*) (defn fixture-browser [f] - (with-chrome-headless {:args ["--no-sandbox"]} driver - (disconnect-driver driver) + (e2/with-chrome-headless [driver {:args ["--no-sandbox"]}] + (e/disconnect-driver driver) (binding [*driver* driver] (f)) - (connect-driver driver))) + (e/connect-driver driver))) ;; creating a session every time that automatically erases resources (defn fixture-clear-browser [f] - (connect-driver *driver*) - (go *driver* "http://google.com") + (e/connect-driver *driver*) + (e/go *driver* "http://google.com") (f) - (disconnect-driver *driver*)) + (e/disconnect-driver *driver*)) ;; this is run `once` before running the tests (use-fixtures @@ -1795,20 +2387,21 @@ If for some reason you want to use a single instance, you can use fixtures like For faster testing you can use this example: +//:test-doc-blocks/skip [source,clojure] ---- ..... (defn fixture-browser [f] - (with-chrome-headless {:args ["--no-sandbox"]} driver + (e2/with-chrome-headless [driver {:args ["--no-sandbox"]}] (binding [*driver* driver] (f)))) ;; note that resources, such as cookies, are deleted manually, ;; so this does not guarantee that the tests are clean (defn fixture-clear-browser [f] - (delete-cookies *driver*) - (go *driver* "http://google.com") + (e/delete-cookies *driver*) + (e/go *driver* "http://google.com") (f)) ...... @@ -1820,6 +2413,7 @@ In the example above, we examined a case when you run tests against a single typ However, you may want to test your site on multiple drivers, say, Chrome and Firefox. In that case, your fixture may become a bit more complex: +//:test-doc-blocks/skip [source,clojure] ---- @@ -1827,36 +2421,41 @@ In that case, your fixture may become a bit more complex: (defn fixture-drivers [f] (doseq [type driver-types] - (with-driver type {} driver + (e/with-driver type {} driver (binding [*driver* driver] (testing (format "Testing in %s browser" (name type)) (f)))))) ---- -Now, each test will be run twice in both Firefox and Chrome browsers. -Please note the test call is prepended with `testing` macro that puts driver name into the report. -Once you've got an error, you'll easy find what driver failed the tests exactly. +Now, each test will be run twice. +Once for Firefox and then once Chrome. +Please note the test call is prepended with the `testing` macro that puts the driver name into the report. +Once you've got an error, you'll easily find what driver failed the tests exactly. + +TIP: See also link:{url-tests}[Etaoin's API tests] for an example of this strategy. === Postmortem Handler To Collect Artifacts -To save some artifacts in case of exception, wrap the body of your test into `with-postmortem` handler as follows: +To save some artifacts in case of an exception, wrap the body of your test into the `with-postmortem` handler as follows: +//:test-doc-blocks/skip [source,clojure] ---- (deftest test-user-login - (with-postmortem *driver* {:dir "/path/to/folder"} + (e/with-postmortem *driver* {:dir "/path/to/folder"} (doto *driver* - (go "http://127.0.0.1:8080") - (click-visible :login) + (e/go "http://127.0.0.1:8080") + (e/click-visible :login) ;; any other actions... ))) ---- -Now that, if any exception occurs in that test, artifacts will be saved. +If any exception occurs in that test, artifacts will be saved. -To not copy and paste the options map, declare it on the top of the module. +To not copy and paste the options map, declare it at the top of the module. If you use Circle CI, it would be great to save the data into a special artifacts directory that might be downloaded as a zip file once the build has been finished: +//:test-doc-blocks/skip [source,clojure] ---- (def pm-dir @@ -1869,24 +2468,28 @@ If you use Circle CI, it would be great to save the data into a special artifact Now pass that map everywhere into PM handler: +//:test-doc-blocks/skip [source,clojure] ---- ;; test declaration - (with-postmortem *driver* pm-opt + (e/with-postmortem *driver* pm-opt ;; test body goes here ) ---- Once an error occurs, you will find a PNG image that represents your browser page at the moment of exception and HTML dump. +See <>. + === Running Tests By Tag Since UI tests may take lots of time to pass, it's definitely a good practice to pass both server and UI tests independently from each other. -If you are using lneiningen, here are a few tips. +If you are using leiningen, here are a few tips. First, add `+^:integration+` tag to all the tests that are run under the browser like follows: +//:test-doc-blocks/skip [source,clojure] ---- (deftest ^:integration @@ -1894,7 +2497,8 @@ First, add `+^:integration+` tag to all the tests that are run under the browser (doto *driver* (go url-password-reset) (click :reset-btn) - ... + ;; and so on... + )) ---- Then, open your `project.clj` file and add test selectors: @@ -1905,21 +2509,16 @@ Then, open your `project.clj` file and add test selectors: :integration :integration} ---- -Now, once you launch `lein test` you will run all the tests except browser ones. +Now, when you launch `lein test` you will run all the tests except browser integration tests. To run integration tests, launch `lein test :integration`. -The main difference between a program and a human is that the first one operates very fast. -It means so fast, that sometimes a browser cannot render new HTML in time. -So after each action you need to put `wait-` function that just polls a browser checking for a predicate. -Or just `(wait )` if you don't care about optimization. - -=== Check whether a file has been downloaded +=== Check Whether a File has been Downloaded [[test-file-downloads]] -Sometimes, a file starts to download automatically once you clicked on a link or just visited some page. -In tests, you need to ensure a file really has been downloaded successfully. +Sometimes, a file starts to download automatically when you click on a link or just visit some page. +In tests, you might need to ensure a file really has been downloaded successfully. A common scenario would be: -* provide a custom empty download folder when running a browser (see above). +* provide a custom empty download folder when running a browser (see <>). * Click on a link or perform any action needed to start file downloading. * Wait for some time; for small files, 5-10 seconds would be enough. @@ -1928,8 +2527,12 @@ Check if it matches a proper extension, name, creation date, etc. Example: +//:test-doc-blocks/skip [source,clojure] ---- +(require '[clojure.java.io :as io] + '[clojure.string :as str]) + ;; Local helper that checks whether it is really an Excel file. (defn xlsx? [file] (-> file @@ -1938,11 +2541,11 @@ Example: ;; Top-level declarations (def DL-DIR "/Users/ivan/Desktop") -(def driver (chrome {:download-dir DL-DIR})) +(def driver (e/chrome {:download-dir DL-DIR})) ;; Later, in tests... -(click-visible driver :download-that-application) -(wait driver 7) ;; wait for a file has been downloaded +(e/click-visible driver :download-that-application) +(e/wait driver 7) ;; wait for a file has been downloaded ;; Now, scan the directory and try to find a file: (let [files (file-seq (io/file DL-DIR)) @@ -1954,20 +2557,23 @@ Example: == Running Selenium IDE files Etaoin can play the files produced by link:{ide}[Selenium IDE]. -It's an official utility to create scenarios interactively. -The IDE comes as an extension to your browser. -Once installed, it records you actions into a JSON file with the `.side` extension. +Selenium IDE allows you to record web interactions for later playback. +It is installed as an optional extension in your web browser. + +Once installed, and activated, it records your actions into a JSON file with the `.side` extension. You can save that file and run it with Etaoin. -Let's imagine you've installed the IDE and recorded some actions as the official documentation prescribes. -Now that you have a `test.side` file, do this: +Let's imagine you've installed the IDE and recorded some actions as per Selenium IDE documentation. +Now that you have a `test.side` file, you could do something like this: +//:test-doc-blocks/skip [source,clojure] ---- +(require '[clojure.java.io :as io] + '[etaoin.api :as e] + '[etaoin.ide.flow :as flow]) -(require '[etaoin.ide.flow :as flow]) - -(def driver (chrome)) +(def driver (e/chrome)) (def ide-file (io/resource "ide/test.side")) @@ -1997,11 +2603,11 @@ Now that you have a `test.side` file, do this: (flow/run-ide-script driver ide-file opt) ---- -Everything related to the IDE is stored under the `etaoin.ide` package. +Everything related to the IDE feature can be found under the link:{url-doc}/CURRENT/api/etaoin.ide[etaoin.ide] namespace. -=== CLI arguments +=== CLI Arguments -You may also run a script from the command line. +You may also run a `.side` script from the command line. Here is a `clojure` example: [source,shell] @@ -2033,170 +2639,168 @@ We support the following arguments (check them out using the `clojure -M -m etao -h, --help ---- -Pay attention to the `--params` one. +Pay attention to `--params`. This must be an EDN string representing a Clojure map. -That's the same map that you pass into a driver when initiate it. +That's the same map that you pass into a driver at creation time. Please note the IDE support is still experimental. If you encounter unexpected behavior feel free to open an issue. At the moment, we only support Chrome and Firefox for IDE files. -== Troubleshooting [[troubleshooting]] +== Webdriver in Docker -=== Calling maximize function throws an error +To work with the driver in Docker, you can take ready-made images: -Example: +Example for https://hub.docker.com/r/robcherry/docker-chromedriver/[Chrome]: -[source,clojure] +[source,shell] ---- -etaoin.api> (def driver (chrome)) -#'etaoin.api/driver -etaoin.api> (maximize driver) -ExceptionInfo throw+: {:response { -:sessionId "2672b934de785aabb730fd19330cf40c", -:status 13, -:value {:message "unknown error: cannot get automation extension\nfrom unknown error: page could not be found: chrome-extension://aapnijgdinlhnhlmodcfapnahmbfebeb/_generated_background_page.html\n -(Session info: chrome=57.0.2987.133)\n (Driver info: chromedriver=2.27.440174 -(e97a722caafc2d3a8b807ee115bfb307f7d2cfd9),platform=Mac OS X 10.11.6 x86_64)"}}, -... +docker run --name chromedriver -p 9515:4444 -d -e CHROMEDRIVER_WHITELISTED_IPS='' robcherry/docker-chromedriver:latest ---- -*Solution:* just update your `chromedriver` to the last version. -Tested with 2.29, works fine. -People say it woks as well since 2.28. +for https://hub.docker.com/r/instrumentisto/geckodriver[Firefox]: -:maximize-issue: https://github.com/SeleniumHQ/selenium/issues/3508 -:chromedriver-dl: https://sites.google.com/a/chromium.org/chromedriver/downloads +[source,shell] +---- +docker run --name geckodriver -p 4444:4444 -d instrumentisto/geckodriver +---- -Remember, `brew` package manager has the outdated version 2.27. -You will probably have to download binaries from the link:{chromedriver-dl}[official site]. +To connect to an existing running WebDriver process you need to specify the `:host`. +In this example `:host` would be `localhost` or `127.0.0.1`. +The `:port` would be the appropirate port for the running WebDriver process as exposed by docker. +If the port is not specified, the <> port is set. -See the link:{maximize-issue}[related issue] in Selenium project. +//:test-doc-blocks/skip +[source,clojure] +---- +(def driver (e/chrome-headless {:host "localhost" :port 9515 :args ["--no-sandbox"]})) +(def driver (e/firefox-headless {:host "localhost"})) ;; will try to connect to port 4444 +---- -=== Querying wrong elements with XPath expressions +== Troubleshooting [[troubleshooting]] -When passing a vector-like query, say `[{:tag :p} "//*[text()='foo')]]"}]` be careful with hand-written XPath expressions. -In vector, every its expression searches from the previous one in a loop. -There is a hidden mistake here: without a leading dot, the `+"//..."+` clause means to find an element from the root of the whole page. -With a dot, it means to find from the current node, which is one from the previous query, and so forth. +=== Old Versions of WebDrivers can have Limitations -That's why, it's easy to select something completely different that what you would like. -A proper expression would be: `[{:tag :p} ".//*[text()='foo')]]"}]`. +[horizontal] +Reproduction:: For example, `chromedriver` used to throw an error when calling `maximize`: ++ +[source,clojure] +---- +(e2/with-chrome [driver] + (e/maximize driver)) +;; an exception with "cannot get automation extension" was thrown +---- +Cause:: This was a bug in `chromedriver` that was fixed in chromdriver v2.28. +Solution:: Updating to the current WebDriver resolved the issue. -=== Clicking On Non-Visible Element +=== XPath and Searching from Root vs Current node -Example: +Reproduction:: ++ +[source,clojure] +---- +;; we intend to find an element with the text 'some' under an element with id 'mishmash' +(e/get-element-text driver [{:id :mishmash} "//*[contains(text(),'some')]"]) +;; => "A little sample page to illustrate some concepts described in the Etaoin user guide." +;; but we've found the first element with text 'some' +---- +Cause:: In a vector, every expression searches from the previous one in a loop. +Without a leading dot, the XPath `+"//..."+` clause means to find an element from the root of the whole page. +With a dot, it means to find from the current node, which is one from the previous query, and so forth. +Solution:: Add the XPath dot. ++ +[source,clojure] +---- +(e/get-element-text driver [{:id :mishmash} ".//*[contains(text(),'some')]"]) +;; => "some other paragraph" +;; that's what we were looking for! +---- + +=== Clicking On Non-Visible Element +Reproduction:: +//:test-doc-blocks/skip ++ [source,clojure] ---- -etaoin.api> (click driver :some-id) -ExceptionInfo throw+: {:response { -:sessionId "d112ce8ddb49accdae78a769d5809eae", -:status 11, -:value {:message "element not visible\n (Session info: chrome=57.0.2987.133)\n -(Driver info: chromedriver=2.29.461585 -(0be2cd95f834e9ee7c46bcc7cf405b483f5ae83b),platform=Mac OS X 10.11.6 x86_64)"}}, -... +(e/click driver :cantseeme) +;; as of this writing, on chrome throws an exception with message containing 'not interactable' ---- -*Solution:* you are trying to click an element that is not visible or its dimentions are as little as it's impossible for a human to click on it. -You should pass another selector. +Cause:: You cannot interact with an element that is not visible or is so small that a human could not click on it. + +=== Selectors not Working + +Symptom:: Selectors for locating elements are not working, even though the elements are clearly available. + +Possible cause:: Your script may have clicked a link that opened a new tab or window. +Even though the new window is in the foreground, the driver instance is still connected to the original window. + +Solution:: Call `switch-window-next` when a new tab or window is opened to point the driver to the new tab/window. -=== Unpredictable errors in Chrome when window is not active +=== Unpredictable errors in Chrome when the window is not active -*Problem:* when you focus on other window, webdriver session that is run under Google Chrome fails. +Reproduction:: when you focus on another window, a WebDriver session that is run under Google Chrome fails. -*Solution:* Google Chrome may suspend a tab when it has been inactive for some time. -When the page is suspended, no operation could be done on it. +Solution:: Google Chrome may suspend a tab when it has been inactive for some time. +When the page is suspended, no operation can be done on it. No clicks, Js execution, etc. So try to keep Chrome window active during test session. -=== Invalid argument: can't kill an exited process - -*Problem:* When you try to start the driver you get an error: +=== Invalid argument: can't kill an exited process for Firefox +Reproduction:: When you try to start the driver you get an error: +//:test-doc-blocks/skip ++ [source,clojure] ---- -user=> (use 'etaoin.api) -user=> (def driver (firefox {:headless true})) +(def driver (e/firefox {:headless true})) +;; throws an exception containing message with 'invalid argument: can't kill an exited process' ---- -____ -Syntax error (ExceptionInfo) compiling at (REPL:1:13). -throw+: {:response {:value {:error "unknown error", :message "invalid argument: can't kill an exited process".... -____ - -Possible cause: "Running Firefox as root in a regular user's session is not supported" - -*Solution:* To check, run the driver with the path to the log files and the "trace" log level and explore their output. +Possible Cause:: Running Firefox as root in a regular user's session is not supported +To Diagnose:: Run the driver with the path to the log files and the "trace" log level and explore the output. +//:test-doc-blocks/skip ++ [source,clojure] ---- (def driver (firefox {:log-stdout "ffout.log" :log-stderr "fferr.log" :driver-log-level "trace"})) ---- +Similar Problem:: https://github.com/mozilla/geckodriver/issues/1655 -Similar problem: https://github.com/mozilla/geckodriver/issues/1655 - -=== DevToolsActivePort file doesn't exist +=== DevToolsActivePort file doesn't exist error on Chrome -*Problem:* When you try to start the chromedriver you get an error: +Reproduction:: When you try to start the chromedriver you get an error: -____ -clojure.lang.ExceptionInfo: throw+: {:response {:sessionId ".....", :status 13, :value {:message "unknown error: Chrome failed to start: exited abnormally.\n (unknown error: DevToolsActivePort file doesn't exist)... -____ - -Possible cause: +//:test-doc-blocks/skip +[source,clojure] +---- +(def driver (e/chrome)) +;; throws an exception with message containing 'DevToolsActivePort file doesn't exist' +---- -____ +Possible Cause:: A common cause for Chrome to crash during startup is running Chrome as root user (administrator) on Linux. While it is possible to work around this issue by passing --no-sandbox flag when creating your WebDriver session, such a configuration is unsupported and highly discouraged. You need to configure your environment to run Chrome as a regular user instead. -____ -*Solution:* Run driver with an argument `--no-sandbox`. +Potential Solution:: Run driver with an argument `--no-sandbox`. Caution! -This is a bypass OS security model. - -[source,clojure] ----- -(def driver (chrome {:args ["--no-sandbox"]})) ----- - -A similar problem is described https://stackoverflow.com/questions/50642308/webdriverexception-unknown-error-devtoolsactiveport-file-doesnt-exist-while-t[here] - - -=== Selectors not working - -*Problem:* Selectors for locating element are not working, even though the elements are clearly available. - -Possible cause: Your script may have clicked a link that opened a new tab or window. -Even though the new window is in the foreground, the driver instance is still connected to the original window. - -*Solution:* Call `switch-window-next` when a new tab or window is opened to point the driver to the new tab/window. - - -== API v2 - -The `etaoin.api2` namespace brings some bits of alternative macros and functions. -They provide better syntax and live in a separate namespace to prevent the old API from breaking. - -At the moment, the `api2` module provides a set of `+with-...+` macros with a `let`-like binding form: - +This bypasses OS security model. ++ [source,clojure] ---- -(ns ... - (:require - [etaoin.api :as api] - [etaoin.api2 :as api2])) - -(api2/with-chrome [driver {}] - (api/go driver "http://ya.ru")) +(e2/with-chrome [driver {:args ["--no-sandbox"]}] + (e/go driver "https://clojure.org")) ---- -The options map can be skipped so you have only a binding symbol: +Similiar Problem:: A similar problem is described https://stackoverflow.com/questions/50642308/webdriverexception-unknown-error-devtoolsactiveport-file-doesnt-exist-while-t[here] +// A little invisible cheat to cleanup our driver +ifdef::env-test-doc-blocks[] [source,clojure] ---- -(api2/with-firefox [ff] - (api/go ff "http://ya.ru")) +(e/quit driver) ---- +endif::[] diff --git a/doc/02-developer-guide.adoc b/doc/02-developer-guide.adoc index 27c48692..e10a956a 100644 --- a/doc/02-developer-guide.adoc +++ b/doc/02-developer-guide.adoc @@ -67,6 +67,9 @@ All documentation is written in AsciiDoc. We host our docs on cljdoc and have support for <> + + + == Babashka Tasks We use Babashka tasks, to see all available tasks run: @@ -114,6 +117,19 @@ bb test --help We'll likely add finer grained test selection to satisfy developer needs. For now, temporarily tweak `./script/test.clj` if you need to. +==== Testing User Guide Code Blocks + +There are many code examples in the user guide. +In an attempt to ensure they are in working order, we run a selection of them through https://github.com/lread/test-doc-blocks[test-doc-blocks]. + +[source,shell] +---- +bb test-doc +---- + +If you are updating the user guide, it preferable if your code block can be run through test-doc-blocks. +But if this is impractal, you can also have test-doc-blocks skip a code block. + ==== Testing within Docker If you wish, you can build a local docker image for testing on Linux. diff --git a/doc/user-guide-sample-frame1.html b/doc/user-guide-sample-frame1.html new file mode 100644 index 00000000..84ba07aa --- /dev/null +++ b/doc/user-guide-sample-frame1.html @@ -0,0 +1,2 @@ +

In frame1 paragraph

+ + +

A longer section

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed turpis tincidunt id aliquet risus feugiat in ante. Dui faucibus in ornare quam viverra. Turpis in eu mi bibendum neque egestas congue. Quam lacus suspendisse faucibus interdum posuere lorem ipsum. Nisl nunc mi ipsum faucibus vitae aliquet. Bibendum enim facilisis gravida neque convallis a cras semper. Vestibulum morbi blandit cursus risus at ultrices mi. Pellentesque diam volutpat commodo sed egestas. Id nibh tortor id aliquet lectus proin. Mauris nunc congue nisi vitae suscipit tellus. Eleifend donec pretium vulputate sapien nec sagittis aliquam malesuada bibendum. Pulvinar pellentesque habitant morbi tristique. At risus viverra adipiscing at in tellus integer feugiat scelerisque. Felis donec et odio pellentesque diam volutpat commodo sed. Neque sodales ut etiam sit amet nisl purus in. Nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque. Lorem ipsum dolor sit amet consectetur adipiscing elit duis. + +Malesuada bibendum arcu vitae elementum. Nunc faucibus a pellentesque sit. Aliquet eget sit amet tellus cras. Pellentesque id nibh tortor id. Id nibh tortor id aliquet. Et molestie ac feugiat sed lectus vestibulum. Integer quis auctor elit sed vulputate mi sit amet. Metus aliquam eleifend mi in nulla posuere sollicitudin. Gravida in fermentum et sollicitudin ac orci phasellus egestas tellus. Sed felis eget velit aliquet sagittis id. Enim facilisis gravida neque convallis a cras semper auctor neque. Massa vitae tortor condimentum lacinia quis vel. + +Ut tortor pretium viverra suspendisse potenti nullam. Sed vulputate mi sit amet mauris commodo quis imperdiet massa. Mi in nulla posuere sollicitudin. Elit at imperdiet dui accumsan sit amet nulla. Urna nec tincidunt praesent semper feugiat nibh sed. Egestas purus viverra accumsan in. Aliquam sem fringilla ut morbi tincidunt augue. Commodo ullamcorper a lacus vestibulum sed arcu. In massa tempor nec feugiat nisl pretium fusce. Nibh mauris cursus mattis molestie a. Nunc consequat interdum varius sit. In est ante in nibh. Augue interdum velit euismod in pellentesque massa placerat. Tincidunt id aliquet risus feugiat in ante. Ac turpis egestas integer eget aliquet nibh praesent tristique magna. Quam adipiscing vitae proin sagittis nisl. Tellus id interdum velit laoreet id donec ultrices tincidunt. + +Molestie at elementum eu facilisis sed odio morbi. Mauris rhoncus aenean vel elit scelerisque mauris. Maecenas volutpat blandit aliquam etiam. Amet consectetur adipiscing elit pellentesque habitant morbi tristique senectus et. Urna et pharetra pharetra massa massa ultricies mi. Turpis egestas maecenas pharetra convallis posuere morbi leo urna molestie. Sed viverra tellus in hac habitasse. Gravida dictum fusce ut placerat orci. Tellus pellentesque eu tincidunt tortor aliquam nulla facilisi cras. Facilisis mauris sit amet massa vitae. In hendrerit gravida rutrum quisque. + +Sed vulputate odio ut enim blandit. Vulputate eu scelerisque felis imperdiet proin fermentum leo. Et malesuada fames ac turpis egestas integer eget. Ante in nibh mauris cursus mattis molestie a iaculis. Bibendum neque egestas congue quisque egestas diam in arcu. Sed adipiscing diam donec adipiscing tristique. Non consectetur a erat nam at lectus. Orci nulla pellentesque dignissim enim. Velit ut tortor pretium viverra suspendisse potenti nullam. Dolor morbi non arcu risus quis. Nulla facilisi etiam dignissim diam quis enim. Dolor morbi non arcu risus quis varius quam. Nunc sed blandit libero volutpat sed cras ornare. Libero volutpat sed cras ornare arcu. Scelerisque varius morbi enim nunc faucibus a. Euismod in pellentesque massa placerat. Scelerisque in dictum non consectetur. + +Neque gravida in fermentum et sollicitudin. Vel orci porta non pulvinar neque laoreet suspendisse interdum consectetur. Risus feugiat in ante metus. Cursus in hac habitasse platea dictumst quisque sagittis. Dolor morbi non arcu risus quis varius quam. Habitasse platea dictumst vestibulum rhoncus est pellentesque elit. In hac habitasse platea dictumst. Porttitor eget dolor morbi non arcu. Nunc mattis enim ut tellus elementum sagittis vitae et. Accumsan sit amet nulla facilisi morbi tempus. Vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt. Feugiat pretium nibh ipsum consequat nisl vel pretium. Ut tristique et egestas quis ipsum suspendisse ultrices gravida. + +Aliquam sem fringilla ut morbi tincidunt augue interdum. Fermentum dui faucibus in ornare quam viverra orci. Donec ac odio tempor orci dapibus ultrices in. Volutpat commodo sed egestas egestas. Rhoncus est pellentesque elit ullamcorper dignissim. Aliquam eleifend mi in nulla posuere. Vitae elementum curabitur vitae nunc sed velit. Proin sed libero enim sed faucibus turpis. Id ornare arcu odio ut. Proin nibh nisl condimentum id venenatis a. Donec pretium vulputate sapien nec sagittis aliquam. Eu lobortis elementum nibh tellus molestie nunc non blandit massa. Sagittis purus sit amet volutpat consequat mauris nunc. Eget sit amet tellus cras adipiscing. Curabitur vitae nunc sed velit. Risus pretium quam vulputate dignissim suspendisse in est ante. Aenean pharetra magna ac placerat. Gravida arcu ac tortor dignissim convallis aenean et tortor. Nulla aliquet porttitor lacus luctus accumsan tortor posuere ac ut. + +Lectus quam id leo in vitae turpis massa. Etiam non quam lacus suspendisse. Gravida quis blandit turpis cursus in hac. Urna neque viverra justo nec ultrices dui. Nec feugiat in fermentum posuere. Elit at imperdiet dui accumsan. Quis eleifend quam adipiscing vitae proin. Integer quis auctor elit sed vulputate mi sit. Quam id leo in vitae turpis massa sed. Sagittis aliquam malesuada bibendum arcu vitae. Tincidunt eget nullam non nisi est sit amet facilisis magna. Nunc mi ipsum faucibus vitae aliquet. + +Feugiat scelerisque varius morbi enim nunc faucibus a pellentesque. Aenean sed adipiscing diam donec adipiscing tristique risus nec feugiat. Ultricies leo integer malesuada nunc vel risus commodo viverra. Urna neque viverra justo nec. Interdum velit euismod in pellentesque massa. Sed cras ornare arcu dui vivamus arcu felis. Risus quis varius quam quisque id. Cursus metus aliquam eleifend mi in. Eget felis eget nunc lobortis mattis aliquam faucibus purus in. Nunc scelerisque viverra mauris in aliquam sem fringilla ut. Morbi tincidunt ornare massa eget egestas purus. Nisl vel pretium lectus quam id leo. + +Enim praesent elementum facilisis leo. Placerat in egestas erat imperdiet sed euismod nisi. Volutpat sed cras ornare arcu dui vivamus arcu felis bibendum. Erat imperdiet sed euismod nisi. Mauris commodo quis imperdiet massa tincidunt nunc pulvinar sapien et. Ultrices mi tempus imperdiet nulla malesuada pellentesque elit eget. Pretium vulputate sapien nec sagittis aliquam. Arcu cursus vitae congue mauris rhoncus aenean vel elit scelerisque. Amet consectetur adipiscing elit duis. Arcu cursus vitae congue mauris. Volutpat maecenas volutpat blandit aliquam. At ultrices mi tempus imperdiet nulla malesuada pellentesque elit. Purus non enim praesent elementum facilisis leo vel fringilla est. Massa eget egestas purus viverra accumsan in nisl nisi. +

Last section

+
+   Avast, me proud beauty! Wanna know why my Roger is so Jolly? You’re drinking a Salty Dog? How’d you like to try the real thing? Drink up me hearties yoho …a pirates life for me Always be yourself, unless you can be a pirate. Then always be a pirate. STOP BLOWING HOLES IN MY SHIP!!! Work like a captain, play like a pirate. Arrrrrrrr Ahoy! lets trouble the water!
+
+   Come on up and see me urchins. Yes, that is a hornpipe in my pocket and I am happy to see you. Why is the rum gone? The average man will bristle if you say his father was dishonest, but he will brag a little if he discovers that his great- grandfather was a pirate.
+
+   You can always trust the untrustworthy because you can always trust that they will be untrustworthy. Its the trustworthy you can’t trust. Not all treasure is silver and gold Yarrrr! there be ony two ranks of leader amongst us pirates! Captain and if your really notorious then it’s Cap’n! Whats a pirate’s favorite fast food restaurant?  Arrrrbys! Arrrrrrrr
+
+   That’s some treasure chest you’ve got there. What are YOU doing here? Why is the rum gone? Drink up me hearties yoho …a pirates life for me Suddenly you’re like a pirate, you’re 65 years old and you’ve got an ear- ring. Work like a captain, play like a pirate. Well actualy piracy is a democracy with captains voted for by the crew. Arrrrrrrr
+
+   Have ya ever met a man with a real yardarm? You’re drinking a Salty Dog? How’d you like to try the real thing? So, tell me, why do they call ye, “Cap’n Feathersword?” You can always trust the untrustworthy because you can always trust that they will be untrustworthy. Its the trustworthy you can’t trust.
+   
+ + diff --git a/env/dev/demo.clj b/env/dev/demo.clj index df7b5b9e..dcb0617c 100644 --- a/env/dev/demo.clj +++ b/env/dev/demo.clj @@ -1,45 +1,44 @@ -#_{:clj-kondo/ignore [:use]} -(use 'etaoin.api) -(require '[etaoin.keys :as k]) +(require '[etaoin.api :as e] + '[etaoin.keys :as k]) -(def driver (chrome)) +(def driver (e/chrome)) -(go driver "https://en.wikipedia.org/") +(e/go driver "https://en.wikipedia.org/") (def query-search {:tag :input :name :search}) -(wait-visible driver [{:id :simpleSearch} query-search]) +(e/wait-visible driver [{:id :simpleSearch} query-search]) ;; search for something -(fill driver query-search "Clojure programming language") +(e/fill driver query-search "Clojure programming language") -(clear driver query-search) +(e/clear driver query-search) -(fill-human driver query-search "Clojure programming language") +(e/fill-human driver query-search "Clojure programming language") -(fill driver query-search k/enter) -(wait-visible driver {:class :mw-search-results}) +(e/fill driver query-search k/enter) +(e/wait-visible driver {:class :mw-search-results}) -(scroll-down driver 100) +(e/scroll-down driver 100) ;; I'm sure the first link is what I was looking for -(click driver [{:class :mw-search-results} - {:class :mw-search-result-heading} - {:tag :a}]) +(e/click driver [{:class :mw-search-results} + {:class :mw-search-result-heading} + {:tag :a}]) -(wait-visible driver {:id :firstHeading}) +(e/wait-visible driver {:id :firstHeading}) -(get-url driver) -(get-title driver) +(e/get-url driver) +(e/get-title driver) -(has-text? driver "Clojure") +(e/has-text? driver "Clojure") ;; navigate on history -(back driver) -(forward driver) -(refresh driver) +(e/back driver) +(e/forward driver) +(e/refresh driver) -(screenshot driver "clojure.png") +(e/screenshot driver "clojure.png") ;; stops Firefox and HTTP server -(quit driver) +(e/quit driver) diff --git a/resources/clj-kondo.exports/etaoin/etaoin/config.edn b/resources/clj-kondo.exports/etaoin/etaoin/config.edn index db2b79fc..db97bee8 100644 --- a/resources/clj-kondo.exports/etaoin/etaoin/config.edn +++ b/resources/clj-kondo.exports/etaoin/etaoin/config.edn @@ -1,4 +1,7 @@ -{:hooks +{:linters + {:etaoin/with-x-action {:level :error} + :etaoin/binding-sym {:level :error}} + :hooks {:analyze-call {etaoin.api/with-chrome etaoin.api/with-browser etaoin.api/with-firefox etaoin.api/with-browser diff --git a/resources/clj-kondo.exports/etaoin/etaoin/etaoin/api.clj_kondo b/resources/clj-kondo.exports/etaoin/etaoin/etaoin/api.clj_kondo index d48bf872..b4cf3180 100644 --- a/resources/clj-kondo.exports/etaoin/etaoin/etaoin/api.clj_kondo +++ b/resources/clj-kondo.exports/etaoin/etaoin/etaoin/api.clj_kondo @@ -7,10 +7,13 @@ binding-sym (nth macro-args bound-arg-ndx nil)] (if-not (h/symbol-node? binding-sym) ;; could use clj-kondo findings, but I think this is good for now - (throw (ex-info (format "Expected binding symbol as %s arg" - ;; use words instead of numbers to avoid ambiguity - (case 1 "second" - 2 "third")) {})) + (api/reg-finding! (assoc (if binding-sym + (meta binding-sym) + (meta node)) + :message (format "Expected binding symbol as %s arg" + ;; use words instead of numbers to avoid ambiguity + (case bound-arg-ndx 1 "second" 2 "third")) + :type :etaoin/binding-sym)) (let [leading-args (take bound-arg-ndx macro-args) body (drop (inc bound-arg-ndx) macro-args)] {:node (api/list-node @@ -23,16 +26,35 @@ (api/vector-node leading-args) body))})))) -(defn- with-x-down [node] +(defn- with-x-down + "This is somewhat of a maybe an odd duck. + I think it is assumed to be used within a threading macro. + And itself employs a threadfirst macro. + So each body form need to have an action (dummy or not) threaded into it." + [node] (let [macro-args (rest (:children node)) - [leading-args body] (split-at 2 macro-args)] + [input x & body] macro-args + dummy-action (api/map-node [])] {:node (api/list-node - (list* - (api/token-node 'do) - ;; dump the body - (api/list-node (list* body)) - ;; reference the other args so that they are not linted as unused (if they happen to be symbols) - (api/vector-node leading-args)))})) + (apply list* + (api/token-node 'do) + ;; reference x and input just in case they contain something lint-relevant + x input + ;; dump the body, threading a dummy action in as first arg + (map (fn [body-form] + (cond + ;; not certain this is absolutely what we want, but maybe close enough + (h/symbol-node? body-form) (api/list-node (list* body-form dummy-action)) + (api/list-node? body-form) (let [children (:children body-form)] + (assoc body-form :children (apply list* + (first children) + dummy-action + (rest children)))) + :else + (api/reg-finding! (assoc (meta body-form) + :message "expected to be threaded through an action" + :type :etaoin/with-x-action)))) + body)))})) (defn with-browser "Covers etaoin.api/with-chrome and all its variants @@ -46,8 +68,12 @@ [{:keys [node]}] (with-bound-arg node 2)) -(defn with-key-down [{:keys [node]}] +(defn with-key-down + "[input key & body]" + [{:keys [node]}] (with-x-down node)) -(defn with-pointer-btn-down [{:keys [node]}] +(defn with-pointer-btn-down + "[input button & body]" + [{:keys [node]}] (with-x-down node)) diff --git a/script/test.clj b/script/test.clj index 254f3ee6..efffd7f3 100644 --- a/script/test.clj +++ b/script/test.clj @@ -40,7 +40,8 @@ (and (= "windows" os) (= "safari" browser))))] (test-def os "api" platform browser))) (sort-by :desc) - (into [{:os "ubuntu" :cmd "bb lint" :desc "lint"}])))) + (into [{:os "ubuntu" :cmd "bb lint" :desc "lint"} + {:os "macos" :cmd "bb test-doc" :desc "test-doc"}])))) (defn- launch-xvfb [] (if (fs/which "Xvfb") diff --git a/script/test_doc.clj b/script/test_doc.clj new file mode 100644 index 00000000..cbf875b0 --- /dev/null +++ b/script/test_doc.clj @@ -0,0 +1,23 @@ +#!/usr/bin/env bb + +(ns test-doc + (:require [helper.main :as main] + [helper.shell :as shell] + [lread.status-line :as status])) + +(defn generate-doc-tests [] + (status/line :head "Generating tests for code blocks in documents") + (shell/clojure "-X:test-doc-blocks gen-tests")) + +(defn run-clj-doc-tests [] + (status/line :head "Running code block tests under Clojure") + (shell/clojure "-M:test:test-docs" )) + +(defn -main [& args] + (when (main/doc-arg-opt args) + (generate-doc-tests) + (run-clj-doc-tests)) + nil) + +(main/when-invoked-as-script + (apply -main *command-line-args*)) diff --git a/src/etaoin/api.clj b/src/etaoin/api.clj index fd9a14fb..8bd39700 100644 --- a/src/etaoin/api.clj +++ b/src/etaoin/api.clj @@ -17,24 +17,24 @@ Phantom.js (Ghostdriver) - obsolete and no longer tested https://github.com/detro/ghostdriver/blob/ " - (:require [etaoin.impl.proc :as proc] - [etaoin.impl.client :as client] - [etaoin.keys :as keys] - [etaoin.query :as query] - [etaoin.impl.util :as util :refer [defmethods]] - [etaoin.impl.driver :as drv] - [etaoin.impl.xpath :as xpath] - - [clojure.tools.logging :as log] - [clojure.java.io :as io] - [clojure.string :as str] - - [babashka.fs :as fs] - [cheshire.core :refer [generate-stream]] - [slingshot.slingshot :refer [try+ throw+]]) - - (:import (java.util Date Base64) - java.text.SimpleDateFormat)) + (:require + [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.java.io :as io] + [clojure.string :as str] + [clojure.tools.logging :as log] + [etaoin.impl.client :as client] + [etaoin.impl.driver :as drv] + [etaoin.impl.proc :as proc] + [etaoin.impl.util :as util :refer [defmethods]] + [etaoin.impl.xpath :as xpath] + [etaoin.keys :as k] + [etaoin.query :as query] + [slingshot.slingshot :refer [throw+ try+]]) + + (:import + java.text.SimpleDateFormat + (java.util Base64 Date))) ;; ;; defaults @@ -426,7 +426,7 @@ ;; (defn go - "Open the URL the current window. + "Open the `url` in the current window. Example: @@ -633,8 +633,6 @@ (defn query-tree "Takes selectors and acts like a tree. Every next selector queries elements from the previous ones. - The fist selector relies on find-elements, - and the rest ones use find-elements-from {:tag :div} {:tag :a} means @@ -642,7 +640,7 @@ div1 -> [a1 a2 a3] div2 -> [a4 a5 a6] div3 -> [a7 a8 a9] - so the result will be [a1 ... a9] + so the result will be #{a1 ... a9} " [driver q & qs] (reduce (fn [elements q] @@ -733,13 +731,13 @@ [input & [button]] (add-action input {:type "pointerDown" :duration 0 - :button (or button keys/mouse-left)})) + :button (or button k/mouse-left)})) (defn add-pointer-up [input & [button]] (add-action input {:type "pointerUp" :duration 0 - :button (or button keys/mouse-left)})) + :button (or button k/mouse-left)})) (defn add-pointer-cancel [input] @@ -1056,17 +1054,17 @@ (defn left-click "A shortcut for `mouse-click` with the left button." [driver] - (mouse-click driver keys/mouse-left)) + (mouse-click driver k/mouse-left)) (defn right-click "A shortcut for `mouse-click` with the right button." [driver] - (mouse-click driver keys/mouse-right)) + (mouse-click driver k/mouse-right)) (defn middle-click "A shortcut for `mouse-click` with the middle button." [driver] - (mouse-click driver keys/mouse-middle)) + (mouse-click driver k/mouse-middle)) (defn mouse-click-on " @@ -1084,7 +1082,7 @@ that one, use `click` instead. " [driver q] - (mouse-click-on driver keys/mouse-left q)) + (mouse-click-on driver k/mouse-left q)) (defn right-click-on " @@ -1092,7 +1090,7 @@ and right click on it. " [driver q] - (mouse-click-on driver keys/mouse-right q)) + (mouse-click-on driver k/mouse-right q)) (defn middle-click-on " @@ -1101,7 +1099,7 @@ in a new tab. " [driver q] - (mouse-click-on driver keys/mouse-middle q)) + (mouse-click-on driver k/mouse-middle q)) ;; @@ -1932,7 +1930,7 @@ (defn- dump-logs [logs filename & [opt]] - (generate-stream + (json/generate-stream logs (io/writer filename) (merge {:pretty true} opt))) @@ -2728,7 +2726,7 @@ (when (< (rand) mistake-prob) (fill-el driver el (rand-char)) (wait-key) - (fill-el driver el keys/backspace) + (fill-el driver el k/backspace) (wait-key)) (fill-el driver el key) (wait-key)))) @@ -2835,7 +2833,7 @@ (defn submit "Sends Enter button value to an element found with query." [driver q] - (fill driver q keys/enter)) + (fill driver q k/enter)) ;; ;; timeouts diff --git a/src/etaoin/api2.clj b/src/etaoin/api2.clj index 20b0b603..8dc3d6bf 100644 --- a/src/etaoin/api2.clj +++ b/src/etaoin/api2.clj @@ -4,52 +4,49 @@ without breaking them. " (:require - [etaoin.api :as api])) - + [etaoin.api :as e])) (defmacro with-firefox [[bind & [options]] & body] - `(api/with-driver :firefox ~options ~bind + `(e/with-driver :firefox ~options ~bind ~@body)) - (defmacro with-chrome [[bind & [options]] & body] - `(api/with-driver :chrome ~options ~bind + `(e/with-driver :chrome ~options ~bind ~@body)) - (defmacro with-edge [[bind & [options]] & body] - `(api/with-driver :edge ~options ~bind + `(e/with-driver :edge ~options ~bind ~@body)) (defmacro with-phantom [[bind & [options]] & body] - `(api/with-driver :phantom ~options ~bind + `(e/with-driver :phantom ~options ~bind ~@body)) (defmacro with-safari [[bind & [options]] & body] - `(api/with-driver :safari ~options ~bind + `(e/with-driver :safari ~options ~bind ~@body)) (defmacro with-chrome-headless [[bind & [options]] & body] - `(api/with-driver :chrome (assoc ~options :headless true) ~bind + `(e/with-driver :chrome (assoc ~options :headless true) ~bind ~@body)) (defmacro with-firefox-headless [[bind & [options]] & body] - `(api/with-driver :firefox (assoc ~options :headless true) ~bind + `(e/with-driver :firefox (assoc ~options :headless true) ~bind ~@body)) (defmacro with-edge-headless [[bind & [options]] & body] - `(api/with-driver :edge (assoc ~options :headless true) ~bind + `(e/with-driver :edge (assoc ~options :headless true) ~bind ~@body)) diff --git a/src/etaoin/dev.clj b/src/etaoin/dev.clj index 935097cf..0a49ef78 100644 --- a/src/etaoin/dev.clj +++ b/src/etaoin/dev.clj @@ -3,23 +3,20 @@ A namespace to cover Chrome's devtools features. " (:require - [clojure.string :as str] [cheshire.core :as json] + [clojure.string :as str] [etaoin.api :as api])) - (defn try-parse-int [line] (try (Integer/parseInt line) (catch Exception _e line))) - (defn parse-json [string] (json/parse-string string true)) - (defn parse-method " Turns a string like 'Network.SomeAction' @@ -32,7 +29,6 @@ (str/split #"\." 2))] (keyword topname lowname))) - (defn process-log " Takes a log map, parses its message and merges @@ -48,7 +44,6 @@ (merge message) (assoc :_type _type)))) - (defn request? " True if a log entry belongs to a network domain. @@ -56,7 +51,6 @@ [log] (some-> log :_type namespace (= "network"))) - (defn group-requests " Group a set of request logs by their ID. @@ -67,7 +61,6 @@ (some-> log :message :params :requestId)) logs)) - (defn log->request " A helper for a further reduce (see below). diff --git a/src/etaoin/ide/flow.clj b/src/etaoin/ide/flow.clj index 44e91093..83e3d6bb 100644 --- a/src/etaoin/ide/flow.clj +++ b/src/etaoin/ide/flow.clj @@ -6,21 +6,18 @@ [cheshire.core :as json] [clojure.set :as cset] [clojure.spec.alpha :as s] - [etaoin.api :refer :all] + [etaoin.api :as e] [etaoin.ide.impl.api :refer [run-command-with-log str->var]] [etaoin.ide.impl.spec :as spec])) - (declare execute-commands) - (defn execute-branch [driver {:keys [this branch]} opt] (when (run-command-with-log driver this opt) (execute-commands driver branch opt) true)) - (defn execute-if [driver {:keys [if else-if else end]} opt] (or (execute-branch driver if opt) @@ -28,7 +25,6 @@ (execute-commands driver (:branch else) opt)) (run-command-with-log driver end opt)) - (defn execute-times [driver {:keys [this branch end]} opt] (let [n (run-command-with-log driver this opt)] @@ -36,7 +32,6 @@ (execute-commands driver commands opt)) (run-command-with-log driver end opt))) - (defn execute-do [driver {:keys [this branch repeat-if]} opt] (run-command-with-log driver this opt) @@ -45,14 +40,12 @@ (when (run-command-with-log driver repeat-if opt) (recur commands)))) - (defn execute-while [driver {:keys [this branch end]} opt] (while (run-command-with-log driver this opt) (execute-commands driver branch opt)) (run-command-with-log driver end opt)) - (defn execute-for-each [driver {:keys [this branch end]} {vars :vars :as opt}] (let [[var-name arr] (run-command-with-log driver this opt)] @@ -61,17 +54,15 @@ (execute-commands driver branch opt)) (run-command-with-log driver end opt))) - (defn execute-cmd-with-open-window [driver {:keys [windowHandleName windowTimeout] :as cmd} {vars :vars :as opt}] - (let [init-handles (set (get-window-handles driver)) + (let [init-handles (set (e/get-window-handles driver)) _ (run-command-with-log driver cmd opt) - _ (wait (/ windowTimeout 1000)) - final-handles (set (get-window-handles driver)) + _ (e/wait (/ windowTimeout 1000)) + final-handles (set (e/get-window-handles driver)) handle (first (cset/difference final-handles init-handles))] (swap! vars assoc (str->var windowHandleName) handle))) - (defn execute-commands [driver commands opt] (doseq [[cmd-name cmd] commands] @@ -85,7 +76,6 @@ :cmd (run-command-with-log driver cmd opt) (throw (ex-info "Command is not valid" {:command cmd}))))) - (defn run-ide-test [driver {:keys [commands]} & [opt]] (let [command->kw (fn [{:keys [command] :as cmd}] @@ -97,7 +87,6 @@ {:explain-data (s/explain-data ::spec/commands commands)}))) (execute-commands driver commands-tree opt))) - (defn get-tests-by-suite-id [suite-id id {:keys [suites tests]}] (let [test-ids (-> (filter #(= suite-id (id %)) suites) @@ -107,7 +96,6 @@ suite-tests (filter #(test-ids (:id %)) tests)] suite-tests)) - (defn find-tests [{:keys [test-id test-ids suite-id suite-ids test-name suite-name test-names suite-names]} {:keys [tests] :as parsed-file}] @@ -125,7 +113,6 @@ tests tests-found))) - (defn run-ide-script " Run a Selenium IDE file. diff --git a/src/etaoin/ide/impl/api.clj b/src/etaoin/ide/impl/api.clj index 4a1e8ec3..28cc4788 100644 --- a/src/etaoin/ide/impl/api.clj +++ b/src/etaoin/ide/impl/api.clj @@ -8,10 +8,9 @@ [clojure.string :as str] [clojure.test :refer [is]] [clojure.tools.logging :as log] - [etaoin.api :refer :all] - [etaoin.keys :as k] - [etaoin.impl.util :refer [defmethods]])) - + [etaoin.api :as e] + [etaoin.impl.util :refer [defmethods]] + [etaoin.keys :as k])) (defn absolute-path? [path] @@ -19,7 +18,6 @@ str/lower-case (str/starts-with? "http"))) - (defn str->var " Turn ${Foo} into Foo. @@ -29,7 +27,6 @@ (keyword (subs var 2 (-> var count dec))) (keyword var))) - (def special-keys {"ADD" k/num-+ "ALT" k/alt-left @@ -112,7 +109,6 @@ "TAB" k/tab "UP" k/arrow-up}) - (defn fill-str-with-vars [string vars] (reduce (fn [acc [k v]] @@ -120,7 +116,6 @@ value (str v)] (str/replace acc pattern value))) string vars)) - (defn gen-send-key-input [input] (let [pattern #"\$\{KEY_([^}]+)\}" @@ -132,7 +127,6 @@ sp-key (str (get special-keys key))] (str/replace acc pattern sp-key))) input keys))) - (defn gen-script-arguments [script vars] (reduce (fn [acc [k v]] @@ -140,12 +134,10 @@ js-val (str/replace (json/generate-string v) #"\"" "'")] (str/replace acc pattern js-val))) script vars)) - (defn gen-expession-script [script vars] (str "return " (gen-script-arguments script vars))) - (defn make-query [target] (let [[type val] (str/split target #"=" 2)] @@ -155,7 +147,6 @@ "linkText" {:tag :a :fn/has-text val} {:css (format "[%s]" target)}))) - (defn make-absolute-url [base-url target] (let [target (if (str/starts-with? target "/") @@ -166,201 +157,173 @@ base-url)] (str base-url "/" target))) - (defn make-assert-msg [command actual expected] (format "\nAssert command:\"%s\"\nExpected: %s\nActual: %s" (name command) expected actual)) - (defn dispatch-command #_{:clj-kondo/ignore [:unused-binding]} [driver command & [opt]] (some-> command :command)) - (defmulti run-command dispatch-command) - (defmethod run-command :default [_driver command & _] (log/warnf "The \"%s\" command is not implemented" (:command command))) - (defmethod run-command :assert [_driver {:keys [target value command]} & [{vars :vars}]] (let [stored-value (str (get @vars (str->var target)))] (assert (= stored-value value) (make-assert-msg command stored-value value)))) - (defmethods run-command [:assertAlert :assertConfirmation :assertPrompt] [driver {:keys [target command]} & [{_vars :vars}]] - (let [alert-msg (get-alert-text driver)] - (dismiss-alert driver) + (let [alert-msg (e/get-alert-text driver)] + (e/dismiss-alert driver) (assert (= alert-msg target) (make-assert-msg command alert-msg target)))) - (defmethod run-command :assertChecked [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (selected? driver (make-query target))] + (let [actual (e/selected? driver (make-query target))] (assert actual (make-assert-msg command actual true)))) - (defmethod run-command :assertNotChecked [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (selected? driver (make-query target))] + (let [actual (e/selected? driver (make-query target))] (assert (not actual) (make-assert-msg command actual false)))) - (defmethod run-command :assertEditable [driver {:keys [target command]} & [{_vars :vars}]] (let [q (make-query target) - actual (and (enabled? driver (make-query target)) - (nil? (get-element-attr driver q :readonly)))] + actual (and (e/enabled? driver (make-query target)) + (nil? (e/get-element-attr driver q :readonly)))] (assert actual (make-assert-msg command actual true)))) - (defmethod run-command :assertNotEditable [driver {:keys [target command]} & [{_vars :vars}]] (let [q (make-query target) - actual (and (enabled? driver (make-query target)) - (nil? (get-element-attr driver q :readonly)))] + actual (and (e/enabled? driver (make-query target)) + (nil? (e/get-element-attr driver q :readonly)))] (assert (not actual) (make-assert-msg command actual false)))) - (defmethod run-command :assertElementPresent [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (exists? driver (make-query target))] + (let [actual (e/exists? driver (make-query target))] (assert actual (make-assert-msg command actual true)))) - (defmethod run-command :assertElementNotPresent [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (absent? driver (make-query target))] + (let [actual (e/absent? driver (make-query target))] (assert actual (make-assert-msg command actual true)))) - (defmethods run-command [:assertValue :assertSelectedValue] [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-val (get-element-value driver (make-query target))] + (let [actual-val (e/get-element-value driver (make-query target))] (assert (= actual-val value) (make-assert-msg command actual-val value)))) - (defmethod run-command :assertNotSelectedValue [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-val (get-element-value driver (make-query target))] + (let [actual-val (e/get-element-value driver (make-query target))] (assert (not= actual-val value) (make-assert-msg command actual-val value)))) - (defmethod run-command :assertText [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-text (get-element-text driver (make-query target))] + (let [actual-text (e/get-element-text driver (make-query target))] (assert (= actual-text value) (make-assert-msg command actual-text value)))) - (defmethod run-command :assertNotText [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-text (get-element-text driver (make-query target))] + (let [actual-text (e/get-element-text driver (make-query target))] (assert (not= actual-text value) (make-assert-msg command actual-text value)))) - (defmethod run-command :assertSelectedLabel [driver {:keys [target value command]} & [{_vars :vars}]] (let [q (make-query target) - selected-val (get-element-value driver q) - option-el (query driver q {:value selected-val}) - option-text (get-element-text-el driver option-el)] + selected-val (e/get-element-value driver q) + option-el (e/query driver q {:value selected-val}) + option-text (e/get-element-text-el driver option-el)] (assert (= option-text value) (make-assert-msg command option-text value)))) - (defmethod run-command :assertTitle [driver {:keys [target command]} & [{_vars :vars}]] - (let [title (get-title driver)] + (let [title (e/get-title driver)] (assert (= title target) (make-assert-msg command title target)))) - (defmethod run-command :check [driver {:keys [target]} & [{_base-url :base-url}]] (let [q (make-query target)] - (when-not (selected? driver q) - (click driver q)))) - + (when-not (e/selected? driver q) + (e/click driver q)))) (defmethod run-command :click [driver {:keys [target]} & [_opt]] - (click driver (make-query target))) - + (e/click driver (make-query target))) (defmethod run-command :close [driver _ & _] - (close-window driver)) - + (e/close-window driver)) (defmethod run-command :doubleClick [driver {:keys [target]} & [_opt]] - (double-click driver (make-query target))) - + (e/double-click driver (make-query target))) (defmethod run-command :dragAndDropToObject [driver {:keys [target value]} & [_opt]] - (drag-and-drop driver - (make-query target) - (make-query value))) - + (e/drag-and-drop driver + (make-query target) + (make-query value))) (defmethod run-command :echo [_driver {:keys [target]} {vars :vars}] (println (fill-str-with-vars target @vars))) - (defmethod run-command :executeScript [driver {:keys [target value]} & [{vars :vars}]] - (let [result (js-execute driver (gen-script-arguments target @vars))] + (let [result (e/js-execute driver (gen-script-arguments target @vars))] (when-not (str/blank? value) (swap! vars assoc (str->var value) result)) result)) - (defmethod run-command :open [driver {:keys [target]} & [{base-url :base-url}]] (if (absolute-path? target) - (go driver target) - (go driver (make-absolute-url base-url target)))) - + (e/go driver target) + (e/go driver (make-absolute-url base-url target)))) (defmethod run-command :pause [_driver {:keys [target]} & [_opt]] - (wait (/ (Integer/parseInt target) 1000))) - + (e/wait (/ (Integer/parseInt target) 1000))) ;; TODO refactor select fn, add select by-value (defmethods run-command @@ -369,32 +332,30 @@ (let [[type val] (str/split value #"=" 2) q (make-query target)] (case type - "label" (select driver q val) + "label" (e/select driver q val) "index" (let [index (inc (Integer/parseInt val))] ;; the initial index in selenium is 0, in xpath and css selectors it is 1 - (click-el driver (query driver q {:tag :option :index index}))) - - (click-el driver (query driver q (make-query value)))))) + (e/click-el driver (e/query driver q {:tag :option :index index}))) + (e/click-el driver (e/query driver q (make-query value)))))) (defmethod run-command :selectFrame [driver {:keys [target]} & [_opt]] (cond (= target "relative=top") - (switch-frame-top driver) + (e/switch-frame-top driver) (= target "relative=parent") - (switch-frame-parent driver) + (e/switch-frame-parent driver) (str/starts-with? target "index=") - (switch-frame* driver (-> target + (e/switch-frame* driver (-> target (str/split #"index=") second (Integer/parseInt))) - :else (switch-frame driver (make-query target)))) - + :else (e/switch-frame driver (make-query target)))) (defmethod run-command :selectWindow @@ -406,103 +367,90 @@ (str/split #"=") second str->var)] - (switch-window driver (get @vars handle-name))) + (e/switch-window driver (get @vars handle-name))) (str/starts-with? target "win_ser_") (let [index (second (str/split target #"win_ser_")) index (if (= index "local") 0 (Integer/parseInt index)) - handle (get (get-window-handles driver) index)] - (switch-window driver handle)) + handle (get (e/get-window-handles driver) index)] + (e/switch-window driver handle)) :else (throw (ex-info "The `select window` can only be called using handles" {:command command})))) - (defmethod run-command :sendKeys [driver {:keys [target value]} & [{vars :vars}]] - (fill driver (make-query target) (-> (gen-send-key-input value) + (e/fill driver (make-query target) (-> (gen-send-key-input value) (fill-str-with-vars @vars)))) - (defmethod run-command :setWindowSize [driver {:keys [target]} & [_opt]] (let [[width height] (map #(Integer/parseInt %) (str/split target #"x"))] - (set-window-size driver width height))) - + (e/set-window-size driver width height))) (defmethod run-command :store [_driver {:keys [target value]} & [{vars :vars}]] (swap! vars assoc (str->var value) target)) - (defmethod run-command :storeAttribute [driver {:keys [target value]} & [{vars :vars}]] (let [[locator attr-name] (str/split target "@" 2) - attr-val (get-element-attr driver (make-query locator) attr-name)] + attr-val (e/get-element-attr driver (make-query locator) attr-name)] (swap! vars assoc (str->var value) attr-val))) - (defmethod run-command :storeText [driver {:keys [target value]} & [{vars :vars}]] - (let [text (get-element-text driver (make-query target))] + (let [text (e/get-element-text driver (make-query target))] (swap! vars assoc (str->var value) text))) - (defmethod run-command :storeTitle [driver {:keys [value]} & [{vars :vars}]] - (let [title (get-title driver)] + (let [title (e/get-title driver)] (swap! vars assoc (str->var value) title))) - (defmethod run-command :storeValue [driver {:keys [target value]} & [{vars :vars}]] - (let [val (get-element-value driver (make-query target))] + (let [val (e/get-element-value driver (make-query target))] (swap! vars assoc (str->var value) val))) - (defmethod run-command :storeWindowHandle [driver {:keys [target]} & [{vars :vars}]] - (let [handle (get-window-handle driver)] + (let [handle (e/get-window-handle driver)] (swap! vars assoc (str->var target) handle))) - (defmethod run-command :storeXpathCount [driver {:keys [target value]} & [{vars :vars}]] - (let [cnt (count (find-elements* driver locator-xpath target))] + (let [cnt (count (e/find-elements* driver e/locator-xpath target))] (swap! vars assoc (str->var value) cnt))) - (defmethod run-command :submit [driver {:keys [target]} & [{_vars :vars}]] - (fill-el driver (query (make-query target) {:tag :input}) k/enter)) - + (e/fill-el driver (e/query (make-query target) {:tag :input}) k/enter)) (defmethod run-command :type [driver {:keys [target value]} & [{vars :vars}]] - (fill driver (make-query target) (-> (gen-send-key-input value) + (e/fill driver (make-query target) (-> (gen-send-key-input value) (fill-str-with-vars @vars)))) - (defmethod run-command :unCheck [driver {:keys [target]} & [{_base-url :base-url}]] (let [q (make-query target)] - (when (selected? driver q) - (click driver q)))) - + (when (e/selected? driver q) + (e/click driver q)))) (defmethod run-command :verify @@ -510,165 +458,143 @@ (let [stored-value (str (get @vars (str->var target)))] (is (= stored-value value) (make-assert-msg command stored-value value)))) - (defmethod run-command :verifyChecked [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (selected? driver (make-query target))] + (let [actual (e/selected? driver (make-query target))] (is (true? actual) (make-assert-msg command actual true)))) - (defmethod run-command :verifyNotChecked [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (selected? driver (make-query target))] + (let [actual (e/selected? driver (make-query target))] (is (false? actual) (make-assert-msg command actual false)))) - (defmethod run-command :verifyEditable [driver {:keys [target command]} & [{_vars :vars}]] (let [q (make-query target) - actual (and (enabled? driver (make-query target)) - (nil? (get-element-attr driver q :readonly)))] + actual (and (e/enabled? driver (make-query target)) + (nil? (e/get-element-attr driver q :readonly)))] (is (true? actual) (make-assert-msg command actual true)))) - (defmethod run-command :verifyNotEditable [driver {:keys [target command]} & [{_vars :vars}]] (let [q (make-query target) - actual (and (enabled? driver (make-query target)) - (nil? (get-element-attr driver q :readonly)))] + actual (and (e/enabled? driver (make-query target)) + (nil? (e/get-element-attr driver q :readonly)))] (is (false? actual) (make-assert-msg command actual false)))) - (defmethod run-command :verifyElementPresent [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (exists? driver (make-query target))] + (let [actual (e/exists? driver (make-query target))] (is (true? actual) (make-assert-msg command actual true)))) - (defmethod run-command :verifyElementNotPresent [driver {:keys [target command]} & [{_vars :vars}]] - (let [actual (absent? driver (make-query target))] + (let [actual (e/absent? driver (make-query target))] (is (true? actual) (make-assert-msg command actual true)))) - (defmethod run-command [:verifyValue :verifySelectedValue] [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-val (get-element-value driver (make-query target))] + (let [actual-val (e/get-element-value driver (make-query target))] (is (= actual-val value) (make-assert-msg command actual-val value)))) - (defmethod run-command :verifyNotSelectedValue [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-val (get-element-value driver (make-query target))] + (let [actual-val (e/get-element-value driver (make-query target))] (is (not= actual-val value) (make-assert-msg command actual-val value)))) - (defmethod run-command :verifyText [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-text (get-element-text driver (make-query target))] + (let [actual-text (e/get-element-text driver (make-query target))] (is (= actual-text value) (make-assert-msg command actual-text value)))) - (defmethod run-command :verifyNotText [driver {:keys [target value command]} & [{_vars :vars}]] - (let [actual-text (get-element-text driver (make-query target))] + (let [actual-text (e/get-element-text driver (make-query target))] (is (not= actual-text value) (make-assert-msg command actual-text value)))) - (defmethod run-command :verifySelectedLabel [driver {:keys [target value command]} & [{_vars :vars}]] (let [q (make-query target) - selected-val (get-element-value driver q) - option-el (query driver q {:value selected-val}) - option-text (get-element-text-el driver option-el)] + selected-val (e/get-element-value driver q) + option-el (e/query driver q {:value selected-val}) + option-text (e/get-element-text-el driver option-el)] (is (= option-text value) (make-assert-msg command option-text value)))) - (defmethod run-command :verifyTitle [driver {:keys [target command]} & [{_vars :vars}]] - (let [title (get-title driver)] + (let [title (e/get-title driver)] (is (= title target) (make-assert-msg command title target)))) - (defmethod run-command :waitForElementEditable [driver {:keys [target value]} & [{_vars :vars}]] - (wait-enabled driver (make-query target) - {:timeout (/ (Integer/parseInt value) 1000)})) - + (e/wait-enabled driver (make-query target) + {:timeout (/ (Integer/parseInt value) 1000)})) (defmethod run-command :waitForElementNotEditable [driver {:keys [target value]} & [{_vars :vars}]] - (wait-disabled driver (make-query target) - {:timeout (/ (Integer/parseInt value) 1000)})) - + (e/wait-disabled driver (make-query target) + {:timeout (/ (Integer/parseInt value) 1000)})) (defmethod run-command :waitForElementPresent [driver {:keys [target value]} & [{_vars :vars}]] - (wait-exists driver (make-query target) - {:timeout (/ (Integer/parseInt value) 1000)})) - + (e/wait-exists driver (make-query target) + {:timeout (/ (Integer/parseInt value) 1000)})) (defmethod run-command :waitForElementNotPresent [driver {:keys [target value]} & [{_vars :vars}]] - (wait-absent driver (make-query target) - {:timeout (/ (Integer/parseInt value) 1000)})) - + (e/wait-absent driver (make-query target) + {:timeout (/ (Integer/parseInt value) 1000)})) (defmethod run-command :waitForElementVisible [driver {:keys [target value]} & [{_vars :vars}]] - (wait-visible driver (make-query target) - {:timeout (/ (Integer/parseInt value) 1000)})) - + (e/wait-visible driver (make-query target) + {:timeout (/ (Integer/parseInt value) 1000)})) (defmethod run-command :waitForElementNotVisible [driver {:keys [target value]} & [{_vars :vars}]] - (wait-invisible driver (make-query target) - {:timeout (/ (Integer/parseInt value) 1000)})) - + (e/wait-invisible driver (make-query target) + {:timeout (/ (Integer/parseInt value) 1000)})) (defmethod run-command :waitForText [driver {:keys [target value]} & [{_vars :vars}]] (let [q (make-query target)] - (wait-visible driver q) - (wait-has-text driver q value))) - + (e/wait-visible driver q) + (e/wait-has-text driver q value))) (defmethods run-command [:webdriverChooseCancelOnVisibleConfirmation :webdriverChooseCancelOnVisiblePrompt] [driver {:keys [_target _value]} & [{_vars :vars}]] - (dismiss-alert driver)) - + (e/dismiss-alert driver)) (defmethods run-command [:webdriverChooseOkOnVisibleConfirmation] [driver {:keys [_target _value]} & [{_vars :vars}]] - (accept-alert driver)) - + (e/accept-alert driver)) ;; ;; Control flow @@ -677,26 +603,22 @@ (defmethods run-command [:if :elseIf :repeatIf :while] [driver {:keys [target]} & [{vars :vars}]] - (js-execute driver (gen-expession-script target @vars))) - + (e/js-execute driver (gen-expession-script target @vars))) (defmethods run-command [:do :end] [_ _ & _]) - (defmethod run-command :forEach [_driver {:keys [target value]} & [{vars :vars}]] [(str->var value) (get @vars (str->var target))]) - (defmethod run-command :times [_driver {:keys [target]} & [{_vars :vars}]] (Integer/parseInt target)) - (defn log-command-message [{:keys [command target value]}] (cond->> "" @@ -709,7 +631,6 @@ "always" (str (format "Command: %s" (name command))))) - (defn run-command-with-log " A middleware wrapper on top of `run-command`. diff --git a/src/etaoin/ide/main.clj b/src/etaoin/ide/main.clj index d03afca2..dd18af5b 100644 --- a/src/etaoin/ide/main.clj +++ b/src/etaoin/ide/main.clj @@ -11,10 +11,10 @@ (:require [clojure.java.io :as io] [clojure.string :as str] - [clojure.tools.cli :refer [parse-opts]] + [clojure.tools.cli :as cli] [etaoin.api :as api] [etaoin.ide.flow :as flow] - [etaoin.impl.util :refer [exit]])) + [etaoin.impl.util :as util])) (def browsers-set @@ -109,7 +109,7 @@ Options:") " [& args] (let [{:keys [errors summary options]} - (parse-opts args cli-options) + (cli/parse-opts args cli-options) {:keys [help file resource]} options] @@ -117,22 +117,22 @@ Options:") (cond errors - (exit 1 (error-msg errors)) + (util/exit 1 (error-msg errors)) help - (exit 0 (usage summary)) + (util/exit 0 (usage summary)) file (let [ide-file (io/file file)] (when-not (and (.exists ide-file) (not (.isDirectory ide-file))) - (exit 1 "The IDE file not found")) + (util/exit 1 "The IDE file not found")) (run-script ide-file options)) resource (if-let [r (io/resource resource)] (run-script r options) - (exit 1 "Resource not found")) + (util/exit 1 "Resource not found")) :else - (exit 1 "Specify the path to the ide file: `--file` or `--resource`")))) + (util/exit 1 "Specify the path to the ide file: `--file` or `--resource`")))) diff --git a/src/etaoin/impl/client.cljc b/src/etaoin/impl/client.cljc index bfd97a71..7d0c9679 100644 --- a/src/etaoin/impl/client.cljc +++ b/src/etaoin/impl/client.cljc @@ -1,10 +1,11 @@ (ns ^:no-doc etaoin.impl.client - (:require [clojure.string :as str] - [clojure.tools.logging :as log] - #?(:bb [clj-http.lite.client :as client] - :clj [clj-http.client :as client]) - [cheshire.core :as json] - [slingshot.slingshot :refer [throw+]])) + (:require + [cheshire.core :as json] + [clojure.string :as str] + [clojure.tools.logging :as log] + #?(:bb [clj-http.lite.client :as client] + :clj [clj-http.client :as client]) + [slingshot.slingshot :refer [throw+]])) ;; ;; defaults diff --git a/src/etaoin/impl/driver.clj b/src/etaoin/impl/driver.clj index 299f31f6..9bfd81c3 100644 --- a/src/etaoin/impl/driver.clj +++ b/src/etaoin/impl/driver.clj @@ -33,10 +33,11 @@ Selenium Python source code for Firefox https://github.com/SeleniumHQ/selenium/blob/master/py/selenium/webdriver/firefox/options.py " - (:require [etaoin.impl.util :refer [defmethods deep-merge]] - [babashka.fs :as fs] - [clojure.string :as string] - [clojure.tools.logging :as log])) + (:require + [babashka.fs :as fs] + [clojure.string :as string] + [clojure.tools.logging :as log] + [etaoin.impl.util :refer [deep-merge defmethods]])) (defn dispatch-driver [driver & _] diff --git a/src/etaoin/impl/proc.clj b/src/etaoin/impl/proc.clj index 5bb53047..b84a934a 100644 --- a/src/etaoin/impl/proc.clj +++ b/src/etaoin/impl/proc.clj @@ -1,6 +1,7 @@ (ns ^:no-doc etaoin.impl.proc - (:require [clojure.java.io :as io] - [clojure.string :as str])) + (:require + [clojure.java.io :as io] + [clojure.string :as str])) (def windows? (str/starts-with? (System/getProperty "os.name") "Windows")) diff --git a/src/etaoin/query.clj b/src/etaoin/query.clj index 1ceb98d1..59016088 100644 --- a/src/etaoin/query.clj +++ b/src/etaoin/query.clj @@ -1,7 +1,8 @@ (ns etaoin.query "A module to deal with querying elements." - (:require [etaoin.impl.util :as util] - [etaoin.impl.xpath :as xpath])) + (:require + [etaoin.impl.util :as util] + [etaoin.impl.xpath :as xpath])) ;; todo duplicates with api.clj (def locator-xpath "xpath") diff --git a/test/etaoin/api_test.clj b/test/etaoin/api_test.clj index f0ec6878..9bb63ee2 100644 --- a/test/etaoin/api_test.clj +++ b/test/etaoin/api_test.clj @@ -1,14 +1,15 @@ (ns etaoin.api-test - (:require [babashka.fs :as fs] - [clojure.edn :as edn] - [clojure.java.io :as io] - [clojure.java.shell :as shell] - [clojure.string :as str] - [clojure.test :refer :all] - [etaoin.api :refer :all] - [etaoin.test-report :as test-report] - [etaoin.impl.util :refer [with-tmp-file]] - [slingshot.slingshot :refer [try+]])) + (:require + [babashka.fs :as fs] + [clojure.edn :as edn] + [clojure.java.io :as io] + [clojure.java.shell :as shell] + [clojure.string :as str] + [clojure.test :refer [deftest is testing use-fixtures]] + [etaoin.api :as e] + [etaoin.impl.util :as util] + [etaoin.test-report :as test-report] + [slingshot.slingshot :refer [try+]])) (defn numeric? [val] (or (instance? Double val) @@ -52,9 +53,9 @@ (defn fixture-browsers [f] (let [url (-> "html/test.html" io/resource str)] (doseq [type drivers] - (with-driver type (get default-opts type {}) driver - (go driver url) - (wait-visible driver {:id :document-end}) + (e/with-driver type (get default-opts type {}) driver + (e/go driver url) + (e/wait-visible driver {:id :document-end}) (binding [*driver* driver test-report/*context* (name type)] (testing (name type) @@ -74,124 +75,124 @@ (deftest test-visible (doto *driver* - (-> (visible? {:id :button-visible}) is) - (-> (visible? {:id :option-visible}) is) - (-> (invisible? {:id :button-hidden}) is) - (-> (invisible? {:id :div-hidden}) is) - (-> (invisible? {:id :option-hidden}) is) - (-> (invisible? {:id :dunno-foo-bar}) is))) + (-> (e/visible? {:id :button-visible}) is) + (-> (e/visible? {:id :option-visible}) is) + (-> (e/invisible? {:id :button-hidden}) is) + (-> (e/invisible? {:id :div-hidden}) is) + (-> (e/invisible? {:id :option-hidden}) is) + (-> (e/invisible? {:id :dunno-foo-bar}) is))) (deftest test-select (testing "test `select` on select-box" - (let [default-val (get-element-value *driver* :simple-country) - _ (select *driver* :simple-country "France") - selected-val (get-element-value *driver* :simple-country)] + (let [default-val (e/get-element-value *driver* :simple-country) + _ (e/select *driver* :simple-country "France") + selected-val (e/get-element-value *driver* :simple-country)] (is (= "rf" default-val)) (is (= "fr" selected-val))))) (deftest test-multiple-click - (click-multi *driver* [:vehicle1 :vehicle2 :vehicle3] 0.3) - (is (selected? *driver* :vehicle1)) - (is (selected? *driver* :vehicle2)) - (is (selected? *driver* :vehicle3))) + (e/click-multi *driver* [:vehicle1 :vehicle2 :vehicle3] 0.3) + (is (e/selected? *driver* :vehicle1)) + (is (e/selected? *driver* :vehicle2)) + (is (e/selected? *driver* :vehicle3))) (deftest test-input (testing "fill multiple inputs" (doto *driver* - (fill-multi {:simple-input 1 - :simple-password 2 - :simple-textarea 3}) - (click :simple-submit) - (when-safari (wait 3)) - (-> get-url + (e/fill-multi {:simple-input 1 + :simple-password 2 + :simple-textarea 3}) + (e/click :simple-submit) + (e/when-safari (e/wait 3)) + (-> e/get-url (str/ends-with? "?login=1&password=2&message=3") is))) (testing "fill human multiple imputs" (doto *driver* - (fill-human-multi {:simple-input "login" + (e/fill-human-multi {:simple-input "login" :simple-password "123" :simple-textarea "text"}) - (click :simple-submit) - (when-safari (wait 3)) - (-> get-url + (e/click :simple-submit) + (e/when-safari (e/wait 3)) + (-> e/get-url (str/ends-with? "?login=login&password=123&message=text") is))) (testing "fill multiple vars" (doto *driver* - (fill :simple-input 1 "test" 2 \space \A) - (click :simple-submit) - (when-safari (wait 3)) - (-> get-url + (e/fill :simple-input 1 "test" 2 \space \A) + (e/click :simple-submit) + (e/when-safari (e/wait 3)) + (-> e/get-url (str/ends-with? "?login=1test2+A&password=&message=") is)))) (deftest test-clear (testing "simple clear" (doto *driver* - (fill {:id :simple-input} "test") - (clear {:id :simple-input}) - (click {:id :simple-submit}) - (when-safari (wait 3)) - (-> get-url + (e/fill {:id :simple-input} "test") + (e/clear {:id :simple-input}) + (e/click {:id :simple-submit}) + (e/when-safari (e/wait 3)) + (-> e/get-url (str/ends-with? "?login=&password=&message=") is))) (testing "multiple clear" (doto *driver* - (fill-multi {:simple-input 1 - :simple-password 2 - :simple-textarea 3}) - (clear :simple-input - :simple-password - :simple-textarea) - (when-safari (wait 3)) - (-> get-url + (e/fill-multi {:simple-input 1 + :simple-password 2 + :simple-textarea 3}) + (e/clear :simple-input + :simple-password + :simple-textarea) + (e/when-safari (e/wait 3)) + (-> e/get-url (str/ends-with? "?login=&password=&message=") is)))) (deftest test-enabled (doto *driver* - (-> (disabled? {:id :input-disabled}) is) - (-> (enabled? {:id :input-not-disabled}) is) - (-> (disabled? {:id :textarea-disabled}) is)) + (-> (e/disabled? {:id :input-disabled}) is) + (-> (e/enabled? {:id :input-not-disabled}) is) + (-> (e/disabled? {:id :textarea-disabled}) is)) (is (thrown? clojure.lang.ExceptionInfo - (enabled? *driver* {:id :dunno-foo-bar})))) + (e/enabled? *driver* {:id :dunno-foo-bar})))) (deftest test-exists (doto *driver* - (-> (exists? {:tag :html}) is) - (-> (exists? {:tag :body}) is) - (-> (absent? {:id :dunno-foo-bar}) is))) + (-> (e/exists? {:tag :html}) is) + (-> (e/exists? {:tag :body}) is) + (-> (e/absent? {:id :dunno-foo-bar}) is))) ;; In Safari, alerts work quite slow, so we add some delays. (deftest test-alert - (when-not-phantom *driver* + (e/when-not-phantom *driver* (doto *driver* - (click {:id :button-alert}) - (when-safari (wait 1)) - (-> get-alert-text (= "Hello!") is) - (-> has-alert? is) - (accept-alert) - (when-safari (wait 1)) - (-> has-alert? not is) - (click {:id :button-alert}) - (when-safari (wait 1)) - (-> has-alert? is) - (dismiss-alert) - (when-safari (wait 1)) - (-> has-alert? not is)))) + (e/click {:id :button-alert}) + (e/when-safari (e/wait 1)) + (-> e/get-alert-text (= "Hello!") is) + (-> e/has-alert? is) + (e/accept-alert) + (e/when-safari (e/wait 1)) + (-> e/has-alert? not is) + (e/click {:id :button-alert}) + (e/when-safari (e/wait 1)) + (-> e/has-alert? is) + (e/dismiss-alert) + (e/when-safari (e/wait 1)) + (-> e/has-alert? not is)))) (deftest test-properties - (when-firefox *driver* - (let [result (get-element-properties + (e/when-firefox *driver* + (let [result (e/get-element-properties *driver* :input-property :value)] (is (= ["val"] result))))) (deftest test-element-value - (let [result (get-element-value + (let [result (e/get-element-value *driver* :input-property)] (is (= "val" result)))) @@ -199,7 +200,7 @@ (deftest test-attributes (testing "common attributes" (doto *driver* - (-> (get-element-attrs + (-> (e/get-element-attrs {:id :input-attr} :id :type :value :name :style "disabled" "data-foo" "data-bar") @@ -213,13 +214,13 @@ "bar"]) is))) (testing "event attributes" - (let [val (get-element-attr *driver* - {:id :input-attr} - :onclick)] + (let [val (e/get-element-attr *driver* + {:id :input-attr} + :onclick)] (is (= val "alert(123)")))) (testing "missing attributes" (doto *driver* - (-> (get-element-attrs + (-> (e/get-element-attrs {:id :input-attr} :foo "bar" :baz "dunno") (= [nil nil nil nil]) @@ -227,27 +228,27 @@ (deftest test-get-inner-html (let [inner-html "
Inner HTML
" - result (get-element-inner-html *driver* :element-props)] + result (e/get-element-inner-html *driver* :element-props)] (is (= inner-html result)))) (deftest test-title (doto *driver* - (-> get-title (= "Webdriver Test Document") is))) + (-> e/get-title (= "Webdriver Test Document") is))) (deftest test-url (doto *driver* - (-> get-url + (-> e/get-url (str/ends-with? "/resources/html/test.html") is))) (deftest test-css-props (testing "single css" (doto *driver* - (-> (get-element-css {:id :div-css-simple} :display) + (-> (e/get-element-css {:id :div-css-simple} :display) (= "block") is))) (testing "multiple css" - (let [result (get-element-csss + (let [result (e/get-element-csss *driver* {:id :div-css-simple} :display :background-color "width" "height") @@ -258,7 +259,7 @@ (is (= width "150px")) (is (= height "250px")))) (testing "styled css" - (let [result (get-element-csss + (let [result (e/get-element-csss *driver* {:id :div-css-styled} :display :width :height) @@ -267,7 +268,7 @@ (is (= width "333px")) (is (= height "111px")))) (testing "missing css" - (let [result (get-element-csss + (let [result (e/get-element-csss *driver* {:id :div-css-styled} :foo :bar "baz")] @@ -276,21 +277,21 @@ (deftest test-wait-has-text (testing "wait for text simple" (doto *driver* - (refresh) - (wait-visible {:id :document-end}) - (click {:id :wait-button}) - (wait-has-text :wait-span "-secret-")) + (e/refresh) + (e/wait-visible {:id :document-end}) + (e/click {:id :wait-button}) + (e/wait-has-text :wait-span "-secret-")) (is true "text found")) (testing "wait for text timeout" (doto *driver* - (refresh) - (wait-visible {:id :document-end}) - (click {:id :wait-button})) + (e/refresh) + (e/wait-visible {:id :document-end}) + (e/click {:id :wait-button})) (try+ - (wait-has-text *driver* - :wait-span - "-secret-" - {:timeout 1}) + (e/wait-has-text *driver* + :wait-span + "-secret-" + {:timeout 1}) (is false "should not be executed") (catch [:type :etaoin/timeout] data (is (= (-> data (dissoc :predicate :time-rest)) @@ -301,13 +302,13 @@ :times 4}))))) (testing "wait for non-existing text" (doto *driver* - (refresh) - (wait-visible {:id :document-end})) + (e/refresh) + (e/wait-visible {:id :document-end})) (try+ - (wait-has-text *driver* - :wait-span - "-dunno-whatever-foo-bar-" - {:timeout 2}) + (e/wait-has-text *driver* + :wait-span + "-dunno-whatever-foo-bar-" + {:timeout 2}) (is false "should not be executed") (catch [:type :etaoin/timeout] data (is (= (-> data (dissoc :predicate :time-rest)) @@ -320,20 +321,20 @@ (deftest test-wait-has-text-everywhere (testing "wait for text simple" (doto *driver* - (refresh) - (wait-visible {:id :document-end}) - (click {:id :wait-button}) - (wait-has-text-everywhere "-secret-")) + (e/refresh) + (e/wait-visible {:id :document-end}) + (e/click {:id :wait-button}) + (e/wait-has-text-everywhere "-secret-")) (is true "text found")) (testing "wait for text timeout" (doto *driver* - (refresh) - (wait-visible {:id :document-end}) - (click {:id :wait-button})) + (e/refresh) + (e/wait-visible {:id :document-end}) + (e/click {:id :wait-button})) (try+ - (wait-has-text-everywhere *driver* - "-secret-" - {:timeout 1}) + (e/wait-has-text-everywhere *driver* + "-secret-" + {:timeout 1}) (is false "should not be executed") (catch [:type :etaoin/timeout] data (is (= (-> data (dissoc :predicate :time-rest)) @@ -344,12 +345,12 @@ :times 4}))))) (testing "wait for non-existing text" (doto *driver* - (refresh) - (wait-visible {:id :document-end})) + (e/refresh) + (e/wait-visible {:id :document-end})) (try+ - (wait-has-text-everywhere *driver* - "-dunno-whatever-foo-bar-" - {:timeout 2}) + (e/wait-has-text-everywhere *driver* + "-dunno-whatever-foo-bar-" + {:timeout 2}) (is false "should not be executed") (catch [:type :etaoin/timeout] data (is (= (-> data (dissoc :predicate :time-rest)) @@ -363,9 +364,9 @@ (is 1) (testing "wait for an element has class" (doto *driver* - (scroll-query :wait-add-class-trigger) - (click :wait-add-class-trigger) - (wait-has-class :wait-add-class-target + (e/scroll-query :wait-add-class-trigger) + (e/click :wait-add-class-trigger) + (e/wait-has-class :wait-add-class-target :new-one {:timeout 20 :interval 1 @@ -374,7 +375,7 @@ (deftest test-close-window (is 1) (doto *driver* - (close-window))) + (e/close-window))) (deftest test-drag-n-drop (is 1) @@ -382,16 +383,16 @@ doc {:class :document} trash {:xpath "//div[contains(@class, 'trash')]"}] (doto *driver* - (go url) - (drag-and-drop doc trash) - (drag-and-drop doc trash) - (drag-and-drop doc trash) - (drag-and-drop doc trash) - (-> (absent? doc) is)))) + (e/go url) + (e/drag-and-drop doc trash) + (e/drag-and-drop doc trash) + (e/drag-and-drop doc trash) + (e/drag-and-drop doc trash) + (-> (e/absent? doc) is)))) (deftest test-element-location (let [q {:id :el-location-input} - loc (get-element-location *driver* q) + loc (e/get-element-location *driver* q) {:keys [x y]} loc] (is (numeric? x)) (is (numeric? y)))) @@ -402,48 +403,48 @@ ;; monitor, the next two test will fail due to window error. (deftest test-window-position - (when-not-drivers + (e/when-not-drivers [:phantom :edge] *driver* - (let [{:keys [x y]} (get-window-position *driver*)] + (let [{:keys [x y]} (e/get-window-position *driver*)] (is (numeric? x)) (is (numeric? y)) - (set-window-position *driver* (+ x 10) (+ y 10)) - (let [{x' :x y' :y} (get-window-position *driver*)] + (e/set-window-position *driver* (+ x 10) (+ y 10)) + (let [{x' :x y' :y} (e/get-window-position *driver*)] (is (not= x x')) (is (not= y y')))))) (deftest test-window-size (testing "getting size" - (let [{:keys [width height]} (get-window-size *driver*)] + (let [{:keys [width height]} (e/get-window-size *driver*)] (is (numeric? width)) (is (numeric? height)) - (set-window-size *driver* (- width 10) (- height 10)) - (let [{width' :width height' :height} (get-window-size *driver*)] + (e/set-window-size *driver* (- width 10) (- height 10)) + (let [{width' :width height' :height} (e/get-window-size *driver*)] (is (not= width width')) (is (not= height height')))))) (deftest test-switch-window - (let [init-handle (get-window-handle *driver*) - init-url (get-url *driver*) - _ (click *driver* :switch-window) - new-handles (get-window-handles *driver*) + (let [init-handle (e/get-window-handle *driver*) + init-url (e/get-url *driver*) + _ (e/click *driver* :switch-window) + new-handles (e/get-window-handles *driver*) new-handle (first (filter #(not= % init-handle) new-handles)) - _ (switch-window *driver* new-handle) - target-handle (get-window-handle *driver*) - target-url (get-url *driver*)] + _ (e/switch-window *driver* new-handle) + target-handle (e/get-window-handle *driver*) + target-url (e/get-url *driver*)] (is (not= init-handle target-handle)) (is (= 2 (count new-handles))) (is (= new-handle target-handle)) (is (not= init-url target-url)))) (deftest test-switch-window-next - (let [_ (repeat 3 #(click *driver* :switch-window)) - init-handle (get-window-handle *driver*) - _ (repeat 4 #(switch-window-next *driver*)) - target-handle (get-window-handle *driver*)] + (let [_ (repeat 3 #(e/click *driver* :switch-window)) + init-handle (e/get-window-handle *driver*) + _ (repeat 4 #(e/switch-window-next *driver*)) + target-handle (e/get-window-handle *driver*)] (is (= init-handle target-handle)))) -;; need refactoring not working for headless & firefox +;; TODO: need refactoring not working for headless & firefox #_ (deftest test-maximize (when-not-headless *driver* @@ -459,29 +460,29 @@ (deftest test-active-element (testing "active element" - (when-not-safari *driver* + (e/when-not-safari *driver* (doto *driver* - (click {:id :set-active-el}) - (-> (get-element-attr :active :id) + (e/click {:id :set-active-el}) + (-> (e/get-element-attr :active :id) (= "active-el-input") is))) - (when-safari *driver* + (e/when-safari *driver* (is 1)))) (deftest test-element-text - (let [text (get-element-text *driver* {:id :element-text})] + (let [text (e/get-element-text *driver* {:id :element-text})] (is (= text "Element text goes here.")))) (deftest test-element-size - (let [{:keys [width height]} (get-element-size *driver* {:id :element-text})] + (let [{:keys [width height]} (e/get-element-size *driver* {:id :element-text})] (is (numeric? width)) (is (numeric? height)))) (deftest test-cookies (testing "getting all cookies" - (let [cookies (get-cookies *driver*) + (let [cookies (e/get-cookies *driver*) cookies-ff (map #(dissoc % :sameSite) cookies)] - (when-safari *driver* + (e/when-safari *driver* (is (= cookies [{:domain ".^filecookies^" :secure false @@ -495,9 +496,9 @@ :value "test2" :path "/" :name "cookie2"}]))) - (when-chrome *driver* + (e/when-chrome *driver* (is (= cookies []))) - (when-firefox *driver* + (e/when-firefox *driver* (is (= cookies-ff [{:name "cookie1", :value "test1", :path "/", @@ -510,7 +511,7 @@ :domain "", :secure false, :httpOnly false}]))) - (when-phantom *driver* + (e/when-phantom *driver* (is (= cookies [{:domain "", :httponly false, :name "cookie2", @@ -524,9 +525,9 @@ :secure false, :value "test1"}]))))) (testing "getting a cookie" - (let [cookie (get-cookie *driver* :cookie2) + (let [cookie (e/get-cookie *driver* :cookie2) cookie-ff (dissoc cookie :sameSite)] - (when-safari *driver* + (e/when-safari *driver* (is (= cookie {:domain ".^filecookies^" :secure false @@ -534,9 +535,9 @@ :value "test2" :path "/" :name "cookie2"}))) - (when-chrome *driver* + (e/when-chrome *driver* (is (nil? cookie))) - (when-firefox *driver* + (e/when-firefox *driver* (is (= cookie-ff {:name "cookie2" :value "test2" @@ -544,7 +545,7 @@ :domain "" :secure false :httpOnly false}))) - (when-phantom *driver* + (e/when-phantom *driver* (is (= cookie {:domain "" :httponly false @@ -553,21 +554,21 @@ :secure false :value "test2"}))))) (testing "deleting a cookie" - (when-not-phantom + (e/when-not-phantom *driver* - (delete-cookie *driver* :cookie3) - (let [cookie (get-cookie *driver* :cookie3)] + (e/delete-cookie *driver* :cookie3) + (let [cookie (e/get-cookie *driver* :cookie3)] (is (nil? cookie))))) (testing "deleting all cookies" (doto *driver* - delete-cookies - (-> get-cookies + e/delete-cookies + (-> e/get-cookies (= []) is)))) (deftest test-page-source - (let [src (get-source *driver*)] - (if (phantom? *driver*) + (let [src (e/get-source *driver*)] + (if (e/phantom? *driver*) (is (str/starts-with? src "")) (is (str/starts-with? src ""))))) @@ -585,32 +586,32 @@ (throw (ex-info "please install image magick, we use it for screenshot image verification" {})))) (deftest test-screenshot - (with-tmp-file "screenshot" ".png" path - (screenshot *driver* path) + (util/with-tmp-file "screenshot" ".png" path + (e/screenshot *driver* path) (is (valid-image? path)))) (deftest test-with-screenshots (fs/with-temp-dir [dir {:prefix "screenshots"}] - (with-screenshots *driver* dir - (fill *driver* :simple-input "1") - (fill *driver* :simple-input "1") - (fill *driver* :simple-input "1")) + (e/with-screenshots *driver* dir + (e/fill *driver* :simple-input "1") + (e/fill *driver* :simple-input "1") + (e/fill *driver* :simple-input "1")) (is (= 3 (count (fs/list-dir dir)))))) (deftest test-screenshot-element - (when (or (chrome? *driver*) - (firefox? *driver*)) - (with-tmp-file "screenshot" ".png" path - (screenshot-element *driver* {:id :css-test} path) + (when (or (e/chrome? *driver*) + (e/firefox? *driver*)) + (util/with-tmp-file "screenshot" ".png" path + (e/screenshot-element *driver* {:id :css-test} path) (is (valid-image? path))))) (deftest test-js-execute (testing "simple result" - (let [result (js-execute *driver* "return 42;")] + (let [result (e/js-execute *driver* "return 42;")] (is (= result 42)))) (testing "with args" (let [script "return {foo: arguments[0], bar: arguments[1]};" - result (js-execute *driver* script {:test 42} [true, nil, "Hello"])] + result (e/js-execute *driver* script {:test 42} [true, nil, "Hello"])] (is (= result {:foo {:test 42} :bar [true nil "Hello"]}))))) @@ -621,85 +622,85 @@ ;; otherwise slash after file: is ommitted and therefore invalid str)] (testing "adding a script" - (add-script *driver* js-url) - (wait 1) - (let [result (js-execute *driver* "return injected_func();")] + (e/add-script *driver* js-url) + (e/wait 1) + (let [result (e/js-execute *driver* "return injected_func();")] (is (= result "I was injected")))))) (deftest test-set-hash (testing "set hash" (doto *driver* - (set-hash "hello") - (-> get-hash (= "hello") is) - (-> get-url (str/ends-with? "/test.html#hello") is) - (set-hash "goodbye") - (-> get-url (str/ends-with? "/test.html#goodbye") is)))) + (e/set-hash "hello") + (-> e/get-hash (= "hello") is) + (-> e/get-url (str/ends-with? "/test.html#hello") is) + (e/set-hash "goodbye") + (-> e/get-url (str/ends-with? "/test.html#goodbye") is)))) (deftest test-find-element - (let [text (get-element-text *driver* {:class :target})] + (let [text (e/get-element-text *driver* {:class :target})] (is (= text "target-1"))) - (let [text (get-element-text *driver* [{:class :foo} + (let [text (e/get-element-text *driver* [{:class :foo} {:class :target}])] (is (= text "target-2"))) - (with-xpath *driver* - (let [text (get-element-text *driver* ".//div[@class='target'][1]")] + (e/with-xpath *driver* + (let [text (e/get-element-text *driver* ".//div[@class='target'][1]")] (is (= text "target-1")))) - (let [text (get-element-text *driver* {:css ".target"})] + (let [text (e/get-element-text *driver* {:css ".target"})] (is (= text "target-1"))) (let [q [{:css ".bar"} ".//div[@class='inside']" {:tag :span}] - text (get-element-text *driver* q)] + text (e/get-element-text *driver* q)] (is (= text "target-3")))) (deftest test-find-elements-more (testing "simple case" (let [q {:class :find-elements-target} - elements (query-all *driver* q)] + elements (e/query-all *driver* q)] (is (= (count elements) 4)))) (testing "nested case" (let [q [{:id :find-elements-nested} {:class :nested} {:class :target}] - elements (query-all *driver* q) + elements (e/query-all *driver* q) texts (for [el elements] - (get-element-text-el *driver* el))] + (e/get-element-text-el *driver* el))] (is (= (count elements) 2)) (is (= texts ["1" "2"]))))) (deftest test-multiple-elements (testing "tag names" (let [q {:xpath ".//div[@id='operate-multiple-elements']//*"} - elements (query-all *driver* q) + elements (e/query-all *driver* q) tag-names (for [el elements] - (str/lower-case (get-element-tag-el *driver* el)))] + (str/lower-case (e/get-element-tag-el *driver* el)))] (is (= (vec tag-names) ["div" "b" "p" "span"]))))) (deftest test-query-tree (let [url (-> "html/test2.html" io/resource str) - _ (go *driver* url) - all-div (query-tree *driver* {:tag :div}) - all-li (query-tree *driver* {:tag :li}) - li-three-level (query-tree + _ (e/go *driver* url) + all-div (e/query-tree *driver* {:tag :div}) + all-li (e/query-tree *driver* {:tag :li}) + li-three-level (e/query-tree *driver* {:tag :div} {:tag :div} {:tag :div} {:tag :li}) - tag-a (query-tree *driver* {:tag :div} {:tag :div} {:tag :a})] + tag-a (e/query-tree *driver* {:tag :div} {:tag :div} {:tag :a})] (is (= 6 (count all-div))) (is (= 8 (count all-li))) (is (= 5 (count li-three-level))) (is (= 1 (count tag-a))))) (deftest test-child - (let [parent-el (query *driver* {:css "#wc3-barks"}) - child-el (child *driver* parent-el {:css ".crypt-lord"}) - tag-name (str/lower-case (get-element-tag-el *driver* child-el)) - tag-text (get-element-text-el *driver* child-el)] + (let [parent-el (e/query *driver* {:css "#wc3-barks"}) + child-el (e/child *driver* parent-el {:css ".crypt-lord"}) + tag-name (str/lower-case (e/get-element-tag-el *driver* child-el)) + tag-text (e/get-element-text-el *driver* child-el)] (is (= "span" tag-name)) (is (str/includes? tag-text "From the depths I've come!")))) (deftest test-children - (let [parent-el (query *driver* {:css "#wc3-barks"}) - children-els (children *driver* parent-el {:css "p"}) - children-texts (map #(get-element-text-el *driver* %) children-els)] - (is (= ["p" "p"] (map #(str/lower-case (get-element-tag-el *driver* %)) children-els))) + (let [parent-el (e/query *driver* {:css "#wc3-barks"}) + children-els (e/children *driver* parent-el {:css "p"}) + children-texts (map #(e/get-element-text-el *driver* %) children-els)] + (is (= ["p" "p"] (map #(str/lower-case (e/get-element-tag-el *driver* %)) children-els))) (is (str/includes? (first children-texts) "From the depths I've come!")) (is (str/includes? (last children-texts) "I've come from the darkness of the pit!")))) @@ -711,58 +712,58 @@ (io/make-parents (format "%s/%s" dir-tmp "_")) (testing "postmortem" (try - (with-postmortem *driver* {:dir dir-tmp} - (click *driver* :non-existing-element)) + (e/with-postmortem *driver* {:dir dir-tmp} + (e/click *driver* :non-existing-element)) (is false "should be caught") (catch Exception _e (is true "caught") (let [files (file-seq (fs/file dir-tmp)) - expected-file-count (if (supports-logs? *driver*) 3 2)] + expected-file-count (if (e/supports-logs? *driver*) 3 2)] (is (= (-> files rest count) expected-file-count)))))))) (deftest test-find-quotes-in-text (doto *driver* - (-> (has-text? "'quote") is))) + (-> (e/has-text? "'quote") is))) (deftest test-has-text (testing "test :fn/has-string" - (is (boolean (query *driver* {:fn/has-string "From the depth"})))) + (is (boolean (e/query *driver* {:fn/has-string "From the depth"})))) (testing "gloval" - (is (has-text? *driver* "From the depths I've come!")) - (is (has-text? *driver* "I've come from the dark"))) + (is (e/has-text? *driver* "From the depths I've come!")) + (is (e/has-text? *driver* "I've come from the dark"))) (testing "relative" - (is (has-text? *driver* [:wc3-barks {:tag :p} {:tag :span}] "ths I've come!"))) + (is (e/has-text? *driver* [:wc3-barks {:tag :p} {:tag :span}] "ths I've come!"))) (testing "short path" - (is (has-text? *driver* [:wc3-barks {:tag :span}] "ths I've"))) + (is (e/has-text? *driver* [:wc3-barks {:tag :span}] "ths I've"))) (testing "wrong path" - (is (not (has-text? *driver* [:wc3-barks {:tag :p} :pit-lord] "ths I've come!"))))) + (is (not (e/has-text? *driver* [:wc3-barks {:tag :p} :pit-lord] "ths I've come!"))))) ;; actions (deftest test-actions (testing "input key and mouse click" - (when-not-phantom *driver* - (let [input (query *driver* :simple-input) - password (query *driver* :simple-password) - textarea (query *driver* :simple-textarea) - submit (query *driver* :simple-submit) - keyboard (-> (make-key-input) - add-double-pause - (with-key-down "\uE01B") - add-double-pause - (with-key-down "\uE01C") - add-double-pause - (with-key-down "\uE01D")) - mouse (-> (make-mouse-input) - (add-pointer-click-el input) - add-pause - (add-pointer-click-el password) - add-pause - (add-pointer-click-el textarea) - add-pause - (add-pointer-click-el submit))] - (perform-actions *driver* keyboard mouse) - (wait 1) - (is (str/ends-with? (get-url *driver*) "?login=1&password=2&message=3")))))) + (e/when-not-phantom *driver* + (let [input (e/query *driver* :simple-input) + password (e/query *driver* :simple-password) + textarea (e/query *driver* :simple-textarea) + submit (e/query *driver* :simple-submit) + keyboard (-> (e/make-key-input) + e/add-double-pause + (e/with-key-down "\uE01B") + e/add-double-pause + (e/with-key-down "\uE01C") + e/add-double-pause + (e/with-key-down "\uE01D")) + mouse (-> (e/make-mouse-input) + (e/add-pointer-click-el input) + e/add-pause + (e/add-pointer-click-el password) + e/add-pause + (e/add-pointer-click-el textarea) + e/add-pause + (e/add-pointer-click-el submit))] + (e/perform-actions *driver* keyboard mouse) + (e/wait 1) + (is (str/ends-with? (e/get-url *driver*) "?login=1&password=2&message=3")))))) diff --git a/test/etaoin/ide_test.clj b/test/etaoin/ide_test.clj index 84fa7041..5f2747d9 100644 --- a/test/etaoin/ide_test.clj +++ b/test/etaoin/ide_test.clj @@ -1,10 +1,11 @@ (ns etaoin.ide-test - (:require [clojure.edn :as edn] - [etaoin.api :as api] - [etaoin.ide.flow :as ide] - [etaoin.test-report :as test-report] - [clojure.test :refer :all] - [clojure.java.io :as io])) + (:require + [clojure.edn :as edn] + [clojure.java.io :as io] + [clojure.test :refer [deftest is testing use-fixtures]] + [etaoin.api :as e] + [etaoin.ide.flow :as ide] + [etaoin.test-report :as test-report])) (def ^:dynamic *driver*) (def ^:dynamic *base-url*) @@ -25,8 +26,8 @@ (let [base-url (-> "html" io/resource str) test-file-path (-> "ide/test.side" io/resource str)] (doseq [type drivers] - (api/with-driver type {:args ["--no-sandbox"]} driver - (api/go driver base-url) + (e/with-driver type {:args ["--no-sandbox"]} driver + (e/go driver base-url) (binding [*driver* driver *base-url* base-url *test-file-path* test-file-path diff --git a/test/etaoin/unit/proc_test.clj b/test/etaoin/unit/proc_test.clj index 71132e0f..9608937f 100644 --- a/test/etaoin/unit/proc_test.clj +++ b/test/etaoin/unit/proc_test.clj @@ -1,30 +1,32 @@ (ns etaoin.unit.proc-test - (:require [etaoin.api :refer :all] - [clojure.java.shell :refer [sh]] - [clojure.test :refer :all] - [etaoin.impl.proc :as proc] - [etaoin.test-report] - [clojure.pprint :as pprint] - [clojure.string :as str])) + (:require + [clojure.java.shell :as shell] + [clojure.pprint :as pprint] + [clojure.string :as str] + [clojure.test :refer [deftest is]] + [etaoin.api :as e] + [etaoin.api2 :as e2] + [etaoin.impl.proc :as proc] + [etaoin.test-report])) (defn get-count-chromedriver-instances [] (if proc/windows? - (let [instance-report (-> (sh "powershell" "-command" "(Get-Process chromedriver -ErrorAction SilentlyContinue).Path") + (let [instance-report (-> (shell/sh "powershell" "-command" "(Get-Process chromedriver -ErrorAction SilentlyContinue).Path") :out str/split-lines)] ;; more flakiness diagnosis (println "windows chromedriver instance report:" instance-report) (println "windows full list of running processes:") ;; use Get-CimInstance, because Get-Process, does not have commandline available - (pprint/pprint (-> (sh "powershell" "-command" "Get-CimInstance Win32_Process | select name, commandline") + (pprint/pprint (-> (shell/sh "powershell" "-command" "Get-CimInstance Win32_Process | select name, commandline") :out str/split-lines)) (->> instance-report (remove #(str/includes? % "\\scoop\\shims\\")) ;; for the scoop users, exclude the shim process (filter #(str/includes? % "chromedriver")) count)) - (->> (sh "sh" "-c" "ps aux") + (->> (shell/sh "sh" "-c" "ps aux") :out str/split-lines (filter #(str/includes? % "chromedriver")) @@ -33,32 +35,32 @@ (deftest test-process-forking-port-specified (let [port 9997 process (proc/run ["chromedriver" (format "--port=%d" port)]) - _ (wait-running {:port port :host "localhost"})] + _ (e/wait-running {:port port :host "localhost"})] (is (= 1 (get-count-chromedriver-instances))) (is (thrown-with-msg? clojure.lang.ExceptionInfo #"already in use" - (chrome {:port port}))) + (e/chrome {:port port}))) (proc/kill process))) (deftest test-process-forking-port-random (let [port 9998 process (proc/run ["chromedriver" (format "--port=%d" port)]) - _ (wait-running {:port port :host "localhost"})] - (with-chrome {:args ["--no-sandbox"]} driver - ;; added to diagnose flakyness on windows on CI + _ (e/wait-running {:port port :host "localhost"})] + (e2/with-chrome [driver {:args ["--no-sandbox"]}] + ;; added to diagnose flakyness on windows on CI (println "automatically chosen port->" (:port driver)) - ;; added to diagnose flakyness on windows on CI - (wait-running driver) + ;; added to diagnose flakyness on windows on CI + (e/wait-running driver) (is (= 2 (get-count-chromedriver-instances)))) (proc/kill process))) (deftest test-process-forking-connect-existing (let [port 9999 process (proc/run ["chromedriver" (format "--port=%d" port)]) - _ (wait-running {:port port :host "localhost"}) - driver (chrome {:host "localhost" :port port :args ["--no-sandbox"]})] - (wait-running driver) + _ (e/wait-running {:port port :host "localhost"}) + driver (e/chrome {:host "localhost" :port port :args ["--no-sandbox"]})] + (e/wait-running driver) (is (= 1 (get-count-chromedriver-instances))) - (quit driver) + (e/quit driver) (proc/kill process))) diff --git a/test/etaoin/unit/unit_test.clj b/test/etaoin/unit/unit_test.clj index 72ea4966..41d41f6e 100644 --- a/test/etaoin/unit/unit_test.clj +++ b/test/etaoin/unit/unit_test.clj @@ -1,73 +1,66 @@ (ns etaoin.unit.unit-test - (:require [babashka.fs :as fs] - [clojure.spec.alpha :as s] - [clojure.test :refer :all] - [etaoin.api :refer :all] - [etaoin.api2 :as e2] - [etaoin.ide.flow :as ide] - [etaoin.ide.impl.spec :as spec] - [etaoin.test-report] - etaoin.impl.proc)) + (:require + [babashka.fs :as fs] + [clojure.spec.alpha :as s] + [clojure.test :refer [deftest is testing]] + [etaoin.api :as e] + [etaoin.api2 :as e2] + [etaoin.ide.flow :as ide] + [etaoin.ide.impl.spec :as spec] + [etaoin.impl.proc :as proc] + [etaoin.test-report])) (deftest test-firefox-driver-args (with-redefs - [etaoin.impl.proc/run (fn [_ _]) - wait-running identity - create-session (fn [_ _] "session-key") - etaoin.impl.proc/kill identity - delete-session identity] + [etaoin.impl.proc/run (fn [_ _]) + e/wait-running identity + e/create-session (fn [_ _] "session-key") + proc/kill identity + e/delete-session identity] (testing "Session" - (with-firefox {} driver + (e2/with-firefox [driver] (is (= "session-key" (:session driver))))) (testing "No custom args" - (with-firefox {:port 1234} driver + (e2/with-firefox [driver {:port 1234}] (is (= ["geckodriver" "--port" 1234] (:args driver))))) (testing "Default `--marionette-port` is assigned when `:profile` is specified" - (with-firefox {:port 1234 :profile "/tmp/firefox-profile/1"} driver + (e2/with-firefox [driver {:port 1234 :profile "/tmp/firefox-profile/1"}] (is (= ["geckodriver" "--port" 1234 "--marionette-port" 2828] (:args driver))))) (testing "Custom `--marionette-port` is assigned when `:profile` is specified" - (with-firefox {:port 1234 - :profile "/tmp/firefox-profile/1" - :args-driver ["--marionette-port" 2821]} driver + (e2/with-firefox [driver {:port 1234 + :profile "/tmp/firefox-profile/1" + :args-driver ["--marionette-port" 2821]}] (is (= ["geckodriver" "--port" 1234 "--marionette-port" 2821] (:args driver))))))) (deftest test-chrome-profile (fs/with-temp-dir [chrome-dir {:prefix "chrome-dir"}] (let [profile-path (str (fs/file chrome-dir "chrome-profile"))] - (with-chrome {:profile profile-path :args ["--no-sandbox"]} driver - (go driver "chrome://version") + (e2/with-chrome [driver {:profile profile-path :args ["--no-sandbox"]}] + (e/go driver "chrome://version") (is profile-path - (get-element-text driver :profile_path)))))) - -(deftest test-chrome-profile-using-v2-api - (fs/with-temp-dir [chrome-dir {:prefix "chrome-dir"}] - (let [profile-path (str (fs/file chrome-dir "chrome-profile"))] - (e2/with-chrome [driver {:profile profile-path :args ["--no-sandbox"]} ] - (go driver "chrome://version") - (is profile-path - (get-element-text driver :profile_path)))))) + (e/get-element-text driver :profile_path)))))) (deftest test-fail-run-driver (is (thrown-with-msg? clojure.lang.ExceptionInfo #"wrong-driver-path" - (chrome {:path-driver "wrong-driver-path"})))) + (e/chrome {:path-driver "wrong-driver-path"})))) (deftest test-actions - (let [keyboard (-> (make-key-input) - (with-key-down "H") - add-pause - (with-key-down "I") + (let [keyboard (-> (e/make-key-input) + (e/with-key-down "H") + e/add-pause + (e/with-key-down "I") (dissoc :id)) - mouse (-> (make-mouse-input) - add-pointer-click - add-pause - (with-pointer-left-btn-down - (add-pointer-move-to-el "123")) + mouse (-> (e/make-mouse-input) + e/add-pointer-click + e/add-pause + (e/with-pointer-left-btn-down + (e/add-pointer-move-to-el "123")) (dissoc :id)) keyboard-result {:type "key", :actions diff --git a/test/etaoin/unit/xpath_test.clj b/test/etaoin/unit/xpath_test.clj index 0b141abd..c04f682a 100644 --- a/test/etaoin/unit/xpath_test.clj +++ b/test/etaoin/unit/xpath_test.clj @@ -1,5 +1,5 @@ (ns etaoin.unit.xpath-test - (:require [clojure.test :refer :all] + (:require [clojure.test :refer [deftest testing is]] [etaoin.test-report] [etaoin.impl.xpath :as xpath]))