├── README.md ├── src └── webdriver.nim └── webdriver.nimble /README.md: -------------------------------------------------------------------------------- 1 | # webdriver 2 | 3 | A simple implementation of the pretty recent [W3C WebDriver spec](https://www.w3.org/TR/webdriver/). 4 | 5 | I have coded this library during a livestream, if you want to learn more about it's internals and how it can be used take a look: 6 | https://www.youtube.com/watch?v=583BwZ7uSro&index=1&list=PLm-fq5xBdPkrMuVkPWuho7XzszB6kJ2My 7 | 8 | 9 | ## Examples 10 | 11 | I have prepared an example repo showing how to test a sample Jester app using Travis with this library, available here: https://github.com/dom96/geckodriver-travis. 12 | 13 | For a larger example you can have a look at the Nimforum: https://github.com/nim-lang/nimforum/tree/7d8417ff97/tests 14 | -------------------------------------------------------------------------------- /src/webdriver.nim: -------------------------------------------------------------------------------- 1 | # For reference, this is brilliant: https://github.com/jlipps/simple-wd-spec 2 | 3 | import httpclient, uri, json, tables, options, strutils, unicode, sequtils 4 | 5 | type 6 | WebDriver* = ref object 7 | url*: Uri 8 | client*: HttpClient 9 | 10 | Session* = object 11 | driver: WebDriver 12 | id*: string 13 | 14 | Element* = object 15 | session: Session 16 | id*: string 17 | 18 | Cookie* = object 19 | name*: string 20 | value*: string 21 | path*: Option[string] 22 | domain*: Option[string] 23 | secure*: Option[bool] 24 | httpOnly*: Option[bool] 25 | expiry*: Option[BiggestInt] 26 | 27 | LocationStrategy* = enum 28 | CssSelector, LinkTextSelector, PartialLinkTextSelector, TagNameSelector, 29 | XPathSelector 30 | 31 | WebDriverException* = object of Exception 32 | 33 | ProtocolException* = object of WebDriverException 34 | JavascriptException* = object of WebDriverException 35 | 36 | let 37 | defaultSessionCapabilities* = %*{"capabilities": {"browserName": "firefox"}} 38 | 39 | proc `%`*(element: Element): JsonNode = 40 | result = %*{ 41 | "ELEMENT": element.id, 42 | # This key is taken from the selenium python code. Not 43 | # sure why they picked it, but it works 44 | "element-6066-11e4-a52e-4f735466cecf": element.id 45 | } 46 | 47 | proc toKeyword(strategy: LocationStrategy): string = 48 | case strategy 49 | of CssSelector: "css selector" 50 | of LinkTextSelector: "link text" 51 | of PartialLinkTextSelector: "partial link text" 52 | of TagNameSelector: "tag name" 53 | of XPathSelector: "xpath" 54 | 55 | proc checkResponse(resp: string): JsonNode = 56 | result = parseJson(resp) 57 | if result{"value"}.isNil: 58 | raise newException(WebDriverException, $result) 59 | 60 | proc newWebDriver*(url: string = "http://localhost:4444"): WebDriver = 61 | WebDriver(url: url.parseUri, client: newHttpClient()) 62 | 63 | proc createSession*(self: WebDriver, capabilities: JsonNode = defaultSessionCapabilities): Session = 64 | ## Creates a new browsing session. 65 | 66 | # Check the readiness of the Web Driver. 67 | let resp = self.client.getContent($(self.url / "status")) 68 | let obj = parseJson(resp) 69 | let ready = obj{"value", "ready"} 70 | 71 | if ready.isNil(): 72 | let msg = "Readiness message does not follow spec" 73 | raise newException(ProtocolException, msg) 74 | 75 | if not ready.getBool(): 76 | raise newException(WebDriverException, "WebDriver is not ready") 77 | 78 | # Create our session. 79 | let sessionResp = self.client.postContent($(self.url / "session"), 80 | $capabilities) 81 | let sessionObj = parseJson(sessionResp) 82 | let sessionId = sessionObj{"value", "sessionId"} 83 | if sessionId.isNil(): 84 | raise newException(ProtocolException, "No sessionId in response to request") 85 | 86 | return Session(id: sessionId.getStr(), driver: self) 87 | 88 | proc close*(self: Session) = 89 | let reqUrl = $(self.driver.url / "session" / self.id) 90 | let resp = self.driver.client.request(reqUrl, HttpDelete) 91 | 92 | let respObj = checkResponse(resp.body) 93 | 94 | proc navigate*(self: Session, url: string) = 95 | ## Instructs the session to navigate to the specified URL. 96 | let reqUrl = $(self.driver.url / "session" / self.id / "url") 97 | let obj = %*{"url": url} 98 | let resp = self.driver.client.postContent(reqUrl, $obj) 99 | 100 | let respObj = parseJson(resp) 101 | if respObj{"value"}.getFields().len != 0: 102 | raise newException(WebDriverException, $respObj) 103 | 104 | proc getPageSource*(self: Session): string = 105 | ## Retrieves the specified session's page source. 106 | let reqUrl = $(self.driver.url / "session" / self.id / "source") 107 | let resp = self.driver.client.getContent(reqUrl) 108 | 109 | let respObj = checkResponse(resp) 110 | 111 | return respObj{"value"}.getStr() 112 | 113 | proc getUrl*(self: Session): string = 114 | ## Retrieves the specified session's page source. 115 | let reqUrl = $(self.driver.url / "session" / self.id / "url") 116 | let resp = self.driver.client.getContent(reqUrl) 117 | 118 | let respObj = checkResponse(resp) 119 | 120 | return respObj{"value"}.getStr() 121 | 122 | proc findElement*(self: Session, selector: string, 123 | strategy = CssSelector): Option[Element] = 124 | let reqUrl = $(self.driver.url / "session" / self.id / "element") 125 | let reqObj = %*{"using": toKeyword(strategy), "value": selector} 126 | let resp = self.driver.client.post(reqUrl, $reqObj) 127 | if resp.status == $Http404: 128 | return none(Element) 129 | 130 | if resp.status != $Http200: 131 | raise newException(WebDriverException, resp.status) 132 | 133 | let respObj = checkResponse(resp.body) 134 | 135 | for key, value in respObj["value"].getFields().pairs(): 136 | return some(Element(id: value.getStr(), session: self)) 137 | 138 | proc findElements*(self: Session, selector: string, 139 | strategy = CssSelector): seq[Element] = 140 | let reqUrl = $(self.driver.url / "session" / self.id / "elements") 141 | let reqObj = %*{"using": toKeyword(strategy), "value": selector} 142 | let resp = self.driver.client.post(reqUrl, $reqObj) 143 | if resp.status == $Http404: 144 | return @[] 145 | 146 | if resp.status != $Http200: 147 | raise newException(WebDriverException, resp.status) 148 | 149 | let respObj = checkResponse(resp.body) 150 | 151 | for element in respObj["value"].to(seq[JsonNode]): 152 | for key, value in element.getFields().pairs(): 153 | result.add(Element(id: value.getStr(), session: self)) 154 | 155 | proc getText*(self: Element): string = 156 | let reqUrl = $(self.session.driver.url / "session" / self.session.id / 157 | "element" / self.id / "text") 158 | let resp = self.session.driver.client.getContent(reqUrl) 159 | let respObj = checkResponse(resp) 160 | 161 | return respObj["value"].getStr() 162 | 163 | proc getAttribute*(self: Element, name: string): string = 164 | let reqUrl = $(self.session.driver.url / "session" / self.session.id / 165 | "element" / self.id / "attribute" / name) 166 | let resp = self.session.driver.client.getContent(reqUrl) 167 | let respObj = checkResponse(resp) 168 | 169 | return respObj["value"].getStr() 170 | 171 | proc getProperty*(self: Element, name: string): string = 172 | let reqUrl = $(self.session.driver.url / "session" / self.session.id / 173 | "element" / self.id / "property" / name) 174 | let resp = self.session.driver.client.getContent(reqUrl) 175 | let respObj = checkResponse(resp) 176 | 177 | return respObj["value"].getStr() 178 | 179 | proc clear*(self: Element) = 180 | ## Clears an element of text/input 181 | let reqUrl = $(self.session.driver.url / "session" / self.session.id / 182 | "element" / self.id / "clear") 183 | let obj = %*{} 184 | let resp = self.session.driver.client.post(reqUrl, $obj) 185 | if resp.status != $Http200: 186 | raise newException(WebDriverException, resp.status) 187 | 188 | discard checkResponse(resp.body) 189 | 190 | proc click*(self: Element) = 191 | let reqUrl = $(self.session.driver.url / "session" / self.session.id / 192 | "element" / self.id / "click") 193 | let obj = %*{} 194 | let resp = self.session.driver.client.post(reqUrl, $obj) 195 | if resp.status != $Http200: 196 | raise newException(WebDriverException, resp.status) 197 | 198 | discard checkResponse(resp.body) 199 | 200 | # Note: There currently is an open bug in geckodriver that causes DOM events not to fire when sending keys. 201 | # https://github.com/mozilla/geckodriver/issues/348 202 | proc sendKeys*(self: Element, text: string) = 203 | let reqUrl = $(self.session.driver.url / "session" / self.session.id / 204 | "element" / self.id / "value") 205 | let obj = %*{"text": text} 206 | let resp = self.session.driver.client.post(reqUrl, $obj) 207 | if resp.status != $Http200: 208 | raise newException(WebDriverException, resp.status) 209 | 210 | discard checkResponse(resp.body) 211 | 212 | type 213 | # https://w3c.github.io/webdriver/#keyboard-actions 214 | Key* = enum 215 | Unidentified = 0, 216 | Cancel, 217 | Help, 218 | Backspace, 219 | Tab, 220 | Clear, 221 | Return, 222 | Enter, 223 | Shift, 224 | Control, 225 | Alt, 226 | Pause, 227 | Escape 228 | 229 | proc toUnicode(key: Key): Rune = 230 | Rune(0xE000 + ord(key)) 231 | 232 | proc press*(self: Session, keys: varargs[Key]) = 233 | let reqUrl = $(self.driver.url / "session" / self.id / "actions") 234 | let obj = %*{"actions": [ 235 | { 236 | "type": "key", 237 | "id": "keyboard", 238 | "actions": [] 239 | } 240 | ]} 241 | for key in keys: 242 | obj["actions"][0]["actions"].elems.add( 243 | %*{ 244 | "type": "keyDown", 245 | "value": $toUnicode(key) 246 | } 247 | ) 248 | obj["actions"][0]["actions"].elems.add( 249 | %*{ 250 | "type": "keyUp", 251 | "value": $toUnicode(key) 252 | } 253 | ) 254 | 255 | let resp = self.driver.client.post(reqUrl, $obj) 256 | if resp.status != $Http200: 257 | raise newException(WebDriverException, resp.status) 258 | 259 | discard checkResponse(resp.body) 260 | 261 | proc takeScreenshot*(self: Session): string = 262 | let reqUrl = $(self.driver.url / "session" / self.id / "screenshot") 263 | let resp = self.driver.client.getContent(reqUrl) 264 | let respObj = checkResponse(resp) 265 | 266 | return respObj["value"].getStr() 267 | 268 | proc internalExecute(self: Session, code: string, args: varargs[JsonNode], kind: string): JsonNode = 269 | let reqUrl = $(self.driver.url / "session" / self.id / "execute" / kind) 270 | let obj = %*{ 271 | "script": code, 272 | "args": args 273 | } 274 | 275 | let resp = self.driver.client.post(reqUrl, $obj) 276 | let respObj = checkResponse(resp.body) 277 | if respObj["value"].kind == JObject and respObj["value"].hasKey("error"): 278 | raise newException(JavascriptException, respObj["value"]["message"].getStr & "\n" & respObj["value"]["stacktrace"].getStr) 279 | 280 | return respObj["value"] 281 | 282 | proc execute*(self: Session, code: string, args: varargs[JsonNode]): JsonNode = 283 | self.internalExecute(code, args, "sync") 284 | 285 | proc executeAsync*(self: Session, code: string, args: varargs[JsonNode]): JsonNode = 286 | self.internalExecute(code, args, "async") 287 | 288 | proc execute*(self: Session, code: string, args: varargs[Element]): JsonNode = 289 | self.internalExecute(code, args.mapIt(%it), "sync") 290 | 291 | proc executeAsync*(self: Session, code: string, args: varargs[Element]): JsonNode = 292 | self.internalExecute(code, args.mapIt(%it), "async") 293 | 294 | proc addCookie*(self: Session, cookie: Cookie) = 295 | let reqUrl = $(self.driver.url / "session" / self.id / "cookie") 296 | let obj = %* { 297 | "cookie": { 298 | "name": cookie.name, 299 | "value": cookie.value, 300 | } 301 | } 302 | if cookie.path.isSome: 303 | obj["path"] = cookie.path.get.newJString() 304 | if cookie.domain.isSome: 305 | obj["domain"] = cookie.domain.get.newJString() 306 | if cookie.secure.isSome: 307 | obj["secure"] = cookie.secure.get.newJBool() 308 | if cookie.httpOnly.isSome: 309 | obj["httpOnly"] = cookie.httpOnly.get.newJBool() 310 | if cookie.expiry.isSome: 311 | obj["expiry"] = cookie.expiry.get.newJInt() 312 | 313 | let resp = self.driver.client.post(reqUrl, $obj) 314 | if resp.status != $Http200: 315 | raise newException(WebDriverException, resp.status) 316 | 317 | proc getCookie*(self: Session, name: string): Cookie = 318 | let reqUrl = $(self.driver.url / "session" / self.id / "cookie" / name) 319 | 320 | let resp = self.driver.client.get(reqUrl) 321 | 322 | let cookie = checkResponse(resp.body)["value"] 323 | result = Cookie(name: cookie["name"].getStr, value: cookie["value"].getStr) 324 | if cookie.hasKey("path"): 325 | result.path = some(cookie["path"].getStr) 326 | if cookie.hasKey("domain"): 327 | result.domain = some(cookie["domain"].getStr) 328 | if cookie.hasKey("secure"): 329 | result.secure = some(cookie["secure"].getBool) 330 | if cookie.hasKey("httpOnly"): 331 | result.httpOnly = some(cookie["httpOnly"].getBool) 332 | if cookie.hasKey("expiry"): 333 | result.expiry = some(cookie["expiry"].getBiggestInt) 334 | 335 | proc deleteCookie*(self: Session, name: string): Cookie = 336 | let reqUrl = $(self.driver.url / "session" / self.id / "cookie" / name) 337 | 338 | let resp = self.driver.client.delete(reqUrl) 339 | if resp.status != $Http200: 340 | raise newException(WebDriverException, resp.status) 341 | 342 | proc getAllCookies*(self: Session): seq[Cookie] = 343 | let reqUrl = $(self.driver.url / "session" / self.id / "cookie") 344 | 345 | let resp = self.driver.client.get(reqUrl) 346 | if resp.status != $Http200: 347 | raise newException(WebDriverException, resp.status) 348 | 349 | let respObj = checkResponse(resp.body) 350 | for cookie in respObj["value"].items: 351 | var final = Cookie(name: cookie["name"].getStr, value: cookie["value"].getStr) 352 | if cookie.hasKey("path"): 353 | final.path = some(cookie["path"].getStr) 354 | if cookie.hasKey("domain"): 355 | final.domain = some(cookie["domain"].getStr) 356 | if cookie.hasKey("secure"): 357 | final.secure = some(cookie["secure"].getBool) 358 | if cookie.hasKey("httpOnly"): 359 | final.httpOnly = some(cookie["httpOnly"].getBool) 360 | if cookie.hasKey("expiry"): 361 | final.expiry = some(cookie["expiry"].getBiggestInt) 362 | 363 | result.add final 364 | 365 | proc deleteAllCookies*(self: Session): Cookie = 366 | let reqUrl = $(self.driver.url / "session" / self.id / "cookie") 367 | 368 | let resp = self.driver.client.delete(reqUrl) 369 | if resp.status != $Http200: 370 | raise newException(WebDriverException, resp.status) 371 | 372 | when isMainModule: 373 | let webDriver = newWebDriver() 374 | let session = webDriver.createSession() 375 | let amazonUrl = "https://www.amazon.co.uk/Nintendo-Classic-Mini-" & 376 | "Entertainment-System/dp/B073BVHY3F" 377 | session.navigate(amazonUrl) 378 | 379 | echo session.findElement("#priceblock_ourprice").get().getText() 380 | 381 | session.close() 382 | -------------------------------------------------------------------------------- /webdriver.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.3.0" 4 | author = "Dominik Picheta" 5 | description = "Implementation of the WebDriver w3c spec." 6 | license = "MIT" 7 | 8 | srcDir = "src" 9 | 10 | # Dependencies 11 | 12 | requires "nim >= 0.17.2" 13 | 14 | --------------------------------------------------------------------------------