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 +6/−4
- src/Test/WebDriverWrapper.hs +36/−16
- src/Test/WebDriverWrapper/ChromeDriver.hs +99/−0
- src/Test/WebDriverWrapper/Constants.hs +42/−2
- src/Test/WebDriverWrapper/GeckoDriver.hs +5/−5
- src/Test/WebDriverWrapper/Helpers.hs +29/−13
- src/Test/WebDriverWrapper/Selenium.hs +14/−5
- webdriver-wrapper.cabal +3/−1
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