webdriver-wrapper (empty) → 0.1.0.0
raw patch · 9 files changed
+427/−0 lines, 9 filesdep +aesondep +asyncdep +base
Dependencies added: aeson, async, base, bytestring, directory, extra, filepath, http-conduit, http-types, process, process-extras, retry, string-interpolate, tar, text, unix, unordered-containers, webdriver, zip-archive, zlib
Files
- CHANGELOG.md +11/−0
- LICENSE +26/−0
- README.md +11/−0
- src/Test/WebDriverWrapper.hs +41/−0
- src/Test/WebDriverWrapper/Constants.hs +101/−0
- src/Test/WebDriverWrapper/GeckoDriver.hs +58/−0
- src/Test/WebDriverWrapper/Helpers.hs +45/−0
- src/Test/WebDriverWrapper/Selenium.hs +71/−0
- webdriver-wrapper.cabal +63/−0
+ CHANGELOG.md view
@@ -0,0 +1,11 @@+# Changelog for `webdriver-wrapper`++All notable changes to this project will be documented in this file.++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),+and this project adheres to the+[Haskell Package Versioning Policy](https://pvp.haskell.org/).++## Unreleased++## 0.1.0.0 - YYYY-MM-DD
+ LICENSE view
@@ -0,0 +1,26 @@+Copyright 2024 Gabriel Tollini++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+ list of conditions and the following disclaimer.++2. 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.++3. Neither the name of the copyright holder 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 THE COPYRIGHT HOLDER OR CONTRIBUTORS 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.
+ README.md view
@@ -0,0 +1,11 @@+# webdriver-wrapper++This package was created so end-users don't need to manually manage Selenium or their webdriver when dealing with the [webdriver](https://hackage.haskell.org/package/webdriver) package. For now, only `geckodriver` (aka Firefox) is supported - but `chromedriver` should be easy to implement. If you can't use Firefox for whichever reason, let me know and I'll look into implementing `chromedriver` support. ++This package is strongly inspired by [sandwich-webdriver](https://hackage.haskell.org/package/sandwich-webdriver). My original use-case didn't require the sandwich test suite, though, so I made this package which intends on being simpler and more minimalistic. ++## Extra dependencies+You must have Java installed, since Selenium is distributed as a `.jar` file. I'm running OpenJDK 17 on my machine, and it works perfectly with the selected Selenium version. ++## How to use+There are two functions which are "batteries-included": `wrappedRunSession` and `wrappedRunWD`. Just use them as you would `runSession` and `runWD`. The wrapped functions will handle downloading Selenium and geckodriver (if they were not already downloaded), plus starting and stopping Selenium. If you already have some code with `runSession` or `runWD`, their wrapped counterparts are drop-in replacements.
+ src/Test/WebDriverWrapper.hs view
@@ -0,0 +1,41 @@+{- |+Module : Core+Description : end-user functions.++The wrapped functions (`wrappedRunSession` and `wrappedRunWD`) will download Selenium and Firefox's webdriver (geckodriver) if they're not already on the `Test.WebDriverWrapper.Constants.downloadPath`, then start Selenium before running the webdriver equivalent function (`runSession` and `runWD`). They kill the Selenium process at the end of their execution. +-}+module Test.WebDriverWrapper (wrappedRunSession, wrappedRunWD, wrapWebDriverFunction, downloadIfMissing) where++import System.Process (cleanupProcess)+import Control.Concurrent.Async (concurrently_)+import Test.WebDriverWrapper.Selenium (getSeleniumIfNeeded, startSelenium)+import Test.WebDriverWrapper.GeckoDriver (getGeckoDriverIfNeeded)+import Test.WebDriver (runSession)+import Test.WebDriver.Types (WD, WDSession)+import Test.WebDriver.Config (WebDriverConfig)+import Test.WebDriver.Monad (runWD)+import Control.Exception (bracket)++-- | Same as `runSession`, but starts Selenium before execution and kills Selenium after execution. +-- Will download Selenium or Firefox's webdriver (geckodriver) if any is missing. +wrappedRunSession :: WebDriverConfig conf => conf -> WD a -> IO a+wrappedRunSession conf wd = wrapWebDriverFunction (conf,wd) (uncurry runSession) ++-- | Same as `runWD`, but starts Selenium before execution and kills Selenium after execution. +-- Will download Selenium or Firefox's webdriver (geckodriver) if any is missing. +wrappedRunWD :: WDSession -> WD a -> IO a +wrappedRunWD session wd = wrapWebDriverFunction (session, wd) (uncurry runWD)++-- | Runs a function in between starting and killing Selenium. Takes in the arguments and the function, in that order. +-- Will download Selenium or Firefox's webdriver (geckodriver) if any is missing. +wrapWebDriverFunction :: a -> (a -> IO b) -> IO b+wrapWebDriverFunction webdriverArgs webdriverFunction = do+ downloadIfMissing+ bracket+ startSelenium+ cleanupProcess+ (const $ webdriverFunction webdriverArgs)++-- | Dowloads Selenium or Firefox's webdriver (geckodriver) if they're missing. +downloadIfMissing :: IO()+downloadIfMissing = concurrently_ getSeleniumIfNeeded getGeckoDriverIfNeeded
+ src/Test/WebDriverWrapper/Constants.hs view
@@ -0,0 +1,101 @@+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}++{- |+Module : Test.WebDriverWrapper.Constants+Description : Constant values, such as links and paths.+ -}++module Test.WebDriverWrapper.Constants (geckoDriverPath, defaultPath, defaultSeleniumJarUrl, desiredPlatform, getGeckoDriverDownloadUrl, geckoDriverVersionSource, downloadPath, geckoArchivePath, fileFormat, seleniumPath, seleniumLogPath) where++import Data.String.Interpolate (i)+import qualified System.Info as SI+import System.Directory (getXdgDirectory, XdgDirectory (XdgData))+import System.FilePath ((</>))++-- Paths. Should be platform independent.+-- | @haskell-webdriver-wrapper@ directory, created at `XdgData`. +defaultPath :: IO FilePath+defaultPath = getXdgDirectory XdgData "haskell-webdriver-wrapper"++-- | Directory named after `desiredPlatform`, created at the `defaultPath`.+downloadPath :: IO FilePath+downloadPath = (</> desiredPlatform) <$> defaultPath++-- | Intermediary path for the compressed version of geckodriver. Inside `downloadPath`.+geckoArchivePath :: IO FilePath+geckoArchivePath = (</> archive) <$> downloadPath+ where+ archive = "geckodriver" <> fileFormat++-- | Path for geckodriver. Inside `downloadPath`. +geckoDriverPath :: IO FilePath+geckoDriverPath = (</> "geckodriver") <$> downloadPath++-- | Path for selenium.jar. Inside `downloadPath`.+seleniumPath :: IO FilePath+seleniumPath = (</> "selenium.jar") <$> downloadPath++-- | Path for Selenium's log file. Inside `defaultPath`. +seleniumLogPath :: IO FilePath+seleniumLogPath = (</> "selenium.log") <$> defaultPath++-- URLs. Might change at any moment, kinda why I'm putting them all together here.+-- | Url to download Selenium from. +defaultSeleniumJarUrl :: String+defaultSeleniumJarUrl = "https://selenium-release.storage.googleapis.com/3.141/selenium-server-standalone-3.141.59.jar"++-- | API to get geckodriver's latest version.+geckoDriverVersionSource :: String+geckoDriverVersionSource = "https://api.github.com/repos/mozilla/geckodriver/releases/latest"++-- | Url to download geckodriver from. Always the latest version provided by `geckoDriverVersionSource`.+getGeckoDriverDownloadUrl :: String -> String+getGeckoDriverDownloadUrl version = [i|https://github.com/mozilla/geckodriver/releases/download/#{version}/geckodriver-#{version}-#{platform}#{format}|]+ where+ platform = desiredPlatform+ format = fileFormat++-- Platform-dependent variables. +-- | Archive format for geckodriver's download. @.zip@ for Windows, @.tar.gz@ for everyone else. +fileFormat :: String+fileFormat = case SI.os of+ "windows" -> ".zip"+ "mingw32" -> ".zip"++ "darwrin" -> ".tar.gz"+ "linux" -> ".tar.gz"++ _ -> ".tar.gz"++-- | Platform this code is running at. The options are:+-- +-- * win64+-- * win-aarch64+-- * win32+-- * macos+-- * macos-aarch64+-- * linux64+-- * linux-aarch64+-- * linux32+-- +-- If the platform is not identified, @linux64@ is used. +desiredPlatform :: String+desiredPlatform = case (SI.os, SI.arch) of+ ("windows", "x86_64") -> "win64"+ ("windows", "aarch64") -> "win-aarch64"+ ("windows", "i386") -> "win32"+ ("mingw32", "x86_64") -> "win64"+ ("mingw32", "aarch64") -> "win-aarch64"+ ("mingw32", "i386") -> "win32"++ ("darwin", "x86_64") -> "macos"+ ("darwin", "aarch64") -> "macos-aarch64"++ ("linux", "x86_64") -> "linux64"+ ("linux", "aarch64") -> "linux-aarch64"+ ("linux", "i386") -> "linux32"++ _ -> "linux64"
+ src/Test/WebDriverWrapper/GeckoDriver.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE OverloadedStrings #-}++module Test.WebDriverWrapper.GeckoDriver (getGeckoDriverIfNeeded) where++import qualified Data.Text as T+import Network.HTTP.Simple (setRequestMethod, httpLBS, parseRequest, setRequestHeader)+import Test.WebDriverWrapper.Helpers (download, decompress)+import Test.WebDriverWrapper.Constants (getGeckoDriverDownloadUrl, geckoDriverVersionSource, geckoArchivePath, geckoDriverPath, downloadPath)+import Network.HTTP.Client.Conduit (Response(responseBody))+import Data.Aeson (eitherDecode)+import Network.HTTP.Types (hUserAgent)+import qualified Data.Aeson.KeyMap as AKM+import qualified Data.Aeson as A+import System.Directory (createDirectoryIfMissing, removeFile, doesFileExist)+import Control.Monad (unless)++-- | Checks if @geckodriver@ is in the `downloadPath`. If not, download it. +getGeckoDriverIfNeeded :: IO ()+getGeckoDriverIfNeeded = do+ geckoPath <- geckoDriverPath+ hasGeckoDriver <- doesFileExist geckoPath+ unless hasGeckoDriver getGeckoDriver++getGeckoDriver :: IO()+getGeckoDriver = do+ version <- getGeckoDriverVersion+ dPath <- downloadPath+ tarballPath <- geckoArchivePath++ createDirectoryIfMissing True dPath++ let url = getGeckoDriverDownloadUrl version+ download url tarballPath+ decompress tarballPath dPath+ removeFile tarballPath+ +getGeckoDriverVersion :: IO String+getGeckoDriverVersion = do+ requestUrl <- parseRequest versionAPI+ let+ request+ = setRequestMethod "GET"+ $ setRequestHeader hUserAgent ["cli"]+ requestUrl+ response <- responseBody <$> httpLBS request+ let+ version' = eitherDecode response :: Either String A.Object+ maybeVersion = case version' of+ (Left err) -> error err+ (Right version'') -> AKM.lookup "tag_name" version''+ version = case maybeVersion of+ Nothing -> error "Couldn't parse response from Test.WebDriverWrapper.GeckoDriver's version API"+ (Just (A.String v))-> v+ (Just _) -> error "\"tag_name\" key isn't returning a string. Maybe Test.WebDriverWrapper.GeckoDriver's version API changed, consider opening a github issue."+ return $ T.unpack version++ where+ versionAPI = geckoDriverVersionSource
+ src/Test/WebDriverWrapper/Helpers.hs view
@@ -0,0 +1,45 @@+{-# LANGUAGE OverloadedStrings #-}+{- |+Module : Test.WebDriverWrapper.Helpers+Description : Generic functions.+-}+module Test.WebDriverWrapper.Helpers (download, decompress) where++import Test.WebDriverWrapper.Constants (fileFormat, geckoDriverPath)+import Network.HTTP.Simple (setRequestHeader, setRequestMethod, httpLBS)+import Network.HTTP.Types (hUserAgent)+import Network.HTTP.Conduit (Response(..), parseRequest)+import qualified Data.ByteString.Lazy as BS+import Codec.Archive.Zip (toArchive, fromArchive)+import qualified Codec.Compression.GZip as G+import qualified Codec.Archive.Tar as Tar+import System.Posix ( setFileMode, accessModes )++-- | Downloads from @url@ at @output@ filepath. +download :: String -> FilePath -> IO()+download url output = do+ requestUrl <- parseRequest url+ let+ request+ = setRequestMethod "GET"+ $ setRequestHeader hUserAgent ["cli"]+ requestUrl+ response <- responseBody <$> httpLBS request+ BS.writeFile output response++-- | Decompresses geckodriver's download, which comes in @.zip@ for Windows or @.tar.gz@ for everyone else. +-- Takes in the archive's filepath and the output filepath. +decompress :: FilePath -> FilePath -> IO()+decompress file outputPath = do+ case fileFormat of+ ".zip" -> do+ archive <- toArchive <$> BS.readFile file+ BS.writeFile outputPath $ fromArchive archive+ ".tar.gz" -> do+ tarball <- G.decompress <$> BS.readFile file+ Tar.unpack outputPath $ Tar.read tarball++ geckoDriver <- geckoDriverPath++ setFileMode geckoDriver accessModes+ _ -> error "unknown file"
+ src/Test/WebDriverWrapper/Selenium.hs view
@@ -0,0 +1,71 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE OverloadedStrings #-}++module Test.WebDriverWrapper.Selenium (startSelenium, getSeleniumIfNeeded) where++import Test.WebDriverWrapper.Constants (defaultSeleniumJarUrl, seleniumPath, downloadPath, geckoDriverPath, seleniumLogPath)+import Test.WebDriverWrapper.Helpers (download)+import System.Directory (createDirectoryIfMissing, doesFileExist)+import Control.Monad (unless)+import GHC.IO.Handle ( Handle, hGetLine, hClose )+import System.Process (ProcessHandle, createProcess)+import Data.String.Interpolate (i)+import System.IO (openFile)+import GHC.IO.IOMode (IOMode(..))+import Control.Retry (retryOnError, RetryStatus (..))+import System.IO.Error (isEOFError)+import UnliftIO.Retry ( constantDelay, limitRetries )+import Data.Foldable.Extra (orM)+import Data.List (isInfixOf)+import System.Process.Run (proc)++-- | Starts Selenium and waits for its ok message ( "Selenium Server is up and running" ) to show up at the log file.+-- Returns the handles for the Selenium process.+startSelenium :: IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)+startSelenium = do+ geckoPath <- geckoDriverPath+ selPath <- seleniumPath+ logFile <- seleniumLogPath+ writeFile logFile "" -- Create file if it doesn't exist, clears it if it does. Needs to happen for "logFileHasReadyMessage" to work correctly. + let+ selArgs = [[i|-Dwebdriver.gecko.driver=#{geckoPath}|], "-jar", selPath ,"-log", logFile]+ processParams = proc "java" selArgs+ processHandles <- createProcess processParams+ waitForSeleniumStart+ return processHandles++waitForSeleniumStart :: IO()+waitForSeleniumStart = do+ logFile <- seleniumLogPath+ fileHandle <- openFile logFile ReadMode+ succeeded <- retryOnError retryPolicy'+ (\_ e -> return $ isEOFError e)+ (\retryStatus -> if rsIterNumber retryStatus < 599 then logFileHasReadyMessage fileHandle else return False)+ hClose fileHandle+ unless succeeded $ error $ "Couldn't start Selenium successfully. Check " <> logFile <> " for more information."+ where+ -- waits a minute, retrying every 100ms+ retryPolicy' = constantDelay 100000 <> limitRetries 600++logFileHasReadyMessage :: Handle -> IO Bool+logFileHasReadyMessage fileHandle = orM remainingLines+ where+ remainingLines = repeat hasReadyMessage+ hasReadyMessage = (readyMessage `isInfixOf`) <$> hGetLine fileHandle++ readyMessage = "Selenium Server is up and running"++-- | Checks if @selenium@ is in the `downloadPath`. If not, download it. +getSeleniumIfNeeded :: IO ()+getSeleniumIfNeeded = do+ selPath <- seleniumPath+ hasSelenium <- doesFileExist selPath+ unless hasSelenium getSelenium++getSelenium :: IO ()+getSelenium = do+ downloadPath' <- downloadPath+ selPath <- seleniumPath+ createDirectoryIfMissing True downloadPath'+ download defaultSeleniumJarUrl selPath
+ webdriver-wrapper.cabal view
@@ -0,0 +1,63 @@+cabal-version: 2.2++-- This file has been generated from package.yaml by hpack version 0.36.0.+--+-- see: https://github.com/sol/hpack++name: webdriver-wrapper+version: 0.1.0.0+synopsis: Wrapper around the webdriver package that automatically manages Selenium+description: Please see the README on GitHub at <https://github.com/gtollini/webdriver-wrapper#readme>+category: browser, selenium, testing, web, webdriver, geckodriver+homepage: https://github.com/gtollini/webdriver-wrapper#readme+bug-reports: https://github.com/gtollini/webdriver-wrapper/issues+author: Gabriel Tollini+maintainer: gabrieltollini@hotmail.com+copyright: 2024 Gabriel Tollini+license: BSD-3-Clause+license-file: LICENSE+build-type: Simple+extra-doc-files:+ README.md+ CHANGELOG.md++source-repository head+ type: git+ location: https://github.com/gtollini/webdriver-wrapper++library+ exposed-modules:+ Test.WebDriverWrapper+ Test.WebDriverWrapper.Constants+ Test.WebDriverWrapper.GeckoDriver+ Test.WebDriverWrapper.Helpers+ Test.WebDriverWrapper.Selenium+ other-modules:+ Paths_webdriver_wrapper+ autogen-modules:+ Paths_webdriver_wrapper+ hs-source-dirs:+ src+ ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+ build-depends:+ aeson >=2.1.2.1 && <2.3+ , async >=2.2.5 && <2.3+ , base >=4.7 && <5+ , bytestring >=0.11.5 && <0.12+ , directory >=1.3.8 && <1.4+ , extra >=1.7.16 && <1.8+ , filepath >=1.4.300 && <1.5+ , http-conduit >=2.3.8 && <2.4+ , http-types >=0.12.4 && <0.13+ , process >=1.6.19 && <1.7+ , process-extras >=0.7.4 && <0.8+ , retry >=0.9.3 && <0.10+ , string-interpolate >=0.3.3 && <0.4+ , tar >=0.5.1.1 && <0.7+ , text >=2.0.2 && <2.1+ , unix >=2.8.4 && <2.9+ , unordered-containers >=0.2.20 && <0.3+ , webdriver >=0.12.0 && <0.13+ , zip-archive >=0.4.3 && <0.5+ , zlib >=0.6.3 && <0.8+ default-language: Haskell2010