diff --git a/.clj-kondo/babashka/fs/config.edn b/.clj-kondo/babashka/fs/config.edn new file mode 100644 index 00000000..23f36094 --- /dev/null +++ b/.clj-kondo/babashka/fs/config.edn @@ -0,0 +1 @@ +{:lint-as {babashka.fs/with-temp-dir clojure.core/let}} diff --git a/.clj-kondo/lread/status-line/config.edn b/.clj-kondo/lread/status-line/config.edn new file mode 100644 index 00000000..ac284abe --- /dev/null +++ b/.clj-kondo/lread/status-line/config.edn @@ -0,0 +1,2 @@ +{:lint-as {lread.status-line/line clojure.tools.logging/infof + lread.status-line/die clojure.tools.logging/infof}} diff --git a/.clj-kondo/rewrite-clj/rewrite-clj/config.edn b/.clj-kondo/rewrite-clj/rewrite-clj/config.edn new file mode 100644 index 00000000..19ecae96 --- /dev/null +++ b/.clj-kondo/rewrite-clj/rewrite-clj/config.edn @@ -0,0 +1,5 @@ +{:lint-as + {rewrite-clj.zip/subedit-> clojure.core/-> + rewrite-clj.zip/subedit->> clojure.core/->> + rewrite-clj.zip/edit-> clojure.core/-> + rewrite-clj.zip/edit->> clojure.core/->>}} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d05f8592..ab88cce9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,14 +6,57 @@ on: pull_request: jobs: + setup: + runs-on: ubuntu-latest + + outputs: + tests: ${{ steps.set-tests.outputs.tests }} + + steps: + - uses: actions/checkout@v3 + + - name: Clojure deps cache + uses: actions/cache@v3 + with: + path: | + ~/.m2/repository + ~/.deps.clj + ~/.gitlibs + key: cljdeps-${{ hashFiles('project.clj, bb.edn') }} + restore-keys: ${{ runner.os }}-cljdeps- + + - name: "Setup Java" + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '11' + + - name: Install Clojure Tools + uses: DeLaGuardo/setup-clojure@5.1 + with: + bb: 'latest' + lein: 'latest' + + # This assumes downloaded deps are same for all OSes + - name: Bring down deps + run: | + lein deps + bb --version + + - id: set-tests + name: Set test var for matrix + # run test.clj directly instead of via bb task to avoid generic task output + run: echo "::set-output name=tests::$(bb script/test.clj matrix-for-ci --format=json)" + build: + needs: setup runs-on: ${{ matrix.os }}-latest strategy: fail-fast: false matrix: - os: [ ubuntu, macos, windows ] + include: ${{fromJSON(needs.setup.outputs.tests)}} - name: ${{ matrix.os }} + name: ${{ matrix.desc }} steps: @@ -24,7 +67,9 @@ jobs: with: path: | ~/.m2/repository - key: ${{ runner.os }}-cljdeps-${{ hashFiles('project.clj') }} + ~/.deps.clj + ~/.gitlibs + key: cljdeps-${{ hashFiles('project.clj, bb.edn') }} restore-keys: ${{ runner.os }}-cljdeps- - name: "Setup Java" @@ -42,19 +87,6 @@ jobs: - name: Tools versions run: bb tools-versions - - name: Bring down deps - run: lein deps - - - name: Launch Virtual Display (ubuntu) - if: matrix.os == 'ubuntu' - run: Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - - - name: Run Tests (unbuntu) - if: matrix.os == 'ubuntu' - run: lein test - env: - DISPLAY: :99.0 - - - name: Run Tests (macos, windows) - if: matrix.os != 'ubuntu' - run: lein test + - name: Run Tests + # To see all commands: bb test matrix-for-ci + run: ${{ matrix.cmd }} diff --git a/bb.edn b/bb.edn index f29650f9..5f1e25e6 100644 --- a/bb.edn +++ b/bb.edn @@ -1,3 +1,15 @@ {:paths ["script"] - :deps {doric/doric {:mvn/version "0.9.0"}} - :tasks {tools-versions {:task tools-versions/report :doc "report on tools versions"}}} + :deps {doric/doric {:mvn/version "0.9.0"} + lread/status-line {:git/url "https://github.com/lread/status-line.git" + :sha "35ed39645038e81b42cb15ed6753b8462e60a06d"} + dev.nubank/docopt {:mvn/version "0.6.1-fix7"}} + :tasks + {;; setup + :requires ([clojure.string :as string] + [lread.status-line :as status]) + :enter (let [{:keys [name]} (current-task)] (status/line :head "TASK %s %s" name (string/join " " *command-line-args*))) + :leave (let [{:keys [name]} (current-task)] (status/line :detail "\nTASK %s done." name)) + + ;; commands + tools-versions {:task tools-versions/-main :doc "report on tools versions"} + test {:task test/-main :doc "run all or a subset of tests, use --help for args"}}} diff --git a/project.clj b/project.clj index 4457e904..f85fdf9e 100644 --- a/project.clj +++ b/project.clj @@ -41,6 +41,8 @@ [org.clojure/tools.logging "0.3.1"] [org.clojure/data.codec "0.1.0"]] + :test-selectors {:unit :unit} + ;; ;; When running the tests as `lein test2junit`, ;; emit XUNIT test reports to enable CircleCI diff --git a/script/helper/main.clj b/script/helper/main.clj new file mode 100644 index 00000000..2a8a6f11 --- /dev/null +++ b/script/helper/main.clj @@ -0,0 +1,67 @@ +(ns helper.main + (:require [clojure.string :as string] + [docopt.core :as docopt] + [lread.status-line :as status])) + +(defmacro when-invoked-as-script + "Runs `body` when clj was invoked from command line as a script." + [& body] + `(when (= *file* (System/getProperty "babashka.file")) + ~@body)) + +(defn- args-usage-to-docopt-usage + "We specify + + Valid args: + cmd1 + cmd2 + --opt1 + + Or: + + Valid args: [options] + + But docopt expects: + + Usage: + foo cmd1 + foo cmd2 + foo -opt1 + + Or: + + Usage: [options] + + This little fn converts from our args usage to something docopt can understand" + [usage] + (let [re-arg-usage #"(?msi)^Valid args:.*?(?=\n\n)"] + (if-let [args-usage (re-find re-arg-usage usage)] + (let [[label-line & variant-lines] (-> args-usage string/split-lines) + docopt-usage-block (string/join "\n" (concat [(if (re-find #"(?i)Valid args:( +\S.*)" label-line) + (string/replace label-line #"(?i)Valid args:( +\S.*)" "Usage: foo $1") + "Usage:")] + (map #(string/replace % #"^ " " foo ") variant-lines))) + docopt-usage-block (str docopt-usage-block "\n")] + (string/replace usage re-arg-usage docopt-usage-block)) + (throw (ex-info "Did not find expected 'Valid args:' in usage" {}))))) + +(def default-arg-usage "Valid args: [--help] + +This command accepts no arguments.") + +(defn doc-arg-opt + "Args usage wrapper for docopt. + + You'll need to specify --help in your arg-usage, but code to handle --help is provided here." + ([args] + (doc-arg-opt default-arg-usage args)) + ([arg-usage args] + (let [opts (docopt/docopt (args-usage-to-docopt-usage arg-usage) + args + identity + (fn usage-error [_docopt-usage] (status/die 1 arg-usage)))] + (if (get opts "--help") + (do + (status/line :detail arg-usage) + nil) + opts)))) diff --git a/script/helper/os.clj b/script/helper/os.clj new file mode 100644 index 00000000..931c7ef0 --- /dev/null +++ b/script/helper/os.clj @@ -0,0 +1,11 @@ +(ns helper.os + (:require [clojure.string :as string])) + +(defn get-os [] + (let [os-name (string/lower-case (System/getProperty "os.name"))] + (condp re-find os-name + #"win" :win + #"mac" :mac + #"(nix|nux|aix)" :unix + #"sunos" :solaris + :unknown))) diff --git a/script/helper/shell.clj b/script/helper/shell.clj new file mode 100644 index 00000000..da440778 --- /dev/null +++ b/script/helper/shell.clj @@ -0,0 +1,33 @@ +(ns helper.shell + (:require [babashka.tasks :as tasks] + [clojure.pprint :as pprint] + [clojure.string :as string] + [helper.os :as os] + [lread.status-line :as status])) + +(def default-opts {:error-fn + (fn die-on-error [{{:keys [exit cmd]} :proc}] + (status/die exit + "shell exited with %d for: %s" + exit + (with-out-str (pprint/pprint cmd))))}) + +(defn command + "Thin wrapper on babashka.tasks/shell that on error, prints status error message and exits. + Launches everything through powershell if on windows (maybe not a good general solution (?) but + ok for this project)." + [cmd & args] + (let [[opts cmd args] (if (map? cmd) + [cmd (first args) (rest args)] + [nil cmd args]) + opts (merge opts default-opts)] + (if (= :win (os/get-os)) + (let [full-cmd (if (seq args) + ;; naive, but fine for our uses for now, adjust as necessary + (str cmd " " (string/join " " args)) + cmd)] + (tasks/shell opts "powershell" "-command" + ;; powershell -command does not automatically propagate exit code, + ;; hence the secret exit sauce here + (str full-cmd ";exit $LASTEXITCODE") )) + (apply tasks/shell opts cmd args)))) diff --git a/script/test.clj b/script/test.clj new file mode 100644 index 00000000..53fd5524 --- /dev/null +++ b/script/test.clj @@ -0,0 +1,119 @@ +(ns test + (:require [babashka.fs :as fs] + [babashka.process :as process] + [cheshire.core :as json] + [clojure.string :as string] + [doric.core :as doric] + [helper.main :as main] + [helper.shell :as shell] + [lread.status-line :as status])) + +(defn- test-def [os id browser] + {:os os + :cmd (->> ["bb test" id + (when browser (str "--browser=" browser)) + (when (= "ubuntu" os) "--launch-virtual-display")] + (remove nil?) + (string/join " ")) + :desc (->> [id os browser] + (remove nil?) + (string/join " "))}) + +(defn- github-actions-matrix [] + (let [oses ["macos" "ubuntu" "windows"] + ide-browsers ["chrome" "firefox"] + api-browsers ["chrome" "firefox" "edge" "safari"]] + (->> (concat + (for [os oses] + (test-def os "unit" nil)) + (for [os oses + browser ide-browsers] + (test-def os "ide" browser)) + (for [os oses + browser api-browsers + :when (not (or (and (= "ubuntu" os) (some #{browser} ["edge" "safari"])) + (and (= "windows" os) (= "safari" browser))))] + (test-def os "api" browser))) + (sort-by :desc) + (into [])))) + +(defn- launch-xvfb [] + (if (fs/which "Xvfb") + (process/process "Xvfb :99 -screen 0 1024x768x24" {:out (fs/file "/dev/null") + :err (fs/file "/dev/null")}) + (status/die 1 "Xvfb not found")) + (let [deadline (+ (System/currentTimeMillis) 10000)] + (loop [] + (let [{:keys [exit]} (shell/command {:out (fs/file "/dev/null") + :err (fs/file "/dev/null") + :continue true} + "xdpyinfo -display :99")] + (if (zero? exit) + (status/line :detail "Xvfb process looks good.") + (if (> (System/currentTimeMillis) deadline) + (status/die 1 "Failed to get status from Xvfb process") + (do + (status/line :detail "Waiting for Xvfb process.") + (Thread/sleep 500) + (recur)))))))) + +(def args-usage "Valid args: + (api|ide) [--browser=]... [--launch-virtual-display] + (unit|all) [--launch-virtual-display] + matrix-for-ci [--format=json] + --help + +Commands: + unit Run only unit tests + api Run only api tests, optionally specifying browsers to override defaults + ide Run only ide tests, optionally specifying browsers to override defaults + all Run all tests using browser defaults + matrix-for-ci Return text matrix for GitHub Actions + +Options: + --launch-virtual-display Launch a virtual display for browsers (use on linux only) + --help Show this help + +Notes: +- ide tests default to firefox and chrome only. +- api tests default browsers based on OS on which they are run. +- launching a virtual display is necessary for GitHub Actions but not so for CircleCI") + +(defn -main [& args] + (when-let [opts (main/doc-arg-opt args-usage args)] + (cond + (get opts "matrix-for-ci") + (if (= "json" (get opts "--format")) + (status/line :detail (-> (github-actions-matrix) + (json/generate-string #_{:pretty true}))) + (status/line :detail (->> (github-actions-matrix) + (doric/table [:os :cmd :desc])))) + + :else + (let [lein-args (cond + (get opts "api") "test :only etaoin.api-test" + (get opts "ide") "test :only etaoin.ide-test" + (get opts "unit") "test :unit" + :else "test") + browsers (->> (get opts "--browser") (keep identity)) + env (cond-> {} + (seq browsers) + (assoc (if (get opts "api") + "ETAOIN_TEST_DRIVERS" + "ETAOIN_IDE_TEST_DRIVERS") + (mapv keyword browsers)) + + (get opts "--launch-virtual-display") + (assoc "DISPLAY" ":99.0")) + shell-opts (if (seq env) + {:extra-env env} + {})] + (when (get opts "--launch-virtual-display") + (status/line :head "Launching virtual display") + (launch-xvfb)) + (status/line :head "Running tests") + (shell/command shell-opts (str "lein " lein-args)))))) + +(main/when-invoked-as-script + (apply -main *command-line-args*)) + diff --git a/script/tools_versions.clj b/script/tools_versions.clj index d35ca017..4de683a6 100644 --- a/script/tools_versions.clj +++ b/script/tools_versions.clj @@ -1,9 +1,11 @@ (ns tools-versions (:require [babashka.fs :as fs] - [babashka.tasks :as tasks] [clojure.string :as string] [doric.core :as doric] - [cheshire.core :as json])) + [cheshire.core :as json] + [helper.main :as main] + [helper.os :as os] + [helper.shell :as shell])) (def tools [;; earlier versions of java used -version and spit version info to stderr @@ -32,24 +34,16 @@ :args "--version" :version-post-fn identity}) -(defn- get-os [] - (let [os-name (string/lower-case (System/getProperty "os.name"))] - (condp re-find os-name - #"win" :win - #"mac" :mac - #"(nix|nux|aix)" :unix - :unknown))) - (defn- expected-on-this-os [{:keys [oses]}] (or (= :all oses) - (some #{(get-os)} oses))) + (some #{(os/get-os)} oses))) (defn- version-cmd-result [shell-opts {:keys [out err exit]}] (if (not (zero? exit)) - (format "" exit) - (cond-> "" - (= :string (:out shell-opts)) (str out) - (= :string (:err shell-opts)) (str err)))) + {:error (format "exit code %d" exit)} + {:version (cond-> "" + (= :string (:out shell-opts)) (str out) + (= :string (:err shell-opts)) (str err))})) (defn- table-multilines->rows "Convert a seq of maps from [{:a \"one\n\two\" :b \"a\nb\nc\"}] @@ -60,7 +54,7 @@ [results] (reduce (fn [acc n] (let [n (reduce-kv (fn [m k v] - (assoc m k (string/split-lines v))) + (assoc m k (when v (string/split-lines v)))) {} n) max-lines (apply max (map #(count (val %)) n))] @@ -81,11 +75,9 @@ (let [reg-keys ["\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*" "\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*"]] (->> (mapcat (fn [reg-key] - (-> (tasks/shell {:out :string :continue true} - "powershell" - "-command" - (format "Get-ItemProperty HKLM:%s | Select-Object DisplayName, DisplayVersion, InstallLocation | ConvertTo-Json" - reg-key)) + (-> (shell/command {:out :string :continue true} + (format "Get-ItemProperty HKLM:%s | Select-Object DisplayName, DisplayVersion, InstallLocation | ConvertTo-Json" + reg-key)) :out json/parse-string)) reg-keys)))) @@ -103,44 +95,49 @@ (= app pname) (re-matches app pname))))) first)] - {:app (get found-package "InstallLocation" "?") + {:path (get found-package "InstallLocation" "?") :version (get found-package "DisplayVersion" "?")} - {:error (format "" app)})) + {:error (format "windows package not found: %s" app)})) (defmethod resolve-tool :mac-app [{:keys [app shell-opts version-post-fn]}] (let [app-dir (format "/Applications/%s.app" app)] - (if (fs/exists? app-dir) - {:app app-dir - :version - (->> (tasks/shell shell-opts (format "defaults read '%s/Contents/Info' CFBundleShortVersionString" app-dir)) - (version-cmd-result shell-opts) - version-post-fn)} - {:error (format "" app)}))) + (if (fs/exists? app-dir) + (let [version-result (->> (shell/command shell-opts (format "defaults read '%s/Contents/Info' CFBundleShortVersionString" app-dir)) + (version-cmd-result shell-opts)) + version-result (assoc version-result :path app-dir)] + (if (:error version-result) + version-result + (update version-result :version version-post-fn))) + {:error (format "mac app not found: %s" app)}))) (defmethod resolve-tool :bin [{:keys [app shell-opts args version-post-fn]}] (if-let [found-bin (some-> (fs/which app {:win-exts ["com" "exe" "bat" "cmd" "ps1"]}) - str)] - {:app found-bin - :version (->> (if (string/ends-with? found-bin ".ps1") - (tasks/shell shell-opts "powershell" "-command" found-bin args) - (tasks/shell shell-opts found-bin args)) - (version-cmd-result shell-opts) - version-post-fn)} - {:error (format "" app)})) - -(defn report + str)] + (let [version-result (->> (shell/command shell-opts found-bin args) + (version-cmd-result shell-opts)) + version-result (assoc version-result :path found-bin)] + (if (:error version-result) + version-result + (update version-result :version version-post-fn))) + {:error (format "bin not found: %s" app)})) + +(defn -main "Report on tools versions based the the OS the script it is run from. Currently informational only, should always return 0 unless, of course, something exceptional happens." - [] - (->> (for [{:keys [name] :as t} (map #(merge tool-defaults %) tools) - :when (expected-on-this-os t) - :let [{:keys [error app version]} (resolve-tool t)]] - (if error - {:name name :version error} - {:name name :app app :version version})) - table-multilines->rows - (doric/table [:name :version :app]) - println)) + [& args] + (when (main/doc-arg-opt args) + (->> (for [{:keys [name] :as t} (map #(merge tool-defaults %) tools) + :when (expected-on-this-os t) + :let [{:keys [error path version]} (resolve-tool t)]] + (if error + {:name name :path path :version (format "** ERROR: %s **",error)} + {:name name :path path :version version})) + table-multilines->rows + (doric/table [:name :version :path]) + println))) + +(main/when-invoked-as-script + (apply -main *command-line-args*)) diff --git a/test/etaoin/api_test.clj b/test/etaoin/api_test.clj index 7f76aa53..1c42984b 100644 --- a/test/etaoin/api_test.clj +++ b/test/etaoin/api_test.clj @@ -4,6 +4,7 @@ [clojure.string :as str] [clojure.test :refer :all] [etaoin.api :refer :all] + [etaoin.test-report :as test-report] [etaoin.util :refer [with-tmp-dir with-tmp-file]] [slingshot.slingshot :refer [try+]]) (:import javax.imageio.ImageIO)) @@ -53,7 +54,8 @@ (with-driver type (get default-opts type {}) driver (go driver url) (wait-visible driver {:id :document-end}) - (binding [*driver* driver] + (binding [*driver* driver + test-report/*context* (name type)] (testing (name type) (f))))))) @@ -61,6 +63,14 @@ :each fixture-browsers) +(defn report-browsers [f] + (println "Testing with browsers:" drivers) + (f)) + +(use-fixtures + :once + report-browsers) + (deftest test-visible (doto *driver* (-> (visible? {:id :button-visible}) is) diff --git a/test/etaoin/ide_test.clj b/test/etaoin/ide_test.clj index 0d5a4fe4..84fa7041 100644 --- a/test/etaoin/ide_test.clj +++ b/test/etaoin/ide_test.clj @@ -1,6 +1,8 @@ (ns etaoin.ide-test - (:require [etaoin.api :as api] + (: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])) @@ -8,15 +10,27 @@ (def ^:dynamic *base-url*) (def ^:dynamic *test-file-path*) +(defn get-drivers-from-env [] + (when-let [override (System/getenv "ETAOIN_IDE_TEST_DRIVERS")] + (edn/read-string override))) + +(defn get-default-drivers [] + [:firefox :chrome]) + +(def drivers + (or (get-drivers-from-env) + (get-default-drivers))) + (defn fixture-browser [f] (let [base-url (-> "html" io/resource str) test-file-path (-> "ide/test.side" io/resource str)] - (doseq [type [:chrome :firefox]] + (doseq [type drivers] (api/with-driver type {:args ["--no-sandbox"]} driver (api/go driver base-url) (binding [*driver* driver *base-url* base-url - *test-file-path* test-file-path] + *test-file-path* test-file-path + test-report/*context* (name type)] (testing (name type) (f))))))) @@ -24,6 +38,14 @@ :each fixture-browser) +(defn report-browsers [f] + (println "Testing with browsers:" drivers) + (f)) + +(use-fixtures + :once + report-browsers) + (deftest test-asserts (ide/run-ide-script *driver* *test-file-path* {:base-url *base-url* :test-name "test-asserts"}) diff --git a/test/etaoin/proc_test.clj b/test/etaoin/proc_test.clj index fdc9c092..293d8980 100644 --- a/test/etaoin/proc_test.clj +++ b/test/etaoin/proc_test.clj @@ -1,8 +1,9 @@ -(ns etaoin.proc-test +(ns ^:unit etaoin.proc-test (:require [etaoin.api :refer :all] [clojure.java.shell :refer [sh]] [clojure.test :refer :all] [etaoin.proc :as proc] + [etaoin.test-report] [clojure.string :as str])) (defn get-count-chromedriver-instances diff --git a/test/etaoin/test_report.clj b/test/etaoin/test_report.clj new file mode 100644 index 00000000..95f2c8c3 --- /dev/null +++ b/test/etaoin/test_report.clj @@ -0,0 +1,10 @@ +(ns etaoin.test-report + (:require [clojure.test])) + +(def ^:dynamic *context*) + +(defmethod clojure.test/report :begin-test-var [m] + (let [test-name (-> m :var meta :name)] + (if (bound? #'*context*) + (println (format "=== %s [%s]" test-name *context*)) + (println (format "=== %s" test-name))))) diff --git a/test/etaoin/unit_test.clj b/test/etaoin/unit_test.clj index 6fc531ce..fc2cab05 100644 --- a/test/etaoin/unit_test.clj +++ b/test/etaoin/unit_test.clj @@ -1,10 +1,11 @@ -(ns etaoin.unit-test +(ns ^:unit etaoin.unit-test (:require [clojure.spec.alpha :as s] [clojure.test :refer :all] [etaoin.api :refer :all] [etaoin.ide.flow :as ide] [etaoin.ide.spec :as spec] [etaoin.util :refer [with-tmp-dir]] + [etaoin.test-report] etaoin.proc) (:import java.io.File)) diff --git a/test/etaoin/xpath_test.clj b/test/etaoin/xpath_test.clj index 3b422ada..55344946 100644 --- a/test/etaoin/xpath_test.clj +++ b/test/etaoin/xpath_test.clj @@ -1,5 +1,6 @@ -(ns etaoin.xpath-test +(ns ^:unit etaoin.xpath-test (:require [clojure.test :refer :all] + [etaoin.test-report] [etaoin.xpath :as xpath])) (def xpath-samples