diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6e6c602 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,41 @@ +name: Test + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + strategy: + matrix: + jdk: ['8', '11', '17'] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Cache local Maven repository + uses: actions/cache@v2 + with: + path: | + ~/.m2/repository + ~/.gitlibs + key: ${{ runner.os }}-maven-${{ hashFiles('deps.edn') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Prepare java + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: ${{ matrix.jdk }} + + - name: Install clojure tools + uses: DeLaGuardo/setup-clojure@3.7 + with: + cli: latest + lein: latest + boot: latest + - name: Clojure CLI tests + run: clojure -X:test diff --git a/.gitignore b/.gitignore index 1181a7b..e573357 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ pom.xml.asc .hgignore .hg/ .nrepl-history +.cpcache +test-projects/*/target +.lein-failures diff --git a/README.md b/README.md index 1e92682..c092272 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,90 @@ See `boot bat-test -h` for a list of available task options. See `lein bat-test help` for a list of available task options. +## Clojure CLI Usage + +### Main style + +### Exec style + +Add the following alias to your deps.edn: + +```clojure +{:aliases {:test {:extra-deps {metosin/bat-test {...}} + :extra-paths ["test"] + :exec-fn metosin.bat-test.cli/exec + :exec-args {:test-dirs ["test"]}}}} +``` + +Invoke with exec (`-X`), and see `metosin.bat-test.cli/{test,exec,run-tests}` for options. Here are some examples: + +```sh +# run all tests once +clojure -X:test + +# continuously run tests +clojure -X:test :watch true + +# run tests in parallel +clojure -X:test :parallel true + +# just test files under particular directories (handy for monorepos) +clojure -X:test :test-dirs '["module1" "module3"]' + +# use Leiningen-style test selectors (see next section for custom selectors) +clojure -X:test :selectors '[:all]' # <- lein test :all +clojure -X:test :selectors '[my-ns :only other-ns/foo]' # <- lein test my-ns :only other-ns/foo +``` + +### Handy default arguments + +Use `:exec-args` for default values. Especially useful for Leiningen-style test selectors: + +eg., + +```clojure +; selectors.clj +{:default (clojure.core/complement (clojure.core/some-fn :integration :disabled)) + :integration :integration + :all (clojure.core/complement :disabled)} +``` + +```clojure +; deps.edn +{:aliases {:test {:extra-deps {metosin/bat-test {...}} + :extra-paths ["test"] + :exec-fn metosin.bat-test.cli/exec + :exec-args {:test-dirs ["test"] + ;; add this line + :test-selectors-form-file "selectors.clj"}}}} +``` + +You can share this file with Leiningen like so: + +```clojure +; project.clj +(defproject ... + :test-selectors ~(-> "selectors.clj" slurp read-string)) +``` + +## REPL Usage + +Almost identical to Clojure CLI usage (above), except use `metosin.bat-test.cli/test`. + +The major difference is that `test` throws exceptions, whereas `metosin.bat-test.cli/exec` uses `System/exit` (you don't +want this at the REPL). + +You may want to create your own wrapper to provide some default arguments: + +```clojure +(ns my-repl-ns + (:refer-clojure :exclude [test]) + (:require [metosin.bat-test.cli :as bat-test])) + +(defn test [args] + (bat-test/test (into {:test-selectors-form-file "selectors.clj"} args))) +``` + ## License Copyright © 2016-2019 [Metosin Oy](http://www.metosin.fi) diff --git a/build.boot b/build.boot index c091e89..5233051 100644 --- a/build.boot +++ b/build.boot @@ -1,4 +1,4 @@ -(def +version+ "0.4.4") +(def +version+ (slurp "version")) (set-env! :resource-paths #{"src"} diff --git a/build.clj b/build.clj new file mode 100644 index 0000000..5708dc1 --- /dev/null +++ b/build.clj @@ -0,0 +1,56 @@ +(ns build + (:refer-clojure :exclude [compile]) + (:require [clojure.tools.build.api :as b] + [clojure.java.io :as io] + [clojure.string :as str])) + +(def lib 'metosin/bat-test) +(def +version+ (format "%s.%s" (slurp "version") (b/git-count-revs nil))) +(def class-dir "target/classes") +(def basis (b/create-basis {:project "deps.edn"})) +(def jar-file (format "target/%s-%s.jar" (name lib) +version+)) + +(defn write-version-file [d nsym] + (let [f (io/file d (-> (name nsym) + (str/replace #"\." "/") + (str/replace #"-" "_") + (str ".clj")))] + (io/make-parents f) + (spit f (format "(ns %s)\n\n(def +version+ \"%s\")" (name nsym) +version+)))) + +;; operations + +(defn clean [_] + (b/delete {:path "target"})) + +(defn compile [_] + (write-version-file class-dir 'metosin.bat-test.version)) + +(defn jar [_] + (b/write-pom {:class-dir class-dir + :lib lib + :version +version+ + :basis basis + :src-dirs ["src"]}) + (b/copy-dir {:src-dirs ["src" "resources"] + :target-dir class-dir}) + (compile {}) + (b/jar {:class-dir class-dir + :jar-file jar-file})) + +;; clojure -T:build version +(defn version [_] (print +version+)) + +;; clojure -T:build install +(defn install + "Prints the version that was installed." + [_] + (clean {}) + (jar {}) + (b/install {:basis basis + :lib lib + :version +version+ + :class-dir class-dir + :jar-file jar-file}) + ;; test-projects expect version to be printed + (version {})) diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..973fab5 --- /dev/null +++ b/deps.edn @@ -0,0 +1,25 @@ +{:paths ["src"] + :deps {eftest/eftest {:mvn/version "0.5.9"} + org.clojure/tools.namespace {:mvn/version "1.2.0"} + cloverage/cloverage {:mvn/version "1.1.1"} + hawk/hawk {:mvn/version "0.2.11"}} + ;; Note: prep is commented out because it only generates metosin.bat-test.version, + ;; which is only used in leiningen.bat-test--ie., only ever from a jar. This will + ;; thus never be used via a deps.edn source dependency. If enabling this prep step, + ;; add "target/classes" to the top-level :paths entry. + ;; clj -X:deps prep + ;; https://clojure.org/guides/deps_and_cli#prep_libs + ;:deps/prep-lib {:alias :build + ; :fn compile + ; :ensure "target/classes"} + :aliases {;; clojure -X:test + :test {:extra-paths ["test"] + :extra-deps {io.github.cognitect-labs/test-runner + {:git/tag "v0.5.0" :git/sha "b3fd0d2"}} + :exec-fn cognitect.test-runner.api/test} + ;; clj -T:build clean + ;; clj -T:build jar + ;; clj -T:build install + ;; clj -T:build version + :build {:deps {io.github.clojure/tools.build {:git/tag "v0.7.5" :git/sha "34727f7"}} + :ns-default build}}} diff --git a/src/leiningen/bat_test.clj b/src/leiningen/bat_test.clj index 37394c1..dc27e69 100644 --- a/src/leiningen/bat_test.clj +++ b/src/leiningen/bat_test.clj @@ -1,17 +1,20 @@ (ns leiningen.bat-test + (:refer-clojure :exclude [read-string]) (:require [leiningen.help] [leiningen.core.eval :as eval] [leiningen.core.project :as project] [leiningen.core.main :as main] [leiningen.help :as help] [leiningen.test :as test] + [clojure.edn :refer [read-string]] [clojure.java.io :as io] + [metosin.bat-test.cli :as cli] [metosin.bat-test.version :refer [+version+]])) (def profile {:dependencies [['metosin/bat-test +version+] ['eftest "0.5.9"] - ['org.clojure/tools.namespace "0.3.0-alpha4"] - ['cloverage "1.0.13"] + ['org.clojure/tools.namespace "1.2.0"] + ['cloverage "1.1.1"] ['hawk "0.2.11"]]}) (defn quoted-namespace [key s] @@ -33,33 +36,63 @@ on-start (conj (quoted-namespace :on-start on-start)) on-end (conj (quoted-namespace :on-end on-end)))) +(defn- absolutize-path [path] + (let [path (io/file path)] + (.getPath + (if (.isAbsolute path) + path + (io/file (System/getProperty "leiningen.original.pwd") + path))))) + +(defn- absolutize-paths [paths] + (mapv absolutize-path (cond-> paths + (string? paths) vector))) + +(defn- absolutize-opts [opts] + (-> opts + (cond-> + (:watch-directories opts) + (update :watch-directories absolutize-paths)) + (cond-> + (:test-dirs opts) + (update :test-dirs absolutize-paths)))) + +;; TODO test directory sensitivity +;; assumes opts is absolutized (defn- run-tests [project opts watch?] - (let [watch-directories (vec (concat (:test-paths project) - (:source-paths project) - (:resource-paths project))) - opts (assoc opts :watch-directories watch-directories)] + (let [{:keys [watch-directories] :as opts} + (-> opts + (update :watch-directories (fn [watch-directories] + (or watch-directories + ;; note: already absolute paths + (vec (concat (:test-paths project) + (:source-paths project) + (:resource-paths project)))))) + absolutize-opts)] + ;(prn `run-tests opts) (eval/eval-in-project project (if watch? - `(do + `(let [opts# ~opts] (System/setProperty "java.awt.headless" "true") - (metosin.bat-test.impl/run ~opts) - (metosin.bat-test.impl/enter-key-listener ~opts) - (hawk.core/watch! [{:paths ~watch-directories + (metosin.bat-test.impl/run opts#) + (metosin.bat-test.impl/enter-key-listener opts#) + (hawk.core/watch! [{:paths '~watch-directories :filter hawk.core/file? :context (constantly 0) - :handler (fn [~'ctx ~'e] - (if (and (re-matches #"^[^.].*[.]cljc?$" (.getName (:file ~'e))) - (< (+ ~'ctx 1000) (System/currentTimeMillis))) + :handler (fn [ctx# e#] + (if (and (re-matches #"^[^.].*[.]cljc?$" (.getName (:file e#))) + (< (+ ctx# 1000) (System/currentTimeMillis))) (do (try (println) - (metosin.bat-test.impl/run ~opts) + (metosin.bat-test.impl/run opts#) (catch Exception e# (println e#))) (System/currentTimeMillis)) - ~'ctx))}])) - `(let [summary# (metosin.bat-test.impl/run ~opts) + ctx#))}])) + `(let [opts# ~opts + summary# (metosin.bat-test.impl/run opts#) exit-code# (min 1 (+ (:fail summary# 0) (:error summary# 0)))] (if ~(= :leiningen (:eval-in project)) exit-code# @@ -82,6 +115,8 @@ (defn bat-test "Run clojure.test tests. +lein bat-test (once|auto|cloverage|help)? * ( *)* (: )? + Changed namespaces are reloaded using clojure.tools.namespace. Only tests in changed or affected namespaces are run. @@ -107,33 +142,77 @@ Available options: :on-end Function to be called after running tests :cloverage-opts Cloverage options :notify-command String or vector describing a command to run after tests +:test-dirs Path or vector of paths restricting the tests that will be matched. + If provided via project.clj, relative to project root. + If provided via command line, non-absolute paths are relative to the directory Leiningen was called from. + Default: nil (no restrictions). +:enter-key-listener If true, refresh tracker on enter key. Default: true. Only meaningful via `auto` subtask. Also supports Lein test selectors, check `lein test help` for more information. Arguments: -- once, auto, cloverage, help -- test selectors" +- once (default), auto, cloverage, help +- test selectors + +Provides the same interface as metosin.bat-test.cli/exec for arguments after `:`. +eg., lein bat-test my.ns :only foo.bar/baz : :parallel? true" {:help-arglists '([& tests]) :subtasks [#'once #'auto]} [project & args] - (let [subtask (or (some #{"auto" "once" "help" "cloverage"} args) "once") - args (remove #{"auto" "once" "help" "cloverage"} args) - ;; read-args tries to find namespaces in test-paths if args doesn't contain namespaces - [namespaces selectors] (test/read-args args (assoc project :test-paths nil)) + (let [opts (into {:enter-key-listener true} + (:bat-test project)) + [subtask args opts] (let [[op args] (or (when-some [op (#{"auto" "once" "help" "cloverage"} (first args))] + [op (next args)]) + ["once" args]) + [args opts] (cli/split-selectors-and-cli-args + (into {:cloverage (= "cloverage" op)} opts) + args) + ;; give cli args the last word on auto/once. :cloverage value will be preserved via opts. + op (case (:watch opts) + true "auto" + false "once" + op)] + [op args opts]) + [namespaces selectors] (let [;; suppress cli selectors if they're just the defaults. + use-cli-selectors? (seq (:selectors opts)) + ;; selectors after : + [namespaces1 selectors1] + (when use-cli-selectors? + (cli/-lein-test-read-args + opts ;; opts + true ;; quote-args? + (:test-selectors project))) ;; user-selectors + ;; selectors before : + [namespaces2 selectors2] + (when (or (seq args) + ;; TODO unit test this + (not use-cli-selectors?)) + (test/read-args + ;; TODO absolutize file/dir args? + args + ;; read-args tries to find namespaces in test-paths if args doesn't contain namespaces. + ;; suppress this by setting :test-paths to nil. + (assoc project :test-paths nil)))] + ;(prn 'namespaces1 namespaces1) + ;(prn 'namespaces2 namespaces2) + ;(prn 'selectors1 selectors1) + ;(prn 'selectors2 selectors2) + ;; combine in disjunction + [(concat namespaces1 namespaces2) + (concat selectors1 selectors2)]) project (project/merge-profiles project [:leiningen/test :test profile]) - config (assoc (:bat-test project) - :selectors (vec selectors) - :namespaces (mapv (fn [n] `'~n) namespaces) - :cloverage (= "cloverage" subtask))] + config (-> opts + (assoc :selectors (vec selectors) + :namespaces (mapv (fn [n] `'~n) namespaces))) + do-once #(try + (when-let [n (run-tests project config false)] + (when (and (number? n) (pos? n)) + (throw (ex-info "Tests failed." {:exit-code n})))) + (catch clojure.lang.ExceptionInfo e + (main/abort "Tests failed."))) + do-watch #(run-tests project config true)] (case subtask - ("once" "cloverage") - (try - (when-let [n (run-tests project config false)] - (when (and (number? n) (pos? n)) - (throw (ex-info "Tests failed." {:exit-code n})))) - (catch clojure.lang.ExceptionInfo e - (main/abort "Tests failed."))) - - "auto" (run-tests project config true) + ("once" "cloverage") (do-once) + "auto" (do-watch) "help" (println (help/help-for "bat-test")) (main/warn "Unknown task.")))) diff --git a/src/metosin/bat_test.clj b/src/metosin/bat_test.clj index f48d94f..638e70d 100644 --- a/src/metosin/bat_test.clj +++ b/src/metosin/bat_test.clj @@ -31,20 +31,25 @@ Default reporter is :progress." [m test-matcher VAL regex "Regex used to select test namespaces (default #\".*test\")" + d test-dirs VAL edn "Path or vector of paths restricting the tests that will be matched. Relative to project root (default `nil`, no restrictions)" p parallel bool "Run tests parallel (default off)" r report VAL edn "Reporting function" f filter VAL sym "Function to filter the test vars" s on-start VAL sym "Function to be called before running tests (after reloading namespaces)" e on-end VAL sym "Function to be called after running tests" c cloverage bool "Enable Cloverage coverage report (default off)" + l enter-key-listener bool "If true, refresh tracker on enter key (default true). Only meaningful when `parallel` is true." _ cloverage-opts VAL edn "Cloverage options"] (let [p (-> (core/get-env) (update-in [:dependencies] into deps) pod/make-pod future) - opts (assoc *opts* - :verbosity (deref util/*verbosity*) - :watch-directories (:directories pod/env))] + opts (-> {:enter-key-listener true} + (into *opts*) + (assoc :verbosity (deref util/*verbosity*) + :watch-directories (:directories pod/env)) + (cond-> + (some? test-dirs) (assoc :test-dirs test-dirs)))] (fn [handler] (System/setProperty "java.awt.headless" "true") (pod/with-call-in @p (metosin.bat-test.impl/enter-key-listener ~opts)) diff --git a/src/metosin/bat_test/cli.clj b/src/metosin/bat_test/cli.clj new file mode 100644 index 0000000..4e76bc5 --- /dev/null +++ b/src/metosin/bat_test/cli.clj @@ -0,0 +1,323 @@ +(ns metosin.bat-test.cli + (:refer-clojure :exclude [read-string test]) + (:require [clojure.edn :refer [read-string]] + [clojure.java.io :as io] + [clojure.set :as set] + [clojure.string :as str] + [clojure.tools.namespace.file :refer [read-file-ns-decl]] + [clojure.tools.namespace.find :refer [find-namespaces]] + [hawk.core :as hawk] + [metosin.bat-test.impl :as impl] + [metosin.bat-test.util :as util])) + +;; https://github.com/technomancy/leiningen/blob/4d8ee78018158c05d69b250e7851f9d7c3a44fac/src/leiningen/test.clj#L162-L175 +(def ^:private -only-form + [`(fn [ns# & vars#] + ((set (for [v# vars#] + (-> (str v#) + (.split "/") + first + symbol))) + ns#)) + `(fn [m# & vars#] + (some #(let [var# (str "#'" %)] + (if (some #{\/} var#) + (= var# (-> m# :leiningen.test/var str)) + (= % (ns-name (:ns m#))))) + vars#))]) + +(defn ^:private -user-test-selectors-form [{:keys [test-selectors-form-file] :as _args}] + (let [selectors (some-> test-selectors-form-file + slurp + read-string)] + (when (some? selectors) + (assert (map? selectors) (format "Selectors in file %s must be a map" test-selectors-form-file))) + selectors)) + +;; returns [ns selectors] +(defn- separate-selectors-from-nses [args] + (split-with (complement keyword?) args)) + +;; https://github.com/technomancy/leiningen/blob/4d8ee78018158c05d69b250e7851f9d7c3a44fac/src/leiningen/test.clj#L144-L154 +(defn- -split-selectors + "Selectors are (namespace*)(selector-kw selector-non-kw-arg*)* + + If quote-args? is true, is like the original function (returns code). + Otherwise, returns a value--not code." + [args quote-args?] + (let [[nses selectors] (separate-selectors-from-nses args)] + [nses + (loop [acc {} [selector & selectors] selectors] + (if (seq selectors) + (let [[args next] (split-with (complement keyword?) selectors)] + (recur (assoc acc selector (cond->> args + quote-args? (list 'quote))) + next)) + (if selector + (assoc acc selector ()) + acc)))])) + +;; https://github.com/technomancy/leiningen/blob/4d8ee78018158c05d69b250e7851f9d7c3a44fac/src/leiningen/test.clj#L156-L160 +(defn- -partial-selectors [project-selectors selectors] + (for [[k v] selectors + :let [selector-form (k project-selectors)] + :when selector-form] + [selector-form v])) + +(defn ^:private -default-opts [args] + (let [opts (if (map? args) + args + (if (map? (first args)) + (do (assert (not (next args))) + (first args)) + (apply hash-map args)))] + (-> opts + (assoc :cloverage + (if-some [[_ v] (find opts :cloverage)] + v + (some? (:cloverage-opts opts)))) + (update :watch-directories + (fn [watch-directories] + (or (not-empty watch-directories) + (->> (str/split (java.lang.System/getProperty "java.class.path") #":") + (remove #(str/ends-with? % ".jar"))))))))) + +;; https://github.com/technomancy/leiningen/blob/4d8ee78018158c05d69b250e7851f9d7c3a44fac/src/leiningen/test.clj#L177-L180 +(defn- -convert-to-ns + "Unlike original function, takes data, not strings. + Files only converted if they are strings, which is + a change to the lein-test interface." + [possible-file] + (if (and (string? possible-file) + (re-matches #".*\.cljc?" possible-file) + (.exists (io/file possible-file))) + (second (read-file-ns-decl possible-file)) + possible-file)) + +(defn- opts->selectors [{:keys [selectors] :as _opts}] + (-> [] + (into (or (when (vector? selectors) + selectors) + (when (some? selectors) + [selectors]))))) + +;; https://github.com/technomancy/leiningen/blob/4d8ee78018158c05d69b250e7851f9d7c3a44fac/src/leiningen/test.clj#L182 +(defn ^:internal -lein-test-read-args + "Unlike original function, reads list of values, not strings, + and returns a value, not code. + + opts are from `run-tests`." + [opts quote-args? user-selectors] + (let [args (opts->selectors opts) + args (->> args (map -convert-to-ns)) + [nses given-selectors] (-split-selectors args quote-args?) + default-selectors {:all `(constantly true) + :only -only-form} + selectors (-partial-selectors (into default-selectors + user-selectors) + given-selectors) + selectors-or-default (if-some [default (when (empty? selectors) + (:default user-selectors))] + [[default ()]] + selectors)] + (when (and (empty? selectors) + (seq given-selectors)) + (throw (ex-info "Could not find test selectors." {}))) + [nses selectors-or-default])) + +(defn- run-tests1 + "Run tests in :test-dirs. Takes the same options as `run-tests`. + + Returns a test summary {:fail :error }" + [opts] + (let [[namespaces selectors] (-lein-test-read-args opts + false + (-user-test-selectors-form opts))] + (impl/run + (-> opts + ;; convert from `lein test`-style :selectors to internal bat-test representation. + ;; done just before calling bat-test to avoid mistakes. + (assoc :selectors selectors) + (assoc :namespaces namespaces))))) + +(defn run-tests + "Run tests. + + If :watch is true, returns a value that can be passed to `hawk.core/stop!` to stop watching. + If :watch is false, returns a test summary {:fail :error }. + + Takes a single map of keyword arguments, or as keyword args. + + Takes a vector of test selectors as :selectors, basically the args to `lein test`, + except file arguments must be strings: + eg., (run-tests :selectors '[my.ns \"my/file.clj\" :disable :only foo/bar :integration]) + <=> + lein test my.ns my/file.clj :disable :only foo/bar :integration + + Available options: + :watch If true, continuously watch and reload (loaded) namespaces in + :watch-directories, and run tests in :test-dirs when needed. + Default: false + :selectors A vector of test selectors. + :test-matcher The regex used to select test namespaces. A string can also be provided which will be coerce via `re-pattern`. + :parallel? Run tests parallel (default off) + :capture-output? Display logs even if tests pass (option for Eftest test runner) + :report Reporting function, eg., [:pretty {:type :junit :output-to \"target/junit.xml\"}] + :filter Function to filter the test vars + :on-start Function to be called before running tests (after reloading namespaces) + :on-end Function to be called after running tests + :cloverage True to activate cloverage + :cloverage-opts Cloverage options + :notify-command String or vector describing a command to run after tests + :watch-directories Vector of paths to refresh if loaded. Relative to project root. + Only meaningful when :watch is true. + Defaults to all non-jar classpath entries. + :test-dirs Vector of paths restricting the tests that will be matched. Relative to project root. + Default: nil (no restrictions). + :headless If true, set -Djava.awt.headless=true. Default: false. Only meaningful when :watch is true. + :enter-key-listener If true, refresh tracker on enter key. Default: false. Only meaningful when :watch is true." + [& args] + ;; based on https://github.com/metosin/bat-test/blob/636a9964b02d4b4e5665fa83fea799fcc12e6f5f/src/leiningen/bat_test.clj#L36 + (let [opts (-default-opts args)] + (cond + (:watch opts) (let [{:keys [watch-directories]} opts] + (when (:headless opts) + (System/setProperty "java.awt.headless" "true")) + (run-tests1 opts) + (impl/enter-key-listener opts + (fn [opts] + (reset! impl/tracker nil) + (run-tests1 opts))) + (hawk/watch! [{:paths watch-directories + :filter hawk/file? + :context (constantly 0) + :handler (fn [ctx e] + (if (and (re-matches #"^[^.].*[.]cljc?$" (.getName (:file e))) + (< (+ ctx 1000) (System/currentTimeMillis))) + (do + (try + (println) + (run-tests1 opts) + (catch Exception e + (println e))) + (System/currentTimeMillis)) + ctx))}])) + :else (run-tests1 opts)))) + +(defn tests-failed? [{:keys [fail error]}] + (pos? (+ fail error))) + +(defn- -wrap-run-tests1 [args] + (let [signal-failure? (-> args + -default-opts + run-tests1 + tests-failed?)] + (if (:system-exit args) + (System/exit (if signal-failure? 1 0)) + ;; Clojure -X automatically exits cleanly, so we can make this + ;; more generalizable by avoiding System/exit. + ;; https://clojure.atlassian.net/browse/TDEPS-198 + ;; see also: https://github.com/cognitect-labs/test-runner/commit/23771f4bee77d4e938b9bfa89031d2822b4e2622 + (when signal-failure? + (throw (ex-info "Test failures or errors occurred." {})))))) + +(defn massage-cli-args + "Reduce the escaping necessary on the command line." + [args] + (-> args + (set/rename-keys {:capture-output :capture-output? + :parallel :parallel?}))) + +(defn- add-exec-args-file [{:keys [exec-args-file] :as opts}] + (let [maybe-exec-args-file-form (some-> exec-args-file slurp read-string)] + (if (map? maybe-exec-args-file-form) + ;; existing opts take precedence over :exec-args-file + (into (massage-cli-args maybe-exec-args-file-form) + (dissoc opts :exec-args-file)) + opts))) + +(defn test + "Run tests with some useful defaults for REPL usage. + + If :watch is false and tests fail or error, throws an exception (see also `:system-exit` option). + + Supports the same options of `metosin.bat-test.cli/run-tests`, except + for the following differences: + - :parallel Alias for `:parallel?` + - :capture-output Alias for `:capture-output?` + - :system-exit If true and :watch is not true, exit via System/exit (code 0 if tests pass, 1 on failure). + If not true and :watch is not true, throw an exception if tests fail. + Default: nil + - :exec-args-file A file path containing default arguments for this function. Useful to share your default + config between REPL and CLI." + [args] + (let [args (-> (massage-cli-args args) + add-exec-args-file)] + (if (:watch args) + (run-tests args) + (-wrap-run-tests1 args)))) + +(def -default-exec-args + {:headless true + :enter-key-listener true + :system-exit true}) + +;; clojure -X:test +(defn exec + "Run tests via Clojure CLI's -X flag. + + Setup: + eg., deps.edn: {:aliases {:test {:exec-fn metosin.bat-test.cli/exec}}} + $ clojure -X:test :test-dirs '[\"submodule\"]' + + Supports the same options of `metosin.bat-test.cli/test`, except + for the following differences: + - :headless Defaults to true. + - :enter-key-listener Defaults to true. + - :system-exit Defaults to true for cleaner output." + [args] + (-> -default-exec-args + (into args) + test)) + +;; simulate :exec-args https://clojure.org/reference/deps_and_cli#_execute_a_function +(defn- assoc-exec-arg [m [k v]] + ((if (vector? k) assoc-in assoc) m k v)) + +(defn split-selectors-and-cli-args [default-opts-map args] + (let [[args flat-opts] (split-with (complement #{":"}) args) + flat-opts (->> flat-opts + ;; remove ":" + next + (map read-string)) + _ (assert (even? (count flat-opts)) (str "Uneven arguments to bat-test command line interface: " + (pr-str flat-opts))) + opts (not-empty + (reduce assoc-exec-arg + (or default-opts-map {}) + (partition 2 flat-opts)))] + [args opts])) + +(defn add-lein-style-selectors-to-opts [selectors opts] + (let [;; https://github.com/technomancy/leiningen/blob/4d8ee78018158c05d69b250e7851f9d7c3a44fac/src/leiningen/test.clj#L183 + selectors (->> selectors (map -convert-to-ns) (map read-string)) + ;; + [nses1 selectors1] (separate-selectors-from-nses (:selectors opts)) + [nses2 selectors2] (separate-selectors-from-nses selectors) + selectors (vec (concat nses1 nses2 selectors1 selectors2))] + (cond-> opts + (seq selectors) (assoc :selectors selectors)))) + +;; clojure -A:test -m metosin.bat-test.cli {(:exec-args-file )? }? * (selector-kw selector-non-kw-arg*)* (: )? +(defn -main [& args] + (let [[default-opts-map args] (let [maybe-default-opts-map (try (read-string (first args)) + ;; could be ":" + (catch Exception _))] + (if (map? maybe-default-opts-map) + [(add-exec-args-file maybe-default-opts-map) (next args)] + [{} args])) + [selectors opts] (split-selectors-and-cli-args + default-opts-map + args) + opts (add-lein-style-selectors-to-opts selectors opts)] + (exec (into default-opts-map opts)))) diff --git a/src/metosin/bat_test/impl.clj b/src/metosin/bat_test/impl.clj index d81d080..ad9558e 100644 --- a/src/metosin/bat_test/impl.clj +++ b/src/metosin/bat_test/impl.clj @@ -1,12 +1,15 @@ (ns metosin.bat-test.impl (:require [clojure.tools.namespace.dir :as dir] + [clojure.tools.namespace.find :as find] [clojure.tools.namespace.track :as track] [clojure.tools.namespace.reload :as reload] + [clojure.java.io :as io] [clojure.java.shell :refer [sh]] [clojure.string :as string] [eftest.runner :as runner] [eftest.report :as report] - [metosin.bat-test.util :as util])) + [metosin.bat-test.util :as util]) + (:import [java.util.regex Pattern])) (def tracker (atom nil)) (def running (atom false)) @@ -22,14 +25,16 @@ (recur (read-in)))))) (defn enter-key-listener - [opts] - (util/dbug "Listening to the enter key\n") - (on-keypress - java.awt.event.KeyEvent/VK_ENTER - (fn [_] - (when-not @running - (util/info "Running all tests\n") - (run-all opts))))) + ([opts] (enter-key-listener opts #'run-all)) + ([opts run-all] + (when (:enter-key-listener opts) + (util/dbug "Listening to the enter key\n") + (on-keypress + java.awt.event.KeyEvent/VK_ENTER + (fn [_] + (when-not @running + (util/info "Running all tests\n") + (run-all opts))))))) (defn load-only-loaded-and-test-ns [{:keys [::track/load] :as tracker} test-matcher] @@ -95,42 +100,88 @@ :else (constantly true))) (defn namespaces-match [selected-namespaces nss] - (if (seq selected-namespaces) - (filter (set selected-namespaces) nss) - nss)) + (cond->> nss + (seq selected-namespaces) (filter (set selected-namespaces)))) (defn selectors-match [selectors vars] - (if (seq selectors) + (cond->> vars + (seq selectors) (filter (fn [var] (some (fn [[selector args]] - (apply (eval (if (vector? selector) - (second selector) - selector)) + (apply (eval (cond-> selector + (vector? selector) second)) (merge (-> var meta :ns meta) (assoc (meta var) :leiningen.test/var var)) args)) - selectors)) - vars) - vars)) + selectors))))) (defn maybe-run-cloverage [run-tests opts changed-ns test-namespaces] (if (:cloverage opts) - (do (require 'metosin.bat-test.cloverage) - - ((resolve 'metosin.bat-test.cloverage/wrap-cloverage) - ;; Don't instrument -test namespaces - (remove #(contains? (set test-namespaces) %) changed-ns) - (:cloverage-opts opts) - run-tests)) + ((requiring-resolve 'metosin.bat-test.cloverage/wrap-cloverage) + ;; Don't instrument -test namespaces + (remove #(contains? (set test-namespaces) %) changed-ns) + (:cloverage-opts opts) + run-tests) (run-tests))) +(defn test-matcher-only-in-directories [test-matcher + test-dirs] + {:pre [(or (instance? java.util.regex.Pattern test-matcher) + (string? test-matcher)) + (or (nil? test-dirs) + (string? test-dirs) + (coll? test-dirs))] + :post [(instance? java.util.regex.Pattern %)]} + (let [test-dirs (cond-> test-dirs + (string? test-dirs) vector) + re-and (fn [rs] + {:pre [(every? #(instance? java.util.regex.Pattern %) rs)] + :post [(instance? java.util.regex.Pattern %)]} + (case (count rs) + 0 #".*" + 1 (first rs) + ;; lookaheads + ;; https://www.ocpsoft.org/tutorials/regular-expressions/and-in-regex/ + ;; https://stackoverflow.com/a/470602 + (re-pattern (str "^" + (apply str (map #(str "(?=" % ")") rs)) + ".*" ;; I think lookaheads don't consume anything..or something. see SO answer. + "$"))))] + (re-and + (remove nil? + [(cond-> test-matcher + (string? test-matcher) re-pattern) + ;; if test-dirs is nil, don't change test-matcher + ;; otherwise, it's a seqable of zero or more directories that a test must + ;; be in to run. + (when (some? test-dirs) + ;; both restricts the tests being run and stops + ;; bat-test.impl/load-only-loaded-and-test-ns from loading + ;; all test namespaces if only a subset are specified by + ;; `test-dirs`. + (if-some [matching-ns-res (->> test-dirs + (map io/file) + find/find-namespaces + (map name) + ;; TODO may want to further filter these files via a pattern + ;(filter #(string/includes? % "test")) + (map #(str "^" (Pattern/compile % Pattern/LITERAL) "$")) + seq)] + (->> matching-ns-res + (string/join "|") + re-pattern) + ;; no namespaces in these directories, match nothing + ;; https://stackoverflow.com/a/2930209 + ;; negative lookahead that matches anything. never matches. + #"(?!.*)"))])))) + (defn reload-and-test - [tracker {:keys [on-start test-matcher parallel? report selectors namespaces] + [tracker {:keys [on-start test-matcher parallel? report selectors namespaces test-dirs] :or {report :progress test-matcher #".*test"} :as opts}] - (let [parallel? (true? parallel?) - + (let [test-matcher (test-matcher-only-in-directories test-matcher test-dirs) + parallel? (true? parallel?) changed-ns (::track/load @tracker) test-namespaces (->> changed-ns (filter #(re-matches test-matcher (name %))) @@ -163,7 +214,7 @@ (selectors-match selectors) (filter (resolve-hook (:filter opts)))) (-> opts - (dissoc :parallel? :on-start :on-end :filter :test-matcher :selectors) + (dissoc :parallel? :on-start :on-end :filter :test-matcher :selectors :test-dirs) (assoc :multithread? parallel? :report (resolve-reporter report))))) opts @@ -191,16 +242,14 @@ (util/warn "Exception: %s\n" (.getMessage e))))))) ) (defn run [{:keys [on-end watch-directories notify-command] :as opts}] + ;(prn `run opts) (try (reset! running true) (swap! tracker (fn [tracker] (util/dbug "Scan directories: %s\n" (pr-str watch-directories)) (dir/scan-dirs (or tracker (track/tracker)) watch-directories))) - (let [summary (reload-and-test tracker opts)] - (run-notify-command notify-command summary) - summary) (finally ((resolve-hook on-end)) diff --git a/test-projects/cli-fail/bat-test.edn b/test-projects/cli-fail/bat-test.edn new file mode 100644 index 0000000..fde77da --- /dev/null +++ b/test-projects/cli-fail/bat-test.edn @@ -0,0 +1,2 @@ +{:test-selectors-form-file "test-selectors.clj" + :test-dirs ["test-fail" "test-pass"]} diff --git a/test-projects/cli-fail/deps.edn b/test-projects/cli-fail/deps.edn new file mode 100644 index 0000000..56cb478 --- /dev/null +++ b/test-projects/cli-fail/deps.edn @@ -0,0 +1,8 @@ +{:aliases {:test {:extra-paths ["test-pass" + "test-fail"] + :extra-deps {metosin/bat-test {:local/root "../.." + :deps/manifest :deps}} + :exec-fn metosin.bat-test.cli/exec + :exec-args {:test-selectors-form-file "test-selectors.clj" + :test-dirs ["test-fail" "test-pass"]} + :main-opts ["-m" "metosin.bat-test.cli" "{:exec-args-file \"bat-test.edn\"}"]}}} diff --git a/test-projects/cli-fail/project.clj b/test-projects/cli-fail/project.clj new file mode 100644 index 0000000..132fb10 --- /dev/null +++ b/test-projects/cli-fail/project.clj @@ -0,0 +1,13 @@ +(def bat-test-version + (or (System/getenv "INSTALLED_BAT_TEST_VERSION") + (:out ((requiring-resolve 'clojure.java.shell/sh) + "clojure" "-T:build" "install" + :dir "../..")))) +(defproject metosin-test/cli-fail "1.0.0-SNAPSHOT" + :test-paths ["test-pass" + "test-fail"] + :dependencies [[org.clojure/clojure "1.10.3"]] + :test-selectors ~(-> "test-selectors.clj" slurp read-string) + :bat-test {:test-matcher #".*"} + :plugins [[metosin/bat-test ~bat-test-version] + [lein-pprint "1.3.2"]]) diff --git a/test-projects/cli-fail/test-fail/cli_fail/test_fail.clj b/test-projects/cli-fail/test-fail/cli_fail/test_fail.clj new file mode 100644 index 0000000..26341b7 --- /dev/null +++ b/test-projects/cli-fail/test-fail/cli_fail/test_fail.clj @@ -0,0 +1,6 @@ +(ns cli-fail.test-fail + (:require [clojure.test :as t])) + +(t/deftest ^:fail i-fail + (t/is nil + (str "cli-fail.test-pass was" (when-not (find-ns 'cli-fail.test-pass) " not") " loaded."))) diff --git a/test-projects/cli-fail/test-pass/cli_fail/test_pass.clj b/test-projects/cli-fail/test-pass/cli_fail/test_pass.clj new file mode 100644 index 0000000..f31de86 --- /dev/null +++ b/test-projects/cli-fail/test-pass/cli_fail/test_pass.clj @@ -0,0 +1,5 @@ +(ns cli-fail.test-pass + (:require [clojure.test :as t])) + +(t/deftest ^:pass i-pass + (t/is true)) diff --git a/test-projects/cli-fail/test-selectors.clj b/test-projects/cli-fail/test-selectors.clj new file mode 100644 index 0000000..88c6cb4 --- /dev/null +++ b/test-projects/cli-fail/test-selectors.clj @@ -0,0 +1,2 @@ +{:just-passing :pass + :just-failing :fail} diff --git a/test-projects/cli-no-tests/bat-test.edn b/test-projects/cli-no-tests/bat-test.edn new file mode 100644 index 0000000..c809da4 --- /dev/null +++ b/test-projects/cli-no-tests/bat-test.edn @@ -0,0 +1 @@ +{:test-dirs ["test"]} diff --git a/test-projects/cli-no-tests/deps.edn b/test-projects/cli-no-tests/deps.edn new file mode 100644 index 0000000..ca15c09 --- /dev/null +++ b/test-projects/cli-no-tests/deps.edn @@ -0,0 +1,7 @@ +;; note: test dir doesn't exist. this is to test :test-dirs. +{:aliases {:test {:extra-paths ["test"] + :extra-deps {metosin/bat-test {:local/root "../.." + :deps/manifest :deps}} + :exec-fn metosin.bat-test.cli/exec + :exec-args {:test-dirs ["test"]} + :main-opts ["-m" "metosin.bat-test.cli" "{:exec-args-file \"bat-test.edn\"}"]}}} diff --git a/test-projects/cli-no-tests/project.clj b/test-projects/cli-no-tests/project.clj new file mode 100644 index 0000000..029dca3 --- /dev/null +++ b/test-projects/cli-no-tests/project.clj @@ -0,0 +1,9 @@ +(def bat-test-version + (or (System/getenv "INSTALLED_BAT_TEST_VERSION") + (:out ((requiring-resolve 'clojure.java.shell/sh) + "clojure" "-T:build" "install" + :dir "../..")))) +(defproject metosin-test/cli-no-tests "1.0.0-SNAPSHOT" + :dependencies [[org.clojure/clojure "1.10.3"]] + :plugins [[metosin/bat-test ~bat-test-version] + [lein-pprint "1.3.2"]]) diff --git a/test/metosin/bat_test/cli_test.clj b/test/metosin/bat_test/cli_test.clj new file mode 100644 index 0000000..54ecfa2 --- /dev/null +++ b/test/metosin/bat_test/cli_test.clj @@ -0,0 +1,170 @@ +(ns ^:eftest/synchronized metosin.bat-test.cli-test + "doseq is used to parallelize tests and install bat-test jar. + Always wrap assertions about the shell in a doseq for reliability." + (:refer-clojure :exclude [doseq]) + (:require [clojure.test :refer [deftest is testing]] + [clojure.string :as str] + [metosin.bat-test.cli :as cli] + [clojure.java.shell :as sh])) + +(defn install-bat-test-jar [] + (:out (sh/sh "clojure" "-T:build" "install"))) + +(def ^:dynamic *bat-test-jar-version* nil) + +;; compile-time flag +(def parallel? true) + +(defmacro doseq + "Parallel doseq via pmap. + + If parallel? is false, like clojure.core/doseq. + + argv must be pure" + [argv & body] + (if parallel? + `(binding [;; install jar synchronously + *bat-test-jar-version* (install-bat-test-jar)] + (dorun + (pmap + (fn [f#] (f#)) + (doto (for ~argv + ;; `let` to avoid recur target + (fn [] (let [res# (do ~@body)] res#))) + (-> seq assert))))) + `(clojure.core/doseq ~argv ~@body))) + +(defn prep-exec-cmds + ([cmd] (prep-exec-cmds #{:cli :lein} cmd)) + ([impls cmd] + {:post [(seq %)]} + (cond-> [] + (:cli impls) (conj (into ["clojure" "-X:test"] cmd) + (into ["clojure" "-M:test" ":"] cmd)) + (:lein impls) (conj (into ["lein" "bat-test" ":"] cmd))))) + +(defn prep-main-cmds + ([cmd] (prep-main-cmds #{:cli :lein} cmd)) + ([impls cmd] + {:post [(seq %)]} + (cond-> [] + (:cli impls) (conj (into ["clojure" "-M:test"] cmd)) + (:lein impls) (conj (into ["lein" "bat-test"] cmd))))) + +(defn sh-in-dir [dir cmd] + (apply sh/sh (concat cmd [:dir dir + :env (-> (into {} (System/getenv)) + (assoc ;; https://github.com/technomancy/leiningen/issues/2611 + "LEIN_JVM_OPTS" "" + ;; jvm 17 support for fipp https://github.com/brandonbloom/fipp/issues/60 + "LEIN_USE_BOOTCLASSPATH" "no") + (cond-> + *bat-test-jar-version* (assoc "INSTALLED_BAT_TEST_VERSION" *bat-test-jar-version*)))]))) + +(def sh-in-cli-fail (partial #'sh-in-dir "test-projects/cli-fail")) + +(deftest cli-fail-test-all-tests + ;; different ways of running all tests + (doseq [cmd (-> [] + (into (prep-exec-cmds [])) + (into (prep-exec-cmds #{:cli} [":system-exit" "false"])) + (into (prep-exec-cmds [":test-dirs" "[\"test-pass\" \"test-fail\"]"])) + (into (prep-exec-cmds [":selectors" "[cli-fail.test-fail cli-fail.test-pass]"])) + ;; selectors from test-selectors.clj + (into (prep-exec-cmds [":selectors" "[:just-passing :just-failing]"])) + (into (prep-exec-cmds [":selectors" "[:just-failing :only cli-fail.test-pass/i-pass]"])) + (into (prep-main-cmds [":just-passing" ":just-failing"])) + (into (prep-main-cmds [":just-passing" ":" ":selectors" "[:just-failing]"])) + ;; :only with 2 args + (into (prep-exec-cmds [":selectors" "[:only cli-fail.test-fail/i-fail cli-fail.test-pass]"])) + ;; combine :only and :selectors + (into (prep-main-cmds [":only" "cli-fail.test-fail/i-fail" ":just-passing"])) + (into (prep-main-cmds [":only" "cli-fail.test-fail/i-fail" ":" ":selectors" "[:just-passing]"])) + (into (prep-main-cmds [":just-passing" ":" ":selectors" "[:only cli-fail.test-fail/i-fail]"])))] + (testing (pr-str cmd) + (let [{:keys [exit out] :as res} (sh-in-cli-fail cmd)] + (is (= 1 exit) (pr-str res)) + (is (str/includes? out "Ran 2 tests") (pr-str res)) + (is (str/includes? out "2 assertions, 1 failure, 0 errors") (pr-str res)))))) + +(deftest cli-fail-test-just-fail + ;; different ways of just running `cli-fail.test-fail/i-fail` + (doseq [cmd (-> [] + (into (prep-exec-cmds [":test-dirs" "\"test-fail\""])) + (into (prep-exec-cmds [":test-dirs" "[\"test-fail\"]"])) + (into (prep-exec-cmds [":selectors" "[cli-fail.test-fail]"])) + (into (prep-exec-cmds [":selectors" "[:just-failing]"])) + (into (prep-exec-cmds [":selectors" "[:only cli-fail.test-fail/i-fail]"])) + (into (prep-exec-cmds [":test-matcher" "\"cli-fail.test-fail\""])))] + (testing (pr-str cmd) + (let [{:keys [exit out] :as res} (sh-in-cli-fail cmd)] + (is (= 1 exit) (pr-str res)) + (is (str/includes? out "Ran 1 tests") (pr-str res)) + (is (str/includes? out "1 assertion, 1 failure, 0 errors") (pr-str res)))))) + +(deftest cli-fail-test-just-pass + ;; different ways of just running `cli-fail.test-pass/i-pass` + (doseq [cmd (-> [] + (into (prep-exec-cmds [":test-dirs" "\"test-pass\""])) + (into (prep-exec-cmds [":test-dirs" "[\"test-pass\"]"])) + (into (prep-exec-cmds [":selectors" "[cli-fail.test-pass]"])) + (into (prep-exec-cmds [":selectors" "[:just-passing]"])) + (into (prep-exec-cmds [":selectors" "[:only cli-fail.test-pass/i-pass]"])) + (into (prep-exec-cmds [":test-matcher" "\"cli-fail.test-pass\""])) + (into (prep-exec-cmds [":test-matcher" "\".*-pass\""])))] + (testing (pr-str cmd) + (let [{:keys [exit out] :as res} (sh-in-cli-fail cmd)] + (is (= 0 exit) (pr-str res)) + (is (str/includes? out "Ran 1 tests") (pr-str res)) + (is (str/includes? out "1 assertion, 0 failures, 0 errors") (pr-str res)))))) + +(deftest cli-fail-test-no-tests + ;; different ways of running no tests + (doseq [cmd (-> [] + ;; namespace before :only a conjunction + (into (prep-exec-cmds [":selectors" "[cli-fail.test-pass :only cli-fail.test-fail/i-fail]"])) + ;; same, but via main + (into (prep-main-cmds ["cli-fail.test-pass" ":only" "cli-fail.test-fail/i-fail"])))] + (testing (pr-str cmd) + (let [{:keys [exit out] :as res} (sh-in-cli-fail cmd)] + (is (= 0 exit) (pr-str res)) + (is (str/includes? out "No tests found.") (pr-str res)))))) + +(deftest cli-fail-test-clojure-test-reporter + ;; clojure.test/report reporter + (doseq [cmd (-> [] + (into (prep-exec-cmds [":report" "[clojure.test/report]"])))] + (testing (pr-str cmd) + (let [{:keys [exit out] :as res} (sh-in-cli-fail cmd)] + (is (= 1 exit) (pr-str res)) + (is (str/includes? out "Testing cli-fail.test-fail") (pr-str res)) + (is (str/includes? out "FAIL in (i-fail)") (pr-str res)) + (is (str/includes? out "Testing cli-fail.test-pass") (pr-str res)) + (is (str/includes? out "Ran 2 tests containing 2 assertions") (pr-str res)) + (is (str/includes? out "1 failures, 0 errors") (pr-str res)))))) + +(deftest cli-fail-test-test-dirs-loading + ;; :test-dirs influences which namespaces are initially loaded + (doseq [{:keys [cmds pass-loaded?] :as test-case} [{:desc "Load all namespaces" + :pass-loaded? true + :cmds (prep-exec-cmds [":report" "[clojure.test/report]"])} + {:desc "Don't load cli-fail.test-pass" + :pass-loaded? false + :cmds (prep-exec-cmds [":report" "[clojure.test/report]" ":test-dirs" "[\"test-fail\"]"])}] + :let [_ (assert (seq cmds))] + cmd cmds] + (testing (pr-str test-case) + (let [{:keys [exit out] :as res} (sh-in-cli-fail cmd)] + (is (= 1 exit) (pr-str res)) + (is (str/includes? out (str "cli-fail.test-pass was" (when-not pass-loaded? " not") " loaded.")) (pr-str res)) + (is (str/includes? out (format "Ran %s tests" (if pass-loaded? 2 1))) (pr-str res)) + (is (str/includes? out "1 failures, 0 errors") (pr-str res)))))) + +(deftest cli-no-tests-test + ;; check that there are no tests + (doseq [cmd (-> [] + (into (prep-exec-cmds [])))] + (testing (pr-str cmd) + (let [{:keys [exit out] :as res} (sh-in-dir "test-projects/cli-no-tests" cmd)] + (is (= 0 exit) (pr-str res)) + (is (str/includes? out "No tests found") (pr-str res)))))) diff --git a/version b/version new file mode 100644 index 0000000..6f2743d --- /dev/null +++ b/version @@ -0,0 +1 @@ +0.4.4