├── resources └── version ├── .gitignore ├── deps.edn ├── LICENSE ├── .github └── workflows │ └── build.yml ├── README.md └── src └── pows └── core.clj /resources/version: -------------------------------------------------------------------------------- 1 | yyyy-MM-dd.X -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.clj-kondo/ 2 | /.cpcache/ 3 | /.lsp/ 4 | /.nrepl-port 5 | /target/ 6 | /.DS_Store 7 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:path ["src" "resources"] 2 | :deps {http-kit/http-kit {:mvn/version "2.8.0"} 3 | org.clojure/tools.logging {:mvn/version "1.3.0"} 4 | cheshire/cheshire {:mvn/version "6.0.0"} 5 | com.microsoft.playwright/playwright {:mvn/version "1.52.0"}} 6 | :aliases 7 | {:run {:main-opts ["-m" "pows.core"]} 8 | :build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.9" :git/sha "e405aac"}} 9 | :ns-default build}}} 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tatu Tarvainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | uberjar: 5 | name: Build uberjar release 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v4 10 | - uses: actions/setup-java@v4 11 | with: 12 | distribution: 'temurin' 13 | java-version: '21' 14 | - uses: DeLaGuardo/setup-clojure@13.4 15 | with: 16 | cli: '1.12.1.1550' 17 | - name: Cache deps 18 | uses: actions/cache@v4 19 | with: 20 | path: | 21 | ~/.m2/repository 22 | key: deps-${{ hashFiles('deps.edn') }} 23 | restore-keys: deps- 24 | - name: Build 25 | run: clojure -T:build uber 26 | - name: Release name 27 | id: relname 28 | run: echo "relname=`cat resources/version`" >> $GITHUB_OUTPUT 29 | - name: Release 30 | uses: softprops/action-gh-release@v2.3.2 31 | with: 32 | files: target/pows-standalone.jar 33 | name: ${{ steps.relname.outputs.relname }} 34 | tag_name: ${{ steps.relname.outputs.relname }} 35 | token: ${{ secrets.RELEASE_TOKEN }} 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pows: Playwright over WebSocket 2 | 3 | pows is a host that exposes Playwright functionality through a 4 | simple JSON-over-WebSocket protocol. 5 | 6 | Every WS connection gets a fresh playwright context and can use 7 | it to do commands and assertions. 8 | 9 | Playwright has APIs for many languages, but not all. This host can be used 10 | to add bindings for the missing languages. Most every language will have 11 | JSON and WS libraries that can be used to integrate. 12 | 13 | [![asciicast](https://asciinema.org/a/630656.svg)](https://asciinema.org/a/630656) 14 | 15 | ## Usage 16 | 17 | Download a relese and start it with `java -jar `. 18 | Then you are ready to connect to it. You can optionally pass in a port number to use instead of 3344. 19 | 20 | You can also run this via Clojure cli tools: `clojure -M:run` 21 | 22 | ## Bindings 23 | 24 | Language bindings that use pows: 25 | - [pharo-Pows](https://github.com/tatut/pharo-Pows) for Pharo Smalltalk 26 | 27 | 28 | ## Commands 29 | 30 | All commands are JSON objects. The first command may have an `"options"` key 31 | that has an object that configures the context. 32 | 33 | Commands that operate or make an assertion need a `"locator"` key which 34 | is a string or an array of strings. For example `"div.counter"` and 35 | `["div.counter" "nth=0"]` are valid locators. See playwright documentation 36 | on locator strings. 37 | 38 | Commands that do an assertion may have the key/value `"not": true` to negate 39 | the assertion. 40 | 41 | Responses to commands will also always be a JSON object with a `"success"` key 42 | containing a boolean value. Assertion responses will also contain `"expected"` and 43 | `"actual"` keys. Execution errors will contain `"error"` with a string message. 44 | 45 | See [Locator](https://playwright.dev/java/docs/api/class-locator) and [LocatorAssertions](https://playwright.dev/java/docs/api/class-locatorassertions) 46 | for available commands and methods (not everything is implemented). 47 | 48 | For 0-arity calls you can pass any value, it is ignored (for now), eg. `{"locator":"button","click":1}`. 49 | For 1-arity calls the value is used as the sole argument, eg. `{"locator":"input","fill":"type something"}`. 50 | For higher arities the value is an array of arguments eg, `{"locator":".myfoo","hasAttribute":["data-baz","420"]}`. 51 | 52 | Navigation is done with the `{"go": "http://...some url.."}` command. You can also 53 | include `"options"` object that may have a `"timeout"` (milliseconds, defaults to 10000) 54 | and `"headless"` (boolean, defaults to true). 55 | -------------------------------------------------------------------------------- /src/pows/core.clj: -------------------------------------------------------------------------------- 1 | (ns pows.core 2 | "Playwright over WebSocket." 3 | (:require [org.httpkit.server :as http] 4 | [clojure.tools.logging :as log] 5 | [cheshire.core :as cheshire] 6 | [clojure.string :as str]) 7 | (:import (com.microsoft.playwright Playwright BrowserType$LaunchOptions) 8 | (com.microsoft.playwright.assertions PlaywrightAssertions) 9 | (com.microsoft.playwright.options AriaRole)) 10 | (:gen-class)) 11 | 12 | (def ch->state (atom {})) 13 | 14 | (defonce server nil) 15 | (defonce pw (Playwright/create)) 16 | 17 | (defn close [ch] 18 | (let [{:keys [context]} (get @ch->state ch)] 19 | (when context (.close context)))) 20 | 21 | (defn ensure-state [ch config] 22 | (or (get @ch->state ch) 23 | (let [chromium (.chromium pw) 24 | options (doto (BrowserType$LaunchOptions.) 25 | (.setHeadless (:headless config true))) 26 | browser (.launch chromium options) 27 | context (.newContext browser)] 28 | (.setDefaultTimeout context (:timeout options 10000)) 29 | (let [state {:context context 30 | :browser browser 31 | :page (.newPage context)}] 32 | (swap! ch->state assoc ch state) 33 | state)))) 34 | 35 | (defn- locate [page locator] 36 | (loop [at page 37 | [l & ls] (if (string? locator) 38 | [locator] 39 | (remove nil? locator))] 40 | (if-not l 41 | at 42 | (recur (.locator at l) ls)))) 43 | 44 | (defmulti cmd (fn [command _page _loc] (-> command (dissoc :options :locator :not) ffirst))) 45 | 46 | (defn assert-that [loc cmd assert-fn] 47 | (let [asrt (PlaywrightAssertions/assertThat loc) 48 | asrt (if (:not cmd) 49 | (.not asrt) 50 | asrt)] 51 | (try 52 | (assert-fn asrt) 53 | {} 54 | (catch org.opentest4j.AssertionFailedError afe 55 | {:success false 56 | :error (.getMessage afe) 57 | :actual (.getValue (.getActual afe)) 58 | :expected (.getValue (.getExpected afe))})))) 59 | 60 | (defmethod cmd :go [{go :go} page _] (.navigate page go) nil) 61 | 62 | (defmacro defassert [kw arity] 63 | `(defmethod cmd ~kw [cmd# _# loc#] 64 | (assert-that loc# cmd# (fn [l#] 65 | (let [~'args (get cmd# ~kw)] 66 | (~(symbol (str "." (name kw))) 67 | l# ~@(if (= 1 arity) 68 | (list 'args) 69 | (for [i (range 0 arity)] 70 | `(nth ~'args ~i))))))))) 71 | (defmacro defasserts [& asserts] 72 | `(do 73 | ~@(for [[kw arity] asserts] 74 | `(defassert ~kw ~arity)))) 75 | 76 | (defasserts 77 | (:hasText 1) 78 | (:containsText 1) 79 | (:hasAttribute 2) 80 | (:hasClass 1) 81 | (:containsClass 1) 82 | (:hasAccessibleDescription 1) 83 | (:hasAccessibleErrorMessage 1) 84 | (:hasAccessibleName 1) 85 | (:hasCount 1) 86 | (:hasCSS 2) 87 | (:hasId 1) 88 | (:hasJSProperty 2) 89 | (:hasValue 2) 90 | (:hasValues 2) 91 | (:isAttached 0) 92 | (:isChecked 0) 93 | (:isDisabled 0) 94 | (:isEditable 0) 95 | (:isEmpty 0) 96 | (:isEnabled 0) 97 | (:isFocused 0) 98 | (:isHidden 0) 99 | (:isInViewport 0) 100 | (:isVisible 0) 101 | (:matchesAriaSnapshot 1)) 102 | 103 | (defmacro defaction [kw arity] 104 | `(defmethod cmd ~kw [cmd# _# loc#] 105 | (let [~'args (get cmd# ~kw)] 106 | {:result (~(symbol (str "." (name kw))) loc# 107 | ~@(if (= 1 arity) 108 | (list 'args) 109 | (for [i (range 0 arity)] 110 | `(nth ~'args ~i))))}))) 111 | 112 | (defmacro defactions [& actions] 113 | `(do 114 | ~@(for [[action arity] actions] 115 | `(defaction ~action ~arity)))) 116 | 117 | (defmethod cmd :hasRole [{r :hasRole :as c} _ loc] 118 | (assert-that loc c #(.hasRole % 119 | (AriaRole/valueOf (str/upper-case r))))) 120 | 121 | (defactions 122 | (:click 0) 123 | (:type 1) 124 | (:blur 0) 125 | (:boundingBox 0) 126 | (:check 0) 127 | (:clear 0) 128 | (:dblclick 0) 129 | (:dispatchEvent 1) 130 | (:evaluate 1) 131 | (:fill 1) 132 | (:focus 0) 133 | (:hover 0) 134 | (:innerHTML 0) 135 | (:innerText 0) 136 | (:press 1) 137 | (:selectOption 1) 138 | (:selectText 1) 139 | (:setChecked 1) 140 | (:tap 0) 141 | (:uncheck 0) 142 | (:pressSequentially 1)) 143 | 144 | (defmethod cmd :click [_ _ loc] (.click loc)) 145 | 146 | 147 | (defn handle-cmd [ch {:keys [locator] :as command}] 148 | (let [state (ensure-state ch (:options cmd)) 149 | loc (some->> locator (locate (:page state)))] 150 | (try 151 | (merge {:success true} 152 | (cmd command (:page state) loc)) 153 | (catch Throwable t 154 | (log/error t "Error executing command" command) 155 | {:success false :error (.getMessage t)})))) 156 | 157 | (defn ws-handler [req] 158 | (http/as-channel 159 | req 160 | {:on-open (fn [ch] 161 | (if-not (http/websocket? ch) 162 | (http/close ch) 163 | (log/info "Client connected"))) 164 | :on-close (fn [ch status] 165 | (log/info "Client disconnected, status: " status) 166 | (close ch)) 167 | :on-receive (fn [ch msg] 168 | (try 169 | (let [cmd (cheshire/decode msg (comp keyword #(str/replace % "_" "-"))) 170 | resp (handle-cmd ch cmd)] 171 | (http/send! ch (cheshire/encode resp))) 172 | 173 | (catch Throwable t 174 | (log/error t "Closing due to error in message: " msg) 175 | (close ch))))})) 176 | 177 | (defn -main [& [port-number]] 178 | (let [port (or (some-> port-number Long/parseLong) 3344)] 179 | (log/info "Starting on port: " port) 180 | (alter-var-root #'server 181 | (fn [_] 182 | (http/run-server ws-handler {:port port}))))) 183 | --------------------------------------------------------------------------------