sandwich-webdriver-0.4.0.2: src/Test/Sandwich/WebDriver.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}
{-|
Introduce [WebDriver](https://www.selenium.dev/documentation/webdriver/) servers and sessions.
-}
module Test.Sandwich.WebDriver (
-- * Introducing a WebDriver server
introduceWebDriver
, introduceWebDriverViaNix
, introduceWebDriverViaNix'
-- * Non-Nix dependency fetching
-- | When you aren't using Nix, these types specify how to obtain the necessary dependencies.
, defaultWebDriverDependencies
, WebDriverDependencies(..)
-- * Running an example in a given session
-- | Once you have a 'WebDriver' in context, you can run one or more sessions.
-- Each session will open an independent browser instance.
, withSession
, withSession1
, withSession2
-- * Managing sessions
, getSessions
, closeCurrentSession
, closeSession
, closeAllSessions
, closeAllSessionsExcept
, SessionName
-- * Lower-level allocation functions
, allocateWebDriver
, cleanupWebDriver
, introduceBrowserDependenciesViaNix
, introduceBrowserDependenciesViaNix'
, introduceWebDriver'
, addCommandLineOptionsToWdOptions
-- * Context types
-- ** WebDriver
, webdriver
, TestWebDriverContext
, HasTestWebDriverContext
-- ** WebDriverSession
, webdriverSession
, WebDriverSession
, HasWebDriverSessionContext
-- * Shorthands
-- | These are used to make type signatures shorter.
, BaseMonad
, ContextWithBaseDeps
, ContextWithWebdriverDeps
, WebDriverMonad
, WebDriverSessionMonad
-- * On demand options
, OnDemandOptions
, defaultOnDemandOptions
-- * Re-exports
, module Test.Sandwich.WebDriver.Config
) where
import Control.Monad
import Control.Monad.Catch (MonadMask)
import Control.Monad.IO.Class
import Control.Monad.Reader
import qualified Data.Map as M
import Data.Maybe
import Data.String.Interpolate
import qualified Data.Text as T
import Lens.Micro
import System.FilePath
import System.IO.Temp
import Test.Sandwich
import Test.Sandwich.Contexts.Nix
import Test.Sandwich.WebDriver.Binaries
import Test.Sandwich.WebDriver.Config
import Test.Sandwich.WebDriver.Internal.Action
import Test.Sandwich.WebDriver.Internal.Capabilities.Extra
import Test.Sandwich.WebDriver.Internal.Dependencies
import Test.Sandwich.WebDriver.Internal.StartWebDriver
import Test.Sandwich.WebDriver.Internal.Types
import Test.Sandwich.WebDriver.Internal.Util (makeUUID)
import Test.Sandwich.WebDriver.Types
import Test.Sandwich.WebDriver.Video (recordVideoIfConfigured)
import qualified Test.WebDriver as W
import qualified Test.WebDriver.Capabilities as W
import UnliftIO.Directory
import UnliftIO.Exception (bracket)
import UnliftIO.MVar
-- | Introduce a 'WebDriver', using the given 'WebDriverDependencies'.
-- A good default is 'defaultWebDriverDependencies'.
introduceWebDriver :: forall context m. (
BaseMonad m context, HasSomeCommandLineOptions context
)
-- | How to obtain dependencies
=> WebDriverDependencies
-> WdOptions
-> SpecFree (ContextWithWebdriverDeps context) m () -> SpecFree context m ()
introduceWebDriver wdd wdOptions = introduceWebDriver' wdd alloc wdOptions
where
alloc wdOptions' = do
clo <- getSomeCommandLineOptions
allocateWebDriver (addCommandLineOptionsToWdOptions clo wdOptions') onDemandOptions
onDemandOptions = OnDemandOptions {
ffmpegToUse = webDriverFfmpeg wdd
, xvfbToUse = xvfbDependenciesSpecXvfb $ webDriverXvfb wdd
}
-- | Introduce a 'WebDriver' using the current 'NixContext'.
-- This will pull everything required from the configured Nixpkgs snapshot.
introduceWebDriverViaNix :: forall m context. (
BaseMonad m context, HasSomeCommandLineOptions context, HasNixContext context
)
-- | Options
=> WdOptions
-> SpecFree (ContextWithWebdriverDeps context) m ()
-> SpecFree context m ()
introduceWebDriverViaNix = introduceWebDriverViaNix' (defaultNodeOptions { nodeOptionsVisibilityThreshold = defaultFileContextVisibilityThreshold })
-- | Same as 'introduceWebDriverViaNix', but allows passing custom 'NodeOptions'.
introduceWebDriverViaNix' :: forall m context. (
BaseMonad m context, HasSomeCommandLineOptions context, HasNixContext context
)
=> NodeOptions
-- | Options
-> WdOptions
-> SpecFree (ContextWithWebdriverDeps context) m ()
-> SpecFree context m ()
introduceWebDriverViaNix' nodeOptions wdOptions =
introduceBrowserDependenciesViaNix' nodeOptions
-- We use 'introduceWith' here because the alloc function will start an Async to read the logs from the Selenium
-- process and log them. If we were to use 'introduce', the test_logs.txt file would be closed after the allocate section
-- and the write handle would become invalid.
. introduceWith "Introduce WebDriver session" webdriver (\action -> bracket alloc cleanupWebDriver (void . action))
where
alloc = do
clo <- getSomeCommandLineOptions
nc <- getContext nixContext
let onDemandOptions = OnDemandOptions {
ffmpegToUse = UseFfmpegFromNixpkgs nc
, xvfbToUse = UseXvfbFromNixpkgs nc
}
allocateWebDriver (addCommandLineOptionsToWdOptions clo wdOptions) onDemandOptions
-- | Same as 'introduceWebDriver', but with a controllable allocation callback.
introduceWebDriver' :: forall m context. (
BaseMonad m context, HasSomeCommandLineOptions context
)
-- | Dependencies
=> WebDriverDependencies
-> (WdOptions -> ExampleT (ContextWithBaseDeps context) m TestWebDriverContext)
-> WdOptions
-> SpecFree (ContextWithWebdriverDeps context) m () -> SpecFree context m ()
introduceWebDriver' (WebDriverDependencies {..}) alloc wdOptions =
introduce "Introduce browser dependencies" browserDependencies (getBrowserDependencies webDriverDependencyBrowser) (const $ return ())
-- We use 'introduceWith' deliberately here instead of 'introduce'. See comment on above.
. introduceWith "Introduce WebDriver session" webdriver (\action -> bracket (alloc wdOptions) cleanupWebDriver (void . action))
-- | Allocate a WebDriver using the given options.
allocateWebDriver :: (
BaseMonad m context, HasBrowserDependencies context
)
-- | Options
=> WdOptions
-> OnDemandOptions
-> ExampleT context m TestWebDriverContext
allocateWebDriver wdOptions (OnDemandOptions {..}) = do
runRoot <- fromMaybe "/tmp" <$> getCurrentFolder
-- Create a unique name for this webdriver so the folder for its log output doesn't conflict with any others
webdriverName <- ("webdriver_" <>) <$> liftIO makeUUID
-- Directory to log everything for this webdriver
let webdriverRoot = runRoot </> (T.unpack webdriverName)
liftIO $ createDirectoryIfMissing True webdriverRoot
-- Directory to hold any downloads
let downloadDir = webdriverRoot </> "Downloads"
liftIO $ createDirectoryIfMissing True downloadDir
(baseCaps, driverConfig) <- getContext browserDependencies >>= \case
BrowserDependenciesChrome {..} -> do
let caps = W.defaultCaps {
W._capabilitiesBrowserName = Just "chrome"
, W._capabilitiesGoogChromeOptions = Just $
W.defaultChromeOptions
& set W.chromeOptionsBinary (Just browserDependenciesChromeChrome)
}
let driverConfig = W.DriverConfigChromedriver {
driverConfigChromedriver = browserDependenciesChromeChromedriver
, driverConfigChrome = browserDependenciesChromeChrome
#if MIN_VERSION_webdriver(0,14,0)
, driverConfigLogDir = Just runRoot
#else
, driverConfigLogDir = runRoot
#endif
, driverConfigChromedriverFlags = chromedriverExtraFlags wdOptions
}
return (caps, driverConfig)
BrowserDependenciesFirefox {..} -> do
let ffOptions = W.defaultFirefoxOptions
& set W.firefoxOptionsBinary (Just browserDependenciesFirefoxFirefox)
let caps = W.defaultCaps {
W._capabilitiesBrowserName = Just "firefox"
, W._capabilitiesMozFirefoxOptions = Just ffOptions
}
profileRootDir <- liftIO $ createTempDirectory webdriverRoot "geckodriver-profile-root"
let driverConfig = W.DriverConfigGeckodriver {
driverConfigGeckodriver = browserDependenciesFirefoxGeckodriver
, driverConfigFirefox = browserDependenciesFirefoxFirefox
#if MIN_VERSION_webdriver(0,14,0)
, driverConfigLogDir = Just runRoot
#else
, driverConfigLogDir = runRoot
#endif
, driverConfigGeckodriverFlags = "--profile-root" : profileRootDir : geckodriverExtraFlags wdOptions
-- , driverConfigGeckodriverFlags = geckodriverExtraFlags wdOptions
}
return (caps, driverConfig)
-- Final extra capabilities configuration
finalCaps <- pure baseCaps
>>= configureChromeNoSandbox wdOptions
>>= configureHeadlessChromeCapabilities wdOptions (runMode wdOptions)
>>= configureHeadlessFirefoxCapabilities wdOptions (runMode wdOptions)
>>= configureChromeDownloadCapabilities downloadDir
>>= configureFirefoxDownloadCapabilities downloadDir
wdc <- W.mkEmptyWebDriverContext
TestWebDriverContext
<$> pure (T.unpack webdriverName)
<*> pure wdc
<*> pure (wdOptions { capabilities = finalCaps })
<*> liftIO (newMVar mempty)
<*> pure driverConfig
<*> pure downloadDir
<*> pure ffmpegToUse
<*> newMVar OnDemandNotStarted
<*> pure xvfbToUse
<*> newMVar OnDemandNotStarted
-- | Clean up the given WebDriver.
cleanupWebDriver :: (BaseMonad m context) => TestWebDriverContext -> ExampleT context m ()
cleanupWebDriver sess = do
closeAllSessions sess
stopWebDriver sess
-- | Run a given example using a given Selenium session.
withSession :: forall m context a. (
MonadMask m
, HasBaseContext context, HasSomeCommandLineOptions context, WebDriverMonad m context
)
-- | Session to run
=> SessionName
-> ExampleT (LabelValue "webdriverSession" WebDriverSession :> context) m a
-> ExampleT context m a
withSession sessionName action = do
TestWebDriverContext {..} <- getContext webdriver
-- Create new session if necessary (this can throw an exception)
sess <- modifyMVar wdSessionMap $ \sessionMap -> case M.lookup sessionName sessionMap of
Just sess -> return (sessionMap, sess)
Nothing -> do
finalCaps <- pure (capabilities wdOptions)
>>= configureChromeUserDataDir
debug [i|Creating session '#{sessionName}'|]
sess <- W.startSession wdContext wdDriverConfig finalCaps sessionName
return (M.insert sessionName sess sessionMap, sess)
pushContext webdriverSession (sessionName, sess) $
recordVideoIfConfigured sessionName
action
-- | Convenience function. @withSession1 = withSession "session1"@.
withSession1 :: (
MonadMask m
, HasBaseContext context, HasSomeCommandLineOptions context, WebDriverMonad m context
)
-- | Wrapped action
=> ExampleT (LabelValue "webdriverSession" WebDriverSession :> context) m a
-> ExampleT context m a
withSession1 = withSession "session1"
-- | Convenience function. @withSession2 = withSession "session2"@.
withSession2 :: (
MonadMask m
, HasBaseContext context, HasSomeCommandLineOptions context, WebDriverMonad m context
)
-- | Wrapped action
=> ExampleT (LabelValue "webdriverSession" WebDriverSession :> context) m a
-> ExampleT context m a
withSession2 = withSession "session2"
-- | Get all existing session names.
getSessions :: (MonadReader context m, WebDriverMonad m context) => m [SessionName]
getSessions = do
TestWebDriverContext {..} <- getContext webdriver
M.keys <$> liftIO (readMVar wdSessionMap)
-- | Merge the options from the 'CommandLineOptions' into some 'WdOptions'.
addCommandLineOptionsToWdOptions :: SomeCommandLineOptions -> WdOptions -> WdOptions
addCommandLineOptionsToWdOptions (SomeCommandLineOptions (CommandLineOptions {optWebdriverOptions=(CommandLineWebdriverOptions {..})})) wdOptions@(WdOptions {..}) = wdOptions {
runMode = case optDisplay of
Nothing -> runMode
Just Headless -> RunHeadless defaultHeadlessConfig
Just Xvfb -> RunInXvfb (defaultXvfbConfig { xvfbStartFluxbox = optFluxbox })
Just Current -> Normal
, chromeNoSandbox = optChromeNoSandbox
}