24 KiB
Raft - Webdriver implementation in Chicken Scheme
Dependencies
Dependency | Description |
---|---|
alist-lib | Handling alists from JSON objects |
base64 | decoding screenshot data |
coops | Object system |
http-client | API interaction |
intarweb | Supporting HTTP functionality |
medea | JSON handling |
srfi-34 | Exception Handling |
srfi-35 | Exception Types |
uri-common | Supporting HTTP functionality |
Error Conditions
(define-condition-type &raft-exception &error raft-exception?
(stacktrace raft-stacktrace)
(data raft-data))
Every API error code (key "error" in the returned JSON data) gets its own condition type, prefixed by &
. They all inherit from &raft-exception
.
Name | API Error Code |
---|---|
detached-shadow-root | detached shadow root |
element-click-intercepted | element click intercepted |
element-not-interactable | element not interactable |
insecure-certificate | insecure certificate |
invalid-argument | invalid argument |
invalid-cookie-domain | invalid cookie domain |
invalid-element-state | invalid element state |
invalid-selector | invalid selector |
invalid-session-id | invalid session id |
javascript-error | javascript error |
move-target-out-of-bounds | move target out of bounds |
no-such-alert | no such alert |
no-such-cookie | no such cookie |
no-such-element | no such element |
no-such-frame | no such frame |
no-such-shadow-root | no such shadow root |
no-such-window | no such window |
script-timeout | script timeout |
session-not-created | session not created |
stale-element-reference | stale element reference |
timeout | timeout |
unable-to-capture-screen | unable to capture screen |
unable-to-set-cookie | unable to set cookie |
unexpected-alert-open | unexpected alert open |
unknown-command | unknown command |
unknown-error | unknown error |
unknown-method | unknown method |
unsupported-operation | unsupported operation |
WebDriver
The core element of the library is the <Raft>
class and its subclasses. The class has the following fields:
(define-class <Raft> ()
((browser #f)
(active? #f)
(browser-pid #f)
(server #f)
(port #f)
(session-id #f)
(prefs #f)
(capabilities #f)))
The parent class provides a handful of methods, but does not implement all of them; some are the sole responsibility of the subclass. The launch
method, on the other hand, bears shared responsibility. It sets a finalizer to ensure termination of the web driver process in case the class is disposed of with a still-open driver.
(define-method (launch #:after (instance <Raft>) options)
(set-finalizer! instance (lambda (obj)
(when (slot-value instance 'active?)
(terminate instance)))))
(define-method (terminate (instance <Raft>))
(terminate-session instance)
(process-signal (slot-value instance 'browser-pid))
(set! (slot-value instance 'browser-pid) #f)
(set! (slot-value instance 'active?) #f))
(define-method (construct-capabilities (instance <Raft>) #!optional caps)
(raise 'subclass-responsibility))
(define-method (postprocess-result (instance <Raft>) result)
result)
Main initialization is done by calling the make-Raft
procedure with the respective class name and optionally an alist of options.
(define (make-Raft browser #!optional options)
(let ((instance (make browser)))
(launch instance options)
(sleep 1)
instance))
Geckodriver
The Geckodriver is used to control Firefox.
(define-class <Gecko> (<Raft>)
((browser #:firefox)
(server "127.0.0.1")
(port 4444)))
(define-method (launch (instance <Gecko>) options)
(let ((pid (process-run "geckodriver > /dev/null 2>&1")))
(set! (slot-value instance 'browser-pid) pid)
(set! (slot-value instance 'active?) #t)
(set! (slot-value instance 'capabilities) options)))
The capabilities object for Geckodriver is of the form {"capabilities": {...}}
.
For more information on capabilities, see https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities.
(define-method (construct-capabilities (instance <Gecko>))
(let ((caps (or (slot-value instance 'capabilities) (list))))
`((capabilities . ,caps))))
Sometimes, Geckodriver returns the results of a command in a JSON object with the sole key "value"
. We have to correct that before returning the data to the user.
(define-method (postprocess-result (instance <Gecko>) result)
(alist-ref/default result 'value result))
WebDriver API
Communication
Data is sent to the API via a central class method. For convenience, there is a send-with-session
variant that automatically adds the session id.
(define-method (send (instance <Raft>) data uri method)
(let* ((remote (string-append "http://" (slot-value instance 'server) ":" (->string (slot-value instance 'port)) "/"))
(result (postprocess-result instance
(with-input-from-request
(make-request #:method method
#:uri (uri-reference (string-append remote uri))
#:headers (headers `((content-type application/json))))
(if data (json->string data) "")
read-json))))
(if (and (list? result) (alist-ref/default result 'error #f))
(raft-throw result)
result)))
(define-method (send-with-session (instance <Raft>) data uri method)
(send instance data (string-append "session/" (slot-value instance 'session-id) "/" uri) method))
Session management
Session management is very simple. There is just one method to initialize a new session. Everything else is handled automatically.
(define-method (initialize-session (instance <Raft>))
(let ((result (send instance (construct-capabilities instance) "session" 'POST)))
(set! (slot-value instance 'session-id) (alist-ref result 'sessionId))))
(define-method (terminate-session (instance <Raft>))
(when (slot-value instance 'session-id)
(send instance #f (string-append "session/" (slot-value instance 'session-id)) 'DELETE))
(set! (slot-value instance 'session-id) #f))
-- testing session ----------------------------------------------------------- Initial state ........................................................ [ PASS] Session id check ..................................................... [ PASS] Session id after termination ......................................... [ PASS] 3 tests completed in 3.788 seconds. 3 out of 3 (100%) tests passed. -- done testing session ------------------------------------------------------
API Access Methods
(define-method (set-url (instance <Raft>) url)
(send-with-session instance `((url . ,url)) "url" 'POST))
(define-method (url (instance <Raft>))
(send-with-session instance #f "url" 'GET))
-- testing url --------------------------------------------------------------- Initial state ........................................................ [ PASS] Navigating to the first website ...................................... [ PASS] 2 tests completed in 5.247 seconds. 2 out of 2 (100%) tests passed. -- done testing url ----------------------------------------------------------
(define-method (back (instance <Raft>))
(send-with-session instance #f "back" 'POST))
(define-method (forward (instance <Raft>))
(send-with-session instance #f "forward" 'POST))
(define-method (refresh (instance <Raft>))
(send-with-session instance #f "refresh" 'POST))
(define-method (title (instance <Raft>))
(send-with-session instance #f "title" 'GET))
(define-method (status (instance <Raft>))
(send-with-session instance #f "status" 'GET))
(define-method (source (instance <Raft>))
(send-with-session instance #f "source" 'GET))
(define-method (screenshot (instance <Raft>))
(base64-decode (send-with-session instance #f "screenshot" 'GET)))
(define-method (print-page (instance <Raft>))
(send-with-session instance #f "print" 'POST))
(define-method (execute-async (instance <Raft>) script args)
(send-with-session instance `((script . ,script) (args . ,args)) "execute/async" 'POST))
(define-method (execute-sync (instance <Raft>) script args)
(send-with-session instance `((script . ,script) (args . ,args)) "execute/sync" 'POST))
Timeouts
The following timeouts are defined:
script
: defaults to 30'000, specifies when to interrupt a script that is being evaluated. A nil value implies that scripts should never be interrupted, but instead run indefinitely.pageLoad
: defaults to 300'000, provides the timeout limit used to interrupt an explicit navigation attempt.implicit
: defaults to 0, specifies a time to wait for the element location strategy to complete when locating an element.
(define-class <RaftTimeouts> ()
((script 30000)
(pageLoad 300000)
(implicit 0)))
(define-method (extract (instance <RaftTimeouts>))
`((script . ,(slot-value instance 'script))
(pageLoad . ,(slot-value instance 'pageLoad))
(implicit . ,(slot-value instance 'implicit))))
Setting and getting timeouts
(define-method (set-timeouts (instance <Raft>) (timeouts <RaftTimeouts>))
(send-with-session instance (extract timeouts) "timeouts" 'POST))
(define-method (timeouts (instance <Raft>))
(let ((result (send-with-session instance #f "timeouts" 'GET)))
(make <RaftTimeouts>
'script (alist-ref result 'script)
'pageLoad (alist-ref result 'pageLoad)
'implicit (alist-ref result 'implicit))))
Elements
Element Class
(define-class <RaftElement> ()
((driver #f)
(element #f)))
(define-method (send-with-session (instance <RaftElement>) data uri method)
(send-with-session (slot-value instance 'driver) data
(string-append "element/" (slot-value instance 'element) "/" uri)
method))
Finding Elements
Location Strategies
(define css-selector "css selector")
(define link-text "link text")
(define partial-link-text "partial link text")
(define tag-name "tag name")
(define xpath "xpath")
Accessor Methods
(define-method (find-element (instance <Raft>) strategy selector)
(let ((result (send-with-session instance `((using . ,strategy) (value . ,selector)) "element" 'POST)))
(make <RaftElement> 'driver instance 'element (car (alist-values result)))))
(define-method (find-elements (instance <Raft>) strategy selector)
(let ((result (send-with-session instance `((using . ,strategy) (value . ,selector)) "elements" 'POST)))
(map
(lambda (elem)
(make <RaftElement> 'driver instance 'element (car (alist-values elem))))
result)))
(define-method (find-element (instance <RaftElement>) strategy selector)
(let ((result (send-with-session instance `((using . ,strategy) (value . ,selector)) "element" 'POST)))
(make <RaftElement> 'driver (slot-value instance 'driver) 'element (car (alist-values result)))))
(define-method (find-elements (instance <RaftElement>) strategy selector)
(let ((result (send-with-session instance `((using . ,strategy) (value . ,selector)) "elements" 'POST)))
(map
(lambda (elem)
(make <RaftElement> 'driver (slot-value instance 'driver) 'element (car (alist-values elem))))
result)))
Working with Elements
(define-method (attribute (instance <RaftElement>) attribute)
(let ((result (send-with-session instance #f
(string-append "attribute/" attribute)
'GET)))
(if (equal? "true" result)
#t
result)))
(define-method (property (instance <RaftElement>) property)
(send-with-session instance #f (string-append "property/" property) 'GET))
(define-method (clear (instance <RaftElement>))
(send-with-session instance #f "clear" 'POST))
(define-method (click (instance <RaftElement>))
(send-with-session instance #f "click" 'POST))
(define-method (computed-label (instance <RaftElement>))
(send-with-session instance #f "computedlabel" 'GET))
(define-method (computed-role (instance <RaftElement>))
(send-with-session instance #f "computedrole" 'GET))
(define-method (enabled? (instance <RaftElement>))
(send-with-session instance #f "enabled" 'GET))
(define-method (selected? (instance <RaftElement>))
(send-with-session instance #f "selected" 'GET))
(define-method (name (instance <RaftElement>))
(send-with-session instance #f "name" 'GET))
(define-method (rect (instance <RaftElement>))
(send-with-session instance #f "rect" 'GET))
(define-method (screenshot (instance <RaftElement>))
(base64-decode (send-with-session instance #f "screenshot" 'GET)))
(define-method (text (instance <RaftElement>))
(send-with-session instance #f "text" 'GET))
(define-method (set-value (instance <RaftElement>) value)
(send-with-session instance `((text . ,value)) "value" 'POST))
About This Egg
Source
The source is available at https://gitea.lyrion.ch/Chicken/raft.
Author
Daniel Ziltener
Version History
0.5 | Initial Release |
License
Copyright (C) 2023 Daniel Ziltener
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the <organization> nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.