-
Notifications
You must be signed in to change notification settings - Fork 4
/
core.clj
357 lines (313 loc) · 11.8 KB
/
core.clj
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
(ns cuic.core
(:refer-clojure :exclude [eval])
(:require [clojure.string :as string]
[clojure.set :refer [difference]]
[cuic.impl.browser :as browser :refer [tools]]
[cuic.impl.dom-node :refer [->DOMNode maybe existing visible node-id node-id->object-id]]
[cuic.impl.exception :refer [call call-node with-stale-ignored]]
[cuic.impl.retry :as retry]
[cuic.impl.html :as html]
[cuic.impl.js-bridge :as js]
[cuic.impl.input :as input]
[cuic.impl.util :as util])
(:import (java.io Closeable)
(com.github.kklisura.cdt.services ChromeDevToolsService)
(java.awt.image BufferedImage)))
(declare default-mutation-wrapper)
(defonce ^:dynamic *browser* nil)
(defonce ^:dynamic *config*
{:typing-speed :normal
:timeout 10000
:take-screenshot-on-timeout true
:screenshot-dir "target/__screenshots__"
:mutation-wrapper #(default-mutation-wrapper %)
:snapshot-dir "test/__snapshots__"
:abort-on-failed-assertion false})
(defmacro -run-mutation
"Runs the given code block inside mutation wrapper.
** Used internally, do not touch! **"
{:style/indent 0}
[& body]
`(do (apply (:mutation-wrapper *config*) [#(do ~@body)])
nil))
(defmacro -run-node-mutation
"Shortcut for mutation of the given visible node.
** Used internally, do not touch! **"
{:style/indent 0}
[[node-binding node-expr] & body]
`(let [~node-binding (visible ~node-expr)]
(-run-mutation ~@body)))
(defmacro -run-node-query
"Shortcut for data query of the given visible node.
** Used internally, do not touch! **"
{:style/indent 0}
[[node-binding node-expr] expr]
`(with-stale-ignored
(if-let [~node-binding (maybe ~node-expr)]
~expr)))
(defn -browser
"Returns the current browser instance.
** Used internally, do not touch! **"
([] (or *browser* (throw (IllegalStateException. "No browser set! Did you forget to call `use-browser`?")))))
(defn- -t [] ^ChromeDevToolsService (tools (-browser)))
(defn sleep
"Holds the execution the given milliseconds"
[ms]
{:pre [(pos? ms)]}
(Thread/sleep ms))
(defn launch!
"TODO docs"
([opts]
(browser/launch! opts))
([] (launch! {:headless true})))
(defn close!
"Closes the given resource"
[^Closeable resource]
(.close resource))
(defmacro wait
"Evaluates the given expression and returns the value if it is truthy,
otherwise pauses execution for a moment and re-tries to evaluate the
expression. Continues this until thruthy value or timeout exception
occurs."
[expr]
`(retry/loop* #(do ~expr) *browser* *config* '~expr))
(defmacro with-retry
"Evaluates each (mutation) statement in retry-loop so that if the
mutation throws any cuic related error, the errored statement is
retried until the expression passes or timeout exception occurs."
[& statements]
`(do ~@(map (fn [s] `(retry/loop* #(do ~s true) *browser* *config* '~s)) statements)
nil))
(defn running-activities
"Returns a vector of the currently running activities."
([] (browser/activities (-browser))))
(defn document
"Returns the root document node or nil if document is not available"
[]
(as-> (call-node #(-> (.getDOM (-t)) (.getDocument))) $
(node-id->object-id (-browser) (.getNodeId $))
(->DOMNode $ (-browser))
(with-meta $ {::document true})))
(defn q
"Performs a CSS query to the subtree of the given root node and returns a
vector of matched nodes. If called without the root node, page document node
is used as a root node for the query."
([root-node ^String selector]
(or (-run-node-query [n root-node]
(->> (call-node #(-> (.getDOM (-t))
(.querySelectorAll (node-id n) selector)))
(map (partial node-id->object-id (:browser n)))
(mapv #(->DOMNode % (:browser n)))))
[]))
([^String selector]
(q (document) selector)))
(defn eval
"Evaluate JavaScript expression in the global JS context. Return value of the
expression is converted into Clojure data structure. Supports async
expressions (await keyword)."
[^String js-code]
(js/eval (-browser) js-code))
(defn eval-in [node-ctx ^String js-code]
"Evaluates JavaScript expression in the given node context so that JS 'this'
points to the given node. Return value of the expression is converted into
Clojure data structure. Supports async expressions (await keyword)."
(js/eval-in (existing node-ctx) js-code))
(defn value
"Returns the current value of the given input element."
[input-node]
(-run-node-query [n input-node]
(js/eval-in n "this.value")))
(defn options
"Returns a list of options {:keys [value text selected]} for the given HTML
select element."
[select-node]
(or (-run-node-query [n select-node]
(js/eval-in n "Array.prototype.slice.call(this.options).map(function(o){return{value:o.value,text:o.text,selected:o.selected};})"))
[]))
(defn outer-html
"Returns the outer html of the given node in clojure.data.xml format
(node is a map of {:keys [tag attrs content]})"
[node]
(-run-node-query [n node]
(->> (call-node #(.getOuterHTML (.getDOM (-t)) (node-id n) nil nil))
(html/parse (true? (::document (meta n)))))))
(defn text-content
"Returns the raw text content of the given DOM node."
[node]
(-run-node-query [n node]
(if (true? (::document (meta n)))
(text-content (first (q n "body")))
(js/eval-in n "this.textContent"))))
(defn inner-text
"Returns the inner text of the given DOM node"
[node]
(-run-node-query [n node]
(if (true? (::document (meta n)))
(inner-text (first (q n "body")))
(js/eval-in n "this.innerText"))))
(defn attrs
"Returns a map of attributes and their values for the given DOM node"
[node]
(or (-run-node-query [n node]
(->> (call-node #(.getAttributes (.getDOM (-t)) (node-id n)))
(partition 2 2)
(map (fn [[k v]] [(keyword k) v]))
(into {})))
{}))
(defn classes
"Returns a set of CSS classes for the given DOM node"
[node]
(as-> (attrs node) $
(get $ :class "")
(string/split $ #"\s+")
(map string/trim $)
(remove string/blank? $)
(set $)))
(defn term-freqs
"Returns a number of occurrences per term in the given nodes inner text"
[node]
(->> (string/split (inner-text node) #"[\n\s\t]+")
(map string/trim)
(remove string/blank?)
(group-by identity)
(map (fn [[t v]] [t (count v)]))
(into {})))
(defn visible?
"Returns boolean whether the given DOM node is visible in DOM or not"
[node]
(or (-run-node-query [n node]
(js/eval-in n "!!this.offsetParent"))
false))
(defn has-class?
"Returns boolean whether the given DOM node has the given class or not"
[node ^String class]
(contains? (classes node) class))
(defn matches?
"Returns boolean whether the given node matches the given CSS selector or not."
[node ^String selector]
(or (-run-node-query [n node]
(js/exec-in n "try {return this.matches(sel);}catch(e){return false;}" ["sel" selector]))
false))
(defn active?
"Returns boolean whether the given node is active (focused) or not"
[node]
(or (-run-node-query [n node]
(js/eval-in n "document.activeElement === this"))
false))
(defn checked?
"Returns boolean whether the given radio button / checkbox is checked or not"
[node]
(or (-run-node-query [n node]
(js/eval-in n "!!this.checked"))))
(defn page-screenshot
"Takes a screen capture from the currently visible page and returns a
BufferedImage instance containing the screenshot."
([{:keys [masked-nodes]}]
(-> (util/scaled-screenshot (-browser))
(util/mask-nodes masked-nodes)))
([] (page-screenshot {})))
(defn screenshot
"Takes a screen capture from the given DOM node and returns a BufferedImage
instance containing the screenshot. DOM node must be visible or otherwise
an exception is thrown."
([node {:keys [masked-nodes]}]
; for some reason, Chrome's "clip" option in .captureScreenshot does not match the
; bounding box rect of the node so we need to do it manually here in JVM...
(let [n (visible node)
_ (util/scroll-into-view! n)
bb (util/bounding-box n)]
(-> (util/scaled-screenshot (-browser))
(util/mask-nodes masked-nodes)
(util/crop (:left bb) (:top bb) (:width bb) (:height bb)))))
([node] (screenshot node {})))
(defn png-bytes
"Converts the given (screenshot) image to PNG format and returns a byte array
of the encoded data."
[^BufferedImage image]
(util/png-bytes image))
(defn goto!
"Navigates the page to the given URL."
[^String url]
(-run-mutation
(call #(.navigate (.getPage (-t)) url))))
(defn scroll-to!
"Scrolls window to the given DOM node if that node is not already visible
in the current viewport"
[node]
(-run-node-mutation [n node]
(util/scroll-into-view! n)))
(defn click!
"Clicks the given DOM element."
[node]
(-run-node-mutation [n node]
(util/scroll-into-view! n)
(input/mouse-click! (:browser n) (util/bbox-center n))))
(defn hover!
"Hover mouse over the given DOM element."
[node]
(-run-node-mutation [n node]
(util/scroll-into-view! n)
(input/mouse-move! (:browser n) (util/bbox-center n))))
(defn focus!
"Focus on the given DOM element."
[node]
(-run-node-mutation [n node]
(util/scroll-into-view! n)
(call-node #(-> (.getDOM (tools (:browser n)))
(.focus (node-id n) nil nil)))))
(defn select-text!
"Selects all text from the given input DOM element. If element is
not active, it is activated first by clicking it."
[input-node]
(-run-node-mutation [n input-node]
(if-not (active? n) (click! n))
(js/exec-in n "try{this.setSelectionRange(0,this.value.length);}catch(e){}")))
(defn type!
"Types text to the given input element. If element is not active,
it is activated first by clicking it."
[input-node & keys]
(-run-node-mutation [n input-node]
(if-not (active? n) (click! n))
(input/type! (:browser n) keys (:typing-speed *config*))))
(defn clear-text!
"Clears all text from the given input DOM element by selecting all
text and pressing backspace. If element is not active, it is
activated first by clicking it."
[input-node]
(doto input-node
(select-text!)
(type! :backspace)))
(defn select!
[select-node & values]
{:pre [(every? string? values)]}
(-run-node-mutation [n select-node]
(js/exec-in n "
if (this.nodeName.toLowerCase() !== 'select') throw new Error('Not a select node');
var opts = Array.prototype.slice.call(this.options);
for(var i = 0; i < opts.length; i++) {
var o = opts[i];
if ((o.selected = vals.includes(o.value)) && !this.multiple) {
break;
}
}
this.dispatchEvent(new Event('input', {bubbles: true}));
this.dispatchEvent(new Event('change', {bubbles: true}));
" ["vals" (vec values)])))
(defn default-mutation-wrapper
"Default wrapper that surrounds for each mutation function.
Holds the execution until all document (re)loads, XHR requests
and animations have been finished that were started after
the mutation."
[operation]
(letfn [(watched-task? [{:keys [task-type request-type]}]
(and (= :http-request task-type)
(contains? #{:document :xhr} request-type)))
(watched-tasks []
(->> (running-activities)
(filter watched-task?)
(set)))]
(let [before (watched-tasks)]
(operation)
; JavScript apps are slow - let them some time to fire the requests before we actually start polling them
(sleep 100)
(wait (empty? (difference (watched-tasks) before))))))