Skip to content

Commit e86eda7

Browse files
committed
Add integration tests support
1 parent 9962cf7 commit e86eda7

File tree

14 files changed

+419
-13
lines changed

14 files changed

+419
-13
lines changed

bb.edn

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
{:paths ["scripts"]
1+
{:paths ["scripts" "integration-test"]
22
:deps {borkdude/gh-release-artifact {:git/url "https://github.com/borkdude/gh-release-artifact"
3-
:git/sha "4a9a74f0e50e897c45df8cc70684360eb30fce80"}}
3+
:git/sha "4a9a74f0e50e897c45df8cc70684360eb30fce80"}
4+
nubank/matcher-combinators {:mvn/version "3.9.1"}
5+
com.github.clojure-lsp/lsp4clj {:mvn/version "1.13.1"}}
46
:min-bb-version "0.8.156"
57
:tasks {debug-cli make/debug-cli
68
debug-graal make/debug-graal
@@ -10,4 +12,5 @@
1012
test make/unit-test
1113

1214
tag make/tag
13-
get-last-changelog-entry make/get-last-changelog-entry}}
15+
get-last-changelog-entry make/get-last-changelog-entry
16+
integration-test entrypoint/run-all}}

deps.edn

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
nrepl/nrepl {:mvn/version "1.3.0"}}}
1919
:test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
2020
org.clojure/test.check {:mvn/version "1.1.1"}
21-
nubank/matcher-combinators {:mvn/version "3.9.1"}}
21+
nubank/matcher-combinators {:mvn/version "3.9.1"}
22+
http-kit/http-kit {:mvn/version "2.8.0"}}
2223
:jvm-opts ["-XX:-OmitStackTraceInFastThrow"]
2324
:extra-paths ["test"]
2425
:main-opts ["-m" "kaocha.runner"]}

integration-test/entrypoint.clj

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
(ns entrypoint
2+
(:require
3+
[clojure.test :as t]))
4+
5+
(def namespaces
6+
'[integration.initialize-test])
7+
8+
(defn timeout [timeout-ms callback]
9+
(let [fut (future (callback))
10+
ret (deref fut timeout-ms :timed-out)]
11+
(when (= ret :timed-out)
12+
(future-cancel fut))
13+
ret))
14+
15+
(declare ^:dynamic original-report)
16+
17+
(defn log-tail-report [data]
18+
(original-report data)
19+
(when (contains? #{:fail :error} (:type data))
20+
(println "Integration tests failed!")))
21+
22+
(defmacro with-log-tail-report
23+
"Execute body with modified test reporting functions that prints log tail on failure."
24+
[& body]
25+
`(binding [original-report t/report
26+
t/report log-tail-report]
27+
~@body))
28+
29+
#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]}
30+
(defn run-all [& args]
31+
(when-not (first args)
32+
(println "First arg must be path to eca binary")
33+
(System/exit 0))
34+
35+
(apply require namespaces)
36+
37+
(let [timeout-minutes (if (re-find #"(?i)win|mac" (System/getProperty "os.name"))
38+
10 ;; win and mac ci runs take longer
39+
5)
40+
test-results (timeout (* timeout-minutes 60 1000)
41+
#(with-log-tail-report
42+
(apply t/run-tests namespaces)))]
43+
44+
(when (= test-results :timed-out)
45+
(println)
46+
(println (format "Timeout after %d minutes running integration tests!" timeout-minutes))
47+
(System/exit 1))
48+
49+
(let [{:keys [fail error]} test-results]
50+
(System/exit (+ fail error)))))
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
(ns integration.client
2+
(:require
3+
[clojure.core.async :as async]
4+
[clojure.string :as string]
5+
[lsp4clj.coercer :as coercer]
6+
[lsp4clj.io-chan :as lsp.io-chan]
7+
[lsp4clj.lsp.requests :as lsp.requests]
8+
[lsp4clj.lsp.responses :as lsp.responses]
9+
[lsp4clj.protocols.endpoint :as protocols.endpoint])
10+
(:import
11+
[java.time LocalDateTime]
12+
[java.time.format DateTimeFormatter]))
13+
14+
(def ^:private ESC \u001b)
15+
16+
(def ^:private colors
17+
{:black (str ESC "[30m")
18+
:red-bg (str ESC "[41m")
19+
:red (str ESC "[31m")
20+
:green (str ESC "[32m")
21+
:yellow (str ESC "[33m")
22+
:blue (str ESC "[34m")
23+
:magenta (str ESC "[35m")
24+
:cyan (str ESC "[36m")
25+
:white (str ESC "[37m")
26+
:underline (str ESC "[4m")
27+
:reset (str ESC "[m")})
28+
29+
(defn ^:private colored [color string]
30+
(str (get colors color) string (:reset colors)))
31+
32+
(def ^:private ld-formatter DateTimeFormatter/ISO_LOCAL_DATE_TIME)
33+
(defn ^:private local-datetime-str [] (.format ld-formatter (LocalDateTime/now)))
34+
35+
(defprotocol IMockClient
36+
(mock-response [this method body]))
37+
38+
(defn ^:private format-log
39+
[{:keys [client-id]} color msg params]
40+
(string/join " "
41+
[(local-datetime-str)
42+
(colored color (str "Client " client-id " " msg))
43+
(colored :yellow params)]))
44+
45+
(defn ^:private receive-message
46+
[client context message]
47+
(println message)
48+
(let [message-type (coercer/input-message-type message)]
49+
(try
50+
(let [response
51+
(case message-type
52+
(:parse-error :invalid-request)
53+
(protocols.endpoint/log client :red "Error reading message" message-type)
54+
:request
55+
(protocols.endpoint/receive-request client context message)
56+
(:response.result :response.error)
57+
(protocols.endpoint/receive-response client message)
58+
:notification
59+
(protocols.endpoint/receive-notification client context message))]
60+
;; Ensure client only responds to requests
61+
(when (identical? :request message-type)
62+
response))
63+
(catch Throwable e
64+
(protocols.endpoint/log client :red "Error receiving:" e)
65+
(throw e)))))
66+
67+
(defonce client-id (atom 0))
68+
69+
(defrecord Client [client-id
70+
input output
71+
log-ch
72+
join
73+
request-id sent-requests
74+
received-requests received-notifications
75+
mock-responses]
76+
protocols.endpoint/IEndpoint
77+
(start [this context]
78+
(protocols.endpoint/log this :white "lifecycle:" "starting")
79+
(let [pipeline (async/pipeline-blocking
80+
1 ;; no parallelism preserves server message order
81+
output
82+
;; TODO: return error until initialize request is received? https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize
83+
;; `keep` means we do not reply to responses and notifications
84+
(keep #(receive-message this context %))
85+
input)]
86+
(async/go
87+
;; wait for pipeline to close, indicating input closed
88+
(async/<! pipeline)
89+
(deliver join :done)))
90+
;; invokers can deref the return of `start` to stay alive until server is
91+
;; shut down
92+
join)
93+
(shutdown [this]
94+
(protocols.endpoint/log this :white "lifecycle:" "shutting down")
95+
;; closing input will drain pipeline, then close output, then close
96+
;; pipeline
97+
(async/close! input)
98+
(if (= :done (deref join 10e3 :timeout))
99+
(protocols.endpoint/log this :white "lifecycle:" "shutdown")
100+
(protocols.endpoint/log this :red "lifecycle:" "shutdown timed out"))
101+
(async/close! log-ch))
102+
(log [this msg params]
103+
(protocols.endpoint/log this :white msg params))
104+
(log [this color msg params]
105+
(async/put! log-ch (format-log this color msg params)))
106+
(send-request [this method body]
107+
(let [req (lsp.requests/request (swap! request-id inc) method body)
108+
p (promise)
109+
start-ns (System/nanoTime)]
110+
(protocols.endpoint/log this :cyan "sending request:" req)
111+
;; Important: record request before sending it, so it is sure to be
112+
;; available during receive-response.
113+
(swap! sent-requests assoc (:id req) {:request p
114+
:start-ns start-ns})
115+
(async/>!! output req)
116+
p))
117+
(send-notification [this method body]
118+
(let [notif (lsp.requests/notification method body)]
119+
(protocols.endpoint/log this :blue "sending notification:" notif)
120+
(async/>!! output notif)))
121+
(receive-response [this {:keys [id] :as resp}]
122+
(if-let [{:keys [request start-ns]} (get @sent-requests id)]
123+
(let [ms (float (/ (- (System/nanoTime) start-ns) 1000000))]
124+
(protocols.endpoint/log this :green (format "received response (%.0fms):" ms) resp)
125+
(swap! sent-requests dissoc id)
126+
(deliver request (if (:error resp)
127+
resp
128+
(:result resp))))
129+
(protocols.endpoint/log this :red "received response for unmatched request:" resp)))
130+
(receive-request [this _ {:keys [id method] :as req}]
131+
(protocols.endpoint/log this :magenta "received request:" req)
132+
(swap! received-requests conj req)
133+
(when-let [mock-resp (get @mock-responses (keyword method))]
134+
(let [resp (lsp.responses/response id mock-resp)]
135+
(protocols.endpoint/log this :magenta "sending mock response:" resp)
136+
resp)))
137+
(receive-notification [this _ notif]
138+
(protocols.endpoint/log this :blue "received notification:" notif)
139+
(swap! received-notifications conj notif))
140+
IMockClient
141+
(mock-response [_this method body]
142+
(swap! mock-responses assoc method body)))
143+
144+
(def start protocols.endpoint/start)
145+
(def shutdown protocols.endpoint/shutdown)
146+
(def send-notification protocols.endpoint/send-notification)
147+
148+
(defn client [server-in server-out]
149+
(map->Client
150+
{:client-id (swap! client-id inc)
151+
:input (lsp.io-chan/input-stream->input-chan server-out {:keyword-function keyword})
152+
:output (lsp.io-chan/output-stream->output-chan server-in)
153+
:log-ch (async/chan (async/sliding-buffer 20))
154+
:join (promise)
155+
:request-id (atom 0)
156+
:sent-requests (atom {})
157+
:received-requests (atom [])
158+
:received-notifications (atom [])
159+
:mock-responses (atom {})}))
160+
161+
(defn ^:private keyname [key] (str (namespace key) "/" (name key)))
162+
163+
(defn ^:private await-first-and-remove! [client pred coll-type]
164+
(let [coll* (coll-type client)]
165+
(loop [tries 0]
166+
(if (< tries 20)
167+
(if-let [elem (first (filter pred @coll*))]
168+
(do
169+
(swap! coll* #(->> % (remove #{elem}) vec))
170+
elem)
171+
(do
172+
(Thread/sleep 500)
173+
(recur (inc tries))))
174+
(do
175+
(protocols.endpoint/log client :red "timeout waiting:" coll-type)
176+
(throw (ex-info "timeout waiting for client to receive req/notif" {:coll-type coll-type})))))))
177+
178+
(defn await-server-notification [client method]
179+
(let [method-str (keyname method)
180+
notification (await-first-and-remove! client
181+
#(= method-str (:method %))
182+
:received-notifications)]
183+
(:params notification)))
184+
185+
(defn await-server-request [client method]
186+
(let [method-str (keyname method)
187+
msg (await-first-and-remove! client
188+
#(= method-str (:method %))
189+
:received-requests)]
190+
(:params msg)))
191+
192+
(defn request-and-await-server-response! [client method body]
193+
(let [timeout-ms 180000
194+
resp (deref (protocols.endpoint/send-request client method body)
195+
timeout-ms
196+
::timeout)]
197+
(if (= ::timeout resp)
198+
(do
199+
(protocols.endpoint/log client :red "timeout waiting for server response to client request:"
200+
{:method method :timeout-ms timeout-ms})
201+
(throw (ex-info "timeout waiting for server response to client request" {:method method :body body})))
202+
resp)))
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
(ns integration.eca
2+
(:require
3+
[babashka.process :as p]
4+
[clojure.core.async :as async]
5+
[clojure.java.io :as io]
6+
[clojure.test :refer [use-fixtures]]
7+
[integration.client :as client]))
8+
9+
(def ^:dynamic *eca-process* nil)
10+
(def ^:dynamic *mock-client* nil)
11+
12+
(defn start-server
13+
([binary]
14+
(start-server binary []))
15+
([binary args]
16+
(p/process (into [(.getCanonicalPath (io/file binary)) "server" "--log-level" "debug"] args)
17+
{:dir "integration-test/sample-test/"})))
18+
19+
(defn start-process! []
20+
(let [server (start-server (first *command-line-args*))
21+
client (client/client (:in server) (:out server))]
22+
(client/start client nil)
23+
(async/go-loop []
24+
(when-let [log (async/<! (:log-ch client))]
25+
(println log)
26+
(recur)))
27+
(alter-var-root #'*eca-process* (constantly server))
28+
(alter-var-root #'*mock-client* (constantly client))))
29+
30+
(defn clean! []
31+
(flush)
32+
(some-> *mock-client* client/shutdown)
33+
(some-> *eca-process* deref) ;; wait for shutdown of client to shutdown server
34+
(alter-var-root #'*eca-process* (constantly nil))
35+
(alter-var-root #'*mock-client* (constantly nil)))
36+
37+
(defn clean-after-test []
38+
(use-fixtures :each (fn [f] (clean!) (f)))
39+
(use-fixtures :once (fn [f] (f) (clean!))))
40+
41+
(defn notify! [[method body]]
42+
(client/send-notification *mock-client* method body))
43+
44+
(defn request! [[method body]]
45+
(client/request-and-await-server-response! *mock-client* method body))
46+
47+
(defn client-awaits-server-notification [method]
48+
(client/await-server-notification *mock-client* method))
49+
50+
(defn client-awaits-server-request [method]
51+
(client/await-server-request *mock-client* method))
52+
53+
(defn mock-response [method resp]
54+
(client/mock-response *mock-client* method resp))
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
(ns integration.fixture
2+
(:require
3+
[clojure.java.io :as io]
4+
[integration.helper :as h]))
5+
6+
(def default-init-options {:pureConfig true})
7+
8+
(defn initialize-request
9+
([]
10+
(initialize-request {:initializationOptions default-init-options}))
11+
([params]
12+
(initialize-request params [{:name "sample-test"
13+
:uri (h/file->uri (io/file h/default-root-project-path))}]))
14+
([params workspace-folders]
15+
[:initialize
16+
(merge (if workspace-folders {:workspace-folders workspace-folders} {})
17+
params)]))
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
(ns integration.helper
2+
(:require
3+
[babashka.fs :as fs]
4+
[clojure.java.io :as io]
5+
[clojure.string :as string]))
6+
7+
(def windows?
8+
"Whether is running on MS-Windows."
9+
(string/starts-with? (System/getProperty "os.name") "Windows"))
10+
11+
(def ^:dynamic *escape-uris?* false)
12+
13+
(def default-root-project-path
14+
(-> (io/file *file*)
15+
.getParentFile
16+
.getParentFile
17+
(fs/path "sample-test")
18+
fs/canonicalize
19+
str))
20+
21+
(defn escape-uri
22+
"Escapes enough URI characters for testing purposes and returns it.
23+
24+
On MS-Windows, it will also escape the drive colon, mimicking
25+
VS-Code/Calva's behavior."
26+
[uri]
27+
;; Do a better escape considering more chars
28+
(cond-> (string/replace uri "::" "%3A%3A")
29+
windows?
30+
(string/replace #"/([a-zA-Z]):/" "/$1%3A/")))
31+
32+
(defn file->uri [file]
33+
(let [uri (-> file fs/canonicalize .toUri .toString)]
34+
(if *escape-uris?*
35+
(escape-uri uri)
36+
uri)))

0 commit comments

Comments
 (0)