packages feed

webdriver-wrapper 0.1.0.1 → 0.2.0.0

raw patch · 8 files changed

+234/−46 lines, 8 filesdep +vector

Dependencies added: vector

Files

README.md view
@@ -1,11 +1,13 @@ # 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 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. -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. +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 focuses 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. +You must have Java installed, since Selenium is distributed as a `.jar` file. I'm running OpenJDK 17 on my machine, and it just works.  ## 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. +The `wrappedRunSession` function is a drop-in replacement for `runSession`. It will download, if needed, Selenium and a webdriver to `~/.local/haskell-webdriver-wrapper/{your_architecture}` (on Linux) or `%APPDATA%/haskell-webdriver-wrapper/{your_architecture}` (on Windows). Then it starts Selenium and runs your `WD a` computation, closing Selenium whether WD succeeds or fails. ++For `runWD`, there are two functions: `wrappedFirefoxRunWD` and `wrappedChromeRunWD`. They're also drop-in replacements for `runWD`, and will also handle everything related to Selenium - the only difference being, as you can imagine, `wrappedFirefoxRunWD` runs your session on Firefox and `wrappedChromeRunWD` runs your session on Chrome. 
src/Test/WebDriverWrapper.hs view
@@ -1,41 +1,61 @@ {- |-Module : Core+Module : Test.WebDriverWrapper 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+{-# OPTIONS_GHC -Wno-missing-fields #-}+{-# LANGUAGE NamedFieldPuns #-}+module Test.WebDriverWrapper (wrappedRunSession, wrapWebDriverFunction, wrappedFirefoxRunWD, wrappedChromeRunWD) 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+    ( runSession,+      Capabilities(..),+      WDConfig(wdCapabilities),+      Browser(..) ) import Test.WebDriver.Types (WD, WDSession)-import Test.WebDriver.Config (WebDriverConfig) import Test.WebDriver.Monad (runWD) import Control.Exception (bracket)+import Test.WebDriverWrapper.ChromeDriver (getChromeDriverIfNeeded)  -- | 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) +-- Will download Selenium or the browser's webdriver (geckodriver or chromedriver) if any is missing. +wrappedRunSession :: WDConfig -> WD a -> IO a+wrappedRunSession conf wd = wrapWebDriverFunction (browser $ wdCapabilities conf) (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)+wrappedFirefoxRunWD ::  WDSession -> WD a -> IO a+wrappedFirefoxRunWD session wd = wrapWebDriverFunction (Firefox{}) (session, wd) (uncurry runWD) +-- | Same as `runWD`, but starts Selenium before execution and kills Selenium after execution. +-- Will download Selenium or Chrome's webdriver (chromedriver) if any is missing. +wrappedChromeRunWD ::  WDSession -> WD a -> IO a+wrappedChromeRunWD session wd = wrapWebDriverFunction (Chrome{}) (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+-- Will download Selenium and the Browser's webdriver (geckodriver or chromedriver) if any is missing. +wrapWebDriverFunction :: Browser -> a -> (a -> IO b) -> IO b+wrapWebDriverFunction browser' webdriverArgs webdriverFunction = do+    case browser' of+        Firefox {}             -> downloadIfMissingGecko+        Chrome  {chromeBinary} -> downloadIfMissingChrome chromeBinary+        _                      -> error "unsuported browser"+     bracket-        startSelenium+        (startSelenium browser')         cleanupProcess         (const $ webdriverFunction webdriverArgs)  -- | Dowloads Selenium or Firefox's webdriver (geckodriver) if they're missing. -downloadIfMissing :: IO()-downloadIfMissing =  concurrently_ getSeleniumIfNeeded getGeckoDriverIfNeeded+downloadIfMissingGecko :: IO()+downloadIfMissingGecko =  concurrently_ getSeleniumIfNeeded getGeckoDriverIfNeeded++-- | Dowloads Selenium or Firefox's webdriver (geckodriver) if they're missing. +-- Takes a @chromeBinary@'s path, whose @chromedriver@ version will match. +downloadIfMissingChrome :: Maybe FilePath -> IO()+downloadIfMissingChrome chromeBinary' =  concurrently_ getSeleniumIfNeeded $ getChromeDriverIfNeeded chromeBinary'
+ src/Test/WebDriverWrapper/ChromeDriver.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}++module Test.WebDriverWrapper.ChromeDriver (getChromeDriverIfNeeded) where++import Test.WebDriverWrapper.Constants (chromeDriverPath, downloadPath, chromeDriverVersionsUrl, chromeDriverArchIndex, chromeDriverArchivePath, chromeDriverArchiveDirectory)+import System.Directory (doesFileExist, createDirectoryIfMissing, copyFile, removeDirectoryRecursive, removeFile)+import Control.Monad (unless)+import Test.WebDriverWrapper.Helpers (download, decompressZip, evalUntillSuccess)+import Network.HTTP.Simple (parseRequest, setRequestMethod, httpLBS)+import Network.HTTP.Client.Conduit (Response(responseBody))+import Data.Aeson (eitherDecode)+import qualified Data.Aeson.KeyMap as AKM+import qualified Data.Aeson as A+import qualified Data.Vector as V+import qualified Data.Text as T+import GHC.Generics (Generic)+import System.FilePath ((</>))+import System.Process (readProcess)+import Data.Maybe (maybeToList)++-- | Checks if @chromedriver@ is in the `downloadPath`. If not, download it. +getChromeDriverIfNeeded :: Maybe FilePath -> IO()+getChromeDriverIfNeeded browserBinary = do+    chromeDriverPath' <- chromeDriverPath+    hasChromeDriver   <- doesFileExist chromeDriverPath'+    unless hasChromeDriver $ getChromeDriver browserBinary++getChromeDriver :: Maybe FilePath  -> IO()+getChromeDriver browserBinary = do+    dPath   <- downloadPath+    chromeVersion <- getChromeVersion browserBinary++    url <- getChromeDriverDownloadUrl $ T.pack chromeVersion+    chromeDriverArchivePath' <- chromeDriverArchivePath+    chromeDriverArchiveDirectory' <- chromeDriverArchiveDirectory+    chromeDriverPath' <- chromeDriverPath++    createDirectoryIfMissing True dPath++    download url chromeDriverArchivePath'+    decompressZip chromeDriverArchivePath' dPath+    copyFile (chromeDriverArchiveDirectory' </> "chromedriver") chromeDriverPath'+    +    removeDirectoryRecursive chromeDriverArchiveDirectory'+    removeFile chromeDriverArchivePath'+++getChromeDriverDownloadUrl :: T.Text -> IO String+getChromeDriverDownloadUrl chromeVersion = do+    requestUrl <- parseRequest chromeDriverVersionsUrl+    let+        request+            = setRequestMethod "GET"+            requestUrl+    response <- responseBody <$> httpLBS request+    let+        decodedResponse = eitherDecode response :: Either String ChromeDriverMain++        allVersions = case decodedResponse of+            (Left err)        -> error err+            (Right versions') ->  versions  versions'+        +        versionIndex = V.findIndexR ((==chromeVersion).version) allVersions+        versionDownloads = downloads . (allVersions V.!) <$> versionIndex++        maybeLastVersionUrl  =  do+            versionDownloads' <- versionDownloads+            chromedriver <- AKM.lookup "chromedriver" versionDownloads'+            platform <- chromedriver V.!? chromeDriverArchIndex+            AKM.lookup "url" platform++        url = case maybeLastVersionUrl of+            Nothing     -> error "Couldn't get chromedriver url!"+            (Just url') -> T.unpack url'+    return url++getChromeVersion :: Maybe FilePath -> IO String+getChromeVersion executableNames = do+    let candidates = maybeToList executableNames ++ ["google-chrome"] -- defaults to google-chrome in PATH's version. +    terminalOutput <- evalUntillSuccess $ readVersion <$> candidates+    return $ last $ words terminalOutput+    where+        readVersion exec = readProcess exec ["--version"] ""++-- To help out Aeson in parsing the JSON.+data ChromeDriverMain = ChromeDriverMain{+    timestamp :: T.Text,+    versions :: V.Vector ChromeDriverVersion+}+  deriving (Show, Generic, A.ToJSON, A.FromJSON)++data ChromeDriverVersion = ChromeDriverVersion{+    version :: T.Text,+    revision :: T.Text,+    downloads :: AKM.KeyMap (V.Vector (AKM.KeyMap T.Text))+}+  deriving (Show, Generic, A.ToJSON, A.FromJSON)
src/Test/WebDriverWrapper/Constants.hs view
@@ -7,8 +7,7 @@ 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+module Test.WebDriverWrapper.Constants (chromeDriverArchiveDirectory, chromeDriverArchivePath, chromeDriverArchIndex, chromeDriverVersionsUrl, chromeDriverPath, geckoDriverPath, defaultPath, defaultSeleniumJarUrl, desiredPlatform, getGeckoDriverDownloadUrl, geckoDriverVersionSource, downloadPath, geckoArchivePath, fileFormat, seleniumPath, seleniumLogPath) where  import Data.String.Interpolate (i) import qualified System.Info as SI@@ -34,6 +33,18 @@ geckoDriverPath :: IO FilePath geckoDriverPath = (</> "geckodriver") <$> downloadPath +-- | Intermediary path for the compressed version of chromedriver. Inside `downloadPath`.+chromeDriverArchivePath :: IO FilePath+chromeDriverArchivePath = (</> "chromedriver.zip") <$> downloadPath++-- | Where chromedriver initially gets unziped to+chromeDriverArchiveDirectory :: IO FilePath+chromeDriverArchiveDirectory = (</> chromeDriverRelativeZipPath) <$> downloadPath++-- | Path for chromedriver. Inside `downloadPath`. +chromeDriverPath :: IO FilePath+chromeDriverPath = (</> "chromedriver") <$> downloadPath+ -- | Path for selenium.jar. Inside `downloadPath`. seleniumPath :: IO FilePath seleniumPath = (</> "selenium.jar") <$> downloadPath@@ -58,6 +69,10 @@         platform = desiredPlatform         format = fileFormat +-- | API to get chromedriver's download url.+chromeDriverVersionsUrl :: String+chromeDriverVersionsUrl = "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json"+ -- Platform-dependent variables.  -- | Archive format for geckodriver's download. @.zip@ for Windows, @.tar.gz@ for everyone else.  fileFormat :: String@@ -99,3 +114,28 @@     ("linux", "i386")       -> "linux32"      _ -> "linux64"++-- | Index for `chromeDriverVersionsUrl`, which provides a list of urls where each platform is represented by an entry.+--  If the platform is not identified, @linux64@'s index is used.+chromeDriverArchIndex :: Int+chromeDriverArchIndex = case (SI.os, SI.arch) of+    ("linux", "x86_64")   -> 0+    ("darwin", "aarch64") -> 1+    ("darwin", "x86_64")  -> 2+    ("windows", "i386")   -> 3+    ("mingw32", "i386")   -> 3+    ("windows", "x86_64") -> 4++    _ -> 0++chromeDriverRelativeZipPath :: FilePath+chromeDriverRelativeZipPath = "chromedriver-" <> case (SI.os, SI.arch) of+    ("linux", "x86_64")   -> "linux64"+    ("darwin", "aarch64") -> "mac-arm64"+    ("darwin", "x86_64")  -> "max-x64"+    ("windows", "i386")   -> "win32"+    ("mingw32", "i386")   -> "win32"+    ("windows", "x86_64") -> "win64"++    _ -> "linux64"+    
src/Test/WebDriverWrapper/GeckoDriver.hs view
@@ -4,7 +4,7 @@  import qualified Data.Text as T import Network.HTTP.Simple (setRequestMethod, httpLBS, parseRequest, setRequestHeader)-import Test.WebDriverWrapper.Helpers (download, decompress)+import Test.WebDriverWrapper.Helpers (download, decompressGecko) import Test.WebDriverWrapper.Constants (getGeckoDriverDownloadUrl, geckoDriverVersionSource, geckoArchivePath, geckoDriverPath, downloadPath) import Network.HTTP.Client.Conduit (Response(responseBody)) import Data.Aeson (eitherDecode)@@ -25,14 +25,14 @@ getGeckoDriver = do     version <- getGeckoDriverVersion     dPath <- downloadPath-    tarballPath <- geckoArchivePath+    geckoArchivePath' <- geckoArchivePath      createDirectoryIfMissing True dPath      let url = getGeckoDriverDownloadUrl version-    download url tarballPath-    decompress tarballPath dPath-    removeFile tarballPath+    download url geckoArchivePath'+    decompressGecko geckoArchivePath' dPath+    removeFile geckoArchivePath'      getGeckoDriverVersion :: IO String getGeckoDriverVersion = do
src/Test/WebDriverWrapper/Helpers.hs view
@@ -3,17 +3,19 @@ Module : Test.WebDriverWrapper.Helpers Description : Generic functions. -}-module Test.WebDriverWrapper.Helpers (download, decompress) where+module Test.WebDriverWrapper.Helpers (download, decompressGecko, decompressZip, evalUntillSuccess) 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 Codec.Archive.Zip (toArchive, extractFilesFromArchive, ZipOption (OptDestination)) import qualified Codec.Compression.GZip as G import qualified Codec.Archive.Tar as Tar import System.Posix ( setFileMode, accessModes )+import Control.Exception (catch)+import Control.Exception.Base (SomeException)  -- | Downloads from @url@ at @output@ filepath.  download :: String -> FilePath -> IO()@@ -29,17 +31,31 @@  -- | 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+decompressGecko :: FilePath -> FilePath -> IO()+decompressGecko 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+        ".zip"    -> decompressZip file outputPath+        ".tar.gz" -> decompressTarball file outputPath+        _ -> error "unknown file" -            geckoDriver <- geckoDriverPath+-- | Decompresses a Zip file.+-- Takes in the archive's filepath and the output filepath. +decompressZip :: FilePath -> FilePath -> IO()+decompressZip file outputPath = do+    archive <- toArchive <$> BS.readFile file+    extractFilesFromArchive [OptDestination outputPath] archive -            setFileMode geckoDriver accessModes-        _ -> error "unknown file"+-- | Deccompresses a .tar.gz file.+-- Takes in the archive's filepath and the output filepath. +decompressTarball :: FilePath -> FilePath -> IO()+decompressTarball file outputPath = do+    tarball <- G.decompress <$> BS.readFile file+    Tar.unpack outputPath $ Tar.read tarball+    geckoDriver <- geckoDriverPath+    setFileMode geckoDriver accessModes++-- | Evaluates each IO individually and returns the first one that doesn't throw an error. +-- Throws an error if none succeed. +evalUntillSuccess :: [IO String] -> IO String+evalUntillSuccess [] = error "None succeeded on evalUntillSuccess!"+evalUntillSuccess (x:xs) = catch x (const $ evalUntillSuccess xs :: SomeException -> IO String)
src/Test/WebDriverWrapper/Selenium.hs view
@@ -4,7 +4,7 @@  module Test.WebDriverWrapper.Selenium (startSelenium, getSeleniumIfNeeded) where -import Test.WebDriverWrapper.Constants (defaultSeleniumJarUrl, seleniumPath, downloadPath, geckoDriverPath, seleniumLogPath)+import Test.WebDriverWrapper.Constants (defaultSeleniumJarUrl, seleniumPath, downloadPath, geckoDriverPath, seleniumLogPath, chromeDriverPath) import Test.WebDriverWrapper.Helpers (download) import System.Directory (createDirectoryIfMissing, doesFileExist) import Control.Monad (unless)@@ -19,17 +19,26 @@ import Data.Foldable.Extra (orM) import Data.List (isInfixOf) import System.Process.Run (proc)+import Test.WebDriver.Capabilities (Browser)+import Test.WebDriver (Browser(..))  -- | Starts Selenium and waits for its ok message ( "Selenium Server is up and running" ) to show up at the log file.+-- Takes in a `Browser` (since it needs to use the correct webdriver). -- Returns the handles for the Selenium process.-startSelenium :: IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)-startSelenium = do-    geckoPath <- geckoDriverPath+startSelenium :: Browser -> IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)+startSelenium browser = do+    geckoPath  <- geckoDriverPath+    chromePath <- chromeDriverPath     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]+        webdriverArgs = case browser of +            Firefox {} -> [i|-Dwebdriver.gecko.driver=#{geckoPath}|]+            Chrome {}  -> [i|-Dwebdriver.chrome.driver=#{chromePath}|]+            _          -> error "unsuported browser"++        selArgs = webdriverArgs : ["-jar", selPath ,"-log", logFile]         processParams = proc "java" selArgs     processHandles <- createProcess processParams     waitForSeleniumStart
webdriver-wrapper.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           webdriver-wrapper-version:        0.1.0.1+version:        0.2.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@@ -28,6 +28,7 @@ library   exposed-modules:       Test.WebDriverWrapper+      Test.WebDriverWrapper.ChromeDriver       Test.WebDriverWrapper.Constants       Test.WebDriverWrapper.GeckoDriver       Test.WebDriverWrapper.Helpers@@ -57,6 +58,7 @@     , text >=2.0.2 && <2.2     , unix >=2.8.4 && <2.9     , unordered-containers >=0.2.20 && <0.3+    , vector >=0.13.1 && <0.14     , webdriver >=0.12.0 && <0.13     , zip-archive >=0.4.3 && <0.5     , zlib >=0.6.3 && <0.8