hspec-webdriver (empty) → 0.1.0
raw patch · 6 files changed
+499/−0 lines, 6 filesdep +HUnitdep +basedep +hashablesetup-changed
Dependencies added: HUnit, base, hashable, hspec, lifted-base, stm, text, transformers, unordered-containers, webdriver
Files
- LICENSE +20/−0
- README.md +5/−0
- Setup.hs +2/−0
- Test/Hspec/WebDriver.hs +219/−0
- Test/Hspec/WebDriver/Internal.hs +213/−0
- hspec-webdriver.cabal +40/−0
+ LICENSE view
@@ -0,0 +1,20 @@+Copyright (c) 2013 John Lenz++Permission is hereby granted, free of charge, to any person obtaining+a copy of this software and associated documentation files (the+"Software"), to deal in the Software without restriction, including+without limitation the rights to use, copy, modify, merge, publish,+distribute, sublicense, and/or sell copies of the Software, and to+permit persons to whom the Software is furnished to do so, subject to+the following conditions:++The above copyright notice and this permission notice shall be+included in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ README.md view
@@ -0,0 +1,5 @@+This package integrates [hspec](http://hspec.github.io) with+[hs-webdriver](https://hackage.haskell.org/package/webdriver), allowing you to write webdriver tests+using hspec. This package contains no code testing the `hspec-webdriver` package itself. The+`webdriver-angular` package contains some test code which test both the Angular webdriver commands+in `webdriver-angular` and some tests of functions in `hspec-webdriver`.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ Test/Hspec/WebDriver.hs view
@@ -0,0 +1,219 @@+{-# LANGUAGE OverloadedStrings, FlexibleInstances, DeriveDataTypeable, TypeFamilies #-}+-- | Write hspec tests that are webdriver tests, automatically managing the webdriver sessions.+--+-- This module re-exports functions from "Test.Hspec" and "Test.WebDriver.Commands" and it is+-- intended that you just import @Test.Hspec.WebDriver@. If you need to import @Test.Hspec@ or+-- @Test.WebDriver@, you should do so using a qualified import.+--+-- >{-# LANGUAGE OverloadedStrings #-}+-- >module XKCD where+-- >+-- >import Test.Hspec.WebDriver+-- >+-- >main :: IO ()+-- >main = hspec $+-- > describe "XKCD Tests" $ do+-- > it "checks hover text of 327" $ using Firefox $ do+-- > openPage "http://www.xkcd.com/327/"+-- > e <- findElem $ ByCSS "div#comic > img"+-- > e `shouldBeTag` "img"+-- > e `shouldHaveAttr` ("title", "Her daughter is named Help I'm trapped in a driver's license factory.")+-- >+-- > parallel $ it "checks title of 303" $ using [Firefox, Chrome] $ do+-- > openPage "http://www.xkcd.com/303/"+-- > e <- findElem $ ById "ctitle"+-- > e `shouldBeTag` "div"+-- > e `shouldHaveText` "Compiling"+-- >+-- > it "checks image of 1310" pending+--+-- The above code assumes selenium-server-standalone is running on @127.0.0.1:4444@ at path+-- @\/wd\/hub@ (this is the default). You can configure this using `createSessionManager'`. +module Test.Hspec.WebDriver(+ -- * Webdriver+ BrowserDefaults(..)+ , it+ , Using(..)+ , pending+ , pendingWith+ , WdExpectation(..)++ -- * Expectations+ , shouldBe+ , shouldBeTag+ , shouldHaveText+ , shouldHaveAttr+ , shouldReturn+ , shouldThrow++ -- * Session Manager+ , createSessionManager+ , createSessionManager'++ -- * Custom Capabilities+ , TestCapabilities(..)++ -- * Re-exports from hspec+ , hspec+ , Spec+ , describe+ , context+ , parallel+ -- $beforeTodo++ -- * Re-exports from "Test.WebDriver"+ , WD+ , liftIO+ , module Test.WebDriver.Commands++ -- * Internal+ , withCaps+) where++import Control.Exception.Lifted (try, Exception)+import Control.Monad.IO.Class (liftIO)+import Data.Typeable (Typeable)+import Test.HUnit (assertEqual, assertFailure)+import Test.Hspec hiding (shouldReturn, shouldBe, shouldSatisfy, shouldThrow, it, pending, pendingWith)+import Test.Hspec.Core (Result(..), fromSpecList, SpecTree(..), Item(..), Params)+import Test.WebDriver hiding (Browser(..))+import Test.WebDriver.Commands+import qualified Test.WebDriver as W+import qualified Test.Hspec as H+import qualified Data.Text as T++import Test.Hspec.WebDriver.Internal++-- | Webdriver expectations consist of a set of browser 'Capabilities' to use and the actual test as+-- a 'W.WD' monad. The browser capabilities are specified by an enumeration which is an instance of+-- 'TestCapabilities'. The @BrowserDefaults@ enumeration provides items that represent the default set of+-- capabilities for each browser. When creating new sessions, the 'defaultCaps' are used. Also,+-- any existing session (which exists at program startup) which matches the browser is used, no+-- matter the actual capabilities.+--+-- To obtain more control over the capabilities (e.g. to test multiple versions of IE or to test+-- Firefrox without javascript), you should @import Test.Hspec.WebDriver hiding (BrowserDefaults)@+-- and then create your own enumeration which is an instance of 'TestCapabilities' and 'Using'.+data BrowserDefaults = Firefox | Chrome | IE | Opera | IPhone | IPad | Android+ deriving (Eq, Show, Enum, Bounded, Typeable)++instance TestCapabilities BrowserDefaults where+ matchesCaps Firefox (Capabilities { browser = W.Firefox _ _ _ }) = True+ matchesCaps Chrome (Capabilities { browser = W.Chrome _ _ _ _ }) = True+ matchesCaps IE (Capabilities { browser = W.IE _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ }) = True+ matchesCaps Opera (Capabilities { browser = W.Opera _ _ _ _ _ _ _ _ _ _ _ _ }) = True+ matchesCaps IPhone (Capabilities { browser = W.IPhone}) = True+ matchesCaps IPad (Capabilities { browser = W.IPad }) = True+ matchesCaps Android (Capabilities { browser = W.Android }) = True+ matchesCaps _ _ = False++ newCaps Firefox = return $ defaultCaps { browser = firefox }+ newCaps Chrome = return $ defaultCaps { browser = chrome }+ newCaps IE = return $ defaultCaps { browser = ie }+ newCaps Opera = return $ defaultCaps { browser = opera }+ newCaps IPhone = return $ defaultCaps { browser = iPhone }+ newCaps IPad = return $ defaultCaps { browser = iPad }+ newCaps Android = return $ defaultCaps { browser = android }++-- | A webdriver expectation is either an action and a list of capabilities (which should be an instance of+-- 'TestCapabilities') or a pending message.+data WdExpectation cap = WdTest [cap] (WD ())+ | WdPending (Maybe String)++-- | Evaluate an expectation to a list of the function expected by hspec.+evaluateWd :: (Show cap, TestCapabilities cap) => WdExpectation cap -> [(String,Params -> (IO () -> IO ()) -> IO Result)]+evaluateWd (WdPending msg) = [("", \_ _ -> return $ Pending msg)]+evaluateWd (WdTest cs test) = map mkItem cs+ where+ mkItem c = ("using " ++ show c, eval c)++ eval :: TestCapabilities c => c -> params -> (IO () -> IO ()) -> IO Result+ eval c _ action = do action (runWD defaultSession $ withCaps c test)+ return Success++-- | A typeclass of things which can be converted to a 'WdExpectation'. This is the primary method+-- to create expectations to pass to 'it'. Both a single 'BrowserDefaults' and a list+-- of 'BrowserDefaults' can be used.+--+-- >it "opens the home page" $ using Firefox $ do+-- > ...+-- >it "opens the users page" $ using [Firefox, Chrome] $ do+-- > ...+class Using a where+ type UsingCapabilities a :: *+ using :: a -> WD () -> WdExpectation (UsingCapabilities a)++instance Using BrowserDefaults where+ type UsingCapabilities BrowserDefaults = BrowserDefaults+ using d = WdTest [d]++instance Using [BrowserDefaults] where+ type UsingCapabilities [BrowserDefaults] = BrowserDefaults+ using = WdTest++-- | Create a spec from a webdriver expectation.+--+-- The webdriver expectation consists of a list of browser capabilities and the actual test. The+-- test will be executed once for each set of capabilities in the list. By default, the tests will be+-- executed serially. You can use 'parallel' to execute the test on all capabilities in parallel.+--+-- To run the test, a webdriver session is allocated from a pool of sessions (in a thread-safe+-- manner). The pools will be initialized automatically from existing sessions the first time a+-- test is run; you can explicitly create the pools using 'createSessionManager'.+it :: (Show cap, TestCapabilities cap) => String -> WdExpectation cap -> Spec+it msg a = spec+ where+ mkItem m f = SpecItem Item { itemIsParallelizable = False+ , itemRequirement = m+ , itemExample = f+ }+ spec = case evaluateWd a of+ [] -> fromSpecList []+ [("",f)] -> fromSpecList [mkItem (msg) f]+ [(m,f)] -> fromSpecList [mkItem (msg ++ " " ++ m) f]+ ss -> describe msg $ fromSpecList $ map (uncurry mkItem) ss++-- | A 'WdExpectation' that is pending.+pending :: WdExpectation ()+pending = WdPending Nothing++-- | A 'WdExpectation' that is pending, with a message.+pendingWith :: String -> WdExpectation ()+pendingWith = WdPending . Just++-- | 'H.shouldBe' lifted into the 'WD' monad.+shouldBe :: (Show a, Eq a) => a -> a -> WD ()+x `shouldBe` y = liftIO $ x `H.shouldBe` y++-- | Asserts that the given element matches the given tag.+shouldBeTag :: Element -> T.Text -> WD ()+e `shouldBeTag` name = do+ t <- tagName e+ liftIO $ assertEqual ("tag of " ++ show e) name t++-- | Asserts that the given element has the given text.+shouldHaveText :: Element -> T.Text -> WD ()+e `shouldHaveText` txt = do+ t <- getText e+ liftIO $ assertEqual ("text of " ++ show e) txt t++-- | Asserts that the given elemnt has the attribute given by @(attr name, value)@.+shouldHaveAttr :: Element -> (T.Text, T.Text) -> WD ()+e `shouldHaveAttr` (a, txt) = do+ t <- attr e a+ liftIO $ assertEqual ("attribute " ++ T.unpack a ++ " of " ++ show e) (Just txt) t++-- | Asserts that the action returns the expected result.+shouldReturn :: (Show a, Eq a) => WD a -> a -> WD ()+action `shouldReturn` expected = action >>= (\a -> liftIO $ a `H.shouldBe` expected)++-- | Asserts that the action throws an exception.+shouldThrow :: (Show e, Eq e, Exception e) => WD a -> e -> WD ()+shouldThrow w expected = do+ r <- try w+ case r of+ Left err -> err `shouldBe` expected+ Right _ -> liftIO $ assertFailure $ "did not get expected exception " ++ show expected++-- $beforeTodo+-- Future TODO: lift 'before', 'after', and 'around' into the 'WD' monad.
+ Test/Hspec/WebDriver/Internal.hs view
@@ -0,0 +1,213 @@+{-# LANGUAGE ExistentialQuantification #-}+module Test.Hspec.WebDriver.Internal(+ TestCapabilities(..)+ , createSessionManager+ , createSessionManager'+ , withCaps+) where++import Control.Concurrent.STM+import Control.Exception.Lifted (finally, onException)+import Control.Monad.IO.Class (liftIO)+import Data.Hashable+import Data.List (partition)+import Data.Maybe (fromJust)+import Data.Typeable+import Data.Word (Word16)+import System.IO.Unsafe (unsafePerformIO)+import Test.WebDriver+import Test.WebDriver.Classes+import qualified Data.HashMap.Lazy as M++-- | Provides information about the browser capabilities used for testing. If you want more control+-- over capabilities, you should hide @BrowserDefaults@ and then make+-- an enumeration of all the webdriver capabilities you will be testing with. For example,+--+-- >data TestCaps = Firefox+-- > | FirefoxWithoutJavascript+-- > | Chrome+-- > | IE8+-- > | IE9+-- > deriving (Show, Eq, Bounded, Enum, Typeable)+--+-- @TestCaps@ must then be made an instance of @TestCapabilities@. Also, instances of @Using@+-- should be created.+class (Eq c, Enum c, Typeable c) => TestCapabilities c where++ -- | Check if the 'Capabilities' match your enumeration. Note that these capabilities+ -- will be the actual capabilities (with things like version information filled in)+ -- so you should not use @==@ to compare capabilities, only check the actual capabilities you+ -- care about.+ matchesCaps :: c -> Capabilities -> Bool++ -- | The capabilities to pass to 'createSession' when no existing session is found.+ newCaps :: c -> WD Capabilities++-- | This instance is used for pending messages, no capabilities are matched or created.+instance TestCapabilities () where+ matchesCaps () _ = False+ newCaps () = error "Cannot create caps for ()"++data SomeCap = forall c. TestCapabilities c => SomeCap c++instance Eq SomeCap where+ SomeCap c1 == SomeCap c2 = case cast c2 of+ Just c2' -> c1 == c2'+ Nothing -> False++instance Hashable SomeCap where+ hashWithSalt i (SomeCap c) = hashUsing fromEnum i c++data ManagedSessions = ManagedSessions+ { maxSessions :: Int+ -- ^ maximum number of sessions that should be created+ , managedSessions :: M.HashMap SomeCap (TVar ([SessionId],Int))+ -- ^ For each cap, store the list of unused sessions and a count of in-use sessions.+ , initialSessions :: [(SessionId,Capabilities)]+ -- ^ sessions which existed at program start and are not yet assigned.++ -- | settings for session+ , mwdHost :: String+ , mwdPort :: Word16+ , mwdBasePath :: String+ }++-- | Stores the managed sessions+sessionManager :: TVar (Maybe ManagedSessions)+{-# NOINLINE sessionManager #-}+sessionManager = unsafePerformIO (newTVarIO Nothing)++-- | Create and set the session manager+createWdMan :: Int -> Maybe (String, Word16, String) -> WD ()+createWdMan maxSess mSettings = do+ let (host,port,bpath) = case mSettings of+ Just s -> s+ Nothing -> (wdHost defaultSession, wdPort defaultSession, wdBasePath defaultSession)+ sess <- sessions++ let manager = ManagedSessions maxSess M.empty sess host port bpath++ liftIO $ atomically $ do+ mm <- readTVar sessionManager+ case mm of+ Just _ -> return () -- manager has already been created+ Nothing -> writeTVar sessionManager $ Just manager++-- | Create a new session manager using the default webdriver host (127.0.0.1), port (4444), and+-- basepath (@\/wd\/hub@).+--+-- The session manager hands out sessions to tests (in a thread-safe manner). Threads ask for+-- sessions by an enumeration which is an instance of 'TestCapabilities', and the manager stores a+-- pool of sessions for each enumeration item. When calling 'createSessionManager', the already+-- existing sessions are loaded and used as the initial sessions in the pools. If a thread asks for+-- a session but none is available, one of two things happens: if the total number of sessions for+-- this enumeration item is larger than the argument to 'createSessionManager', the thread will+-- block until a session is available. If the total number of sessions for this enumeration item is+-- smaller, a new session will be created. This is only relevant if you run tests in parallel,+-- since when running tests serially at most one session will be in use at any one time in any case.+-- Note that sessions are never closed by the manager.+--+-- If you do not call 'createSessionManager', when the very first test is run a new manager will be+-- created where the maximum number of sessions per enumeration item is one.+createSessionManager :: Int -- ^ threshold number of sessions per enumeration item beyond which new + -- sessions are no longer created. Note you can set this to zero so+ -- that new sessions are never created; the only sessions used will be+ -- those that already exist.+ -> IO ()+createSessionManager maxSess = runWD defaultSession $ createWdMan maxSess Nothing++-- | Same as 'createSessionManager' but allows you to specify the webdriver host, port, and base+-- path for all sessions.+createSessionManager' :: Int -- ^ threshold number of sessions per enumeration item+ -> String -- ^ host+ -> Word16 -- ^ port+ -> String -- ^ base path+ -> IO ()+createSessionManager' maxSess host port bpath = do+ let sess = WDSession { wdHost = host+ , wdPort = port+ , wdBasePath = bpath+ , wdSessId = Nothing+ , lastHTTPRequest = Nothing+ }+ runWD sess $ createWdMan maxSess $ Just (host, port, bpath)++-- | Create a session+createSessionId :: SomeCap -> WD SessionId+createSessionId (SomeCap c) = do+ caps <- newCaps c+ sess <- createSession caps+ return $ fromJust $ wdSessId sess++-- | Searches for a session. Returns the action to be used to obtain the session.+findSession :: SomeCap -> ManagedSessions -> STM (WD SessionId)+findSession sc@(SomeCap c) m =+ case M.lookup sc $ managedSessions m of+ Just tvar -> do+ (sess,count) <- readTVar tvar+ case sess of+ (s:ss) -> do writeTVar tvar (ss, count + 1)+ return $ return s++ [] | count >= maxSessions m -> retry -- retry blocks until a tvar changes++ | otherwise -> do writeTVar tvar ([], count + 1)+ let create = createSessionId sc `onException` (liftIO $ atomically $ do+ (s',cnt) <- readTVar tvar+ writeTVar tvar (s', cnt - 1))+ return create++ Nothing -> do+ let (sess, unmanaged') = partition (\(_,cap) -> matchesCaps c cap) $ initialSessions m+ tvar <- newTVar (map fst sess, 0)+ let m' = m { initialSessions = unmanaged'+ , managedSessions = M.insert sc tvar $ managedSessions m+ }+ writeTVar sessionManager $ Just m'+ findSession sc m'++-- | Take a session out of the pool, using an existing unused session, creating a new session, or+-- blocking if the maximum number of sessions already exist. The new session is set into the 'WD'+-- monad. Note the session can leak if you do not properly call 'putSessionId'.+takeSession :: TestCapabilities s => s -> WD ()+takeSession s = do+ msess <- liftIO $ atomically $ do+ mm <- readTVar sessionManager+ case mm of+ Nothing -> return Nothing+ Just m -> do r <- findSession (SomeCap s) m+ return $ Just (r, mwdHost m, mwdPort m, mwdBasePath m)+ case msess of+ Just (r, host, port, bpath) -> do+ let sess = WDSession { wdHost = host+ , wdPort = port+ , wdBasePath = bpath+ , wdSessId = Nothing+ , lastHTTPRequest = Nothing+ }+ putSession sess+ sid <- r+ putSession sess { wdSessId = Just sid }++ Nothing -> do+ createWdMan 1 Nothing+ takeSession s++-- | Add a session ID back into the pool.+putSessionId :: TestCapabilities s => s -> SessionId -> WD ()+putSessionId s sid = liftIO $ atomically $ do+ mm <- readTVar sessionManager+ let m = maybe (error "Cannot put a session to an uninitialized manager") id mm+ case M.lookup (SomeCap s) $ managedSessions m of+ Nothing -> error "Cannot put a session to a cap that does not exist"+ Just tvar -> do+ (ss,cnt) <- readTVar tvar+ writeTVar tvar (sid:ss,cnt-1)++-- | Find or create a new session, set it into the 'WD' monad, run the given action, and return the+-- session back into the pool once the action completes or an exception occurs.+withCaps :: TestCapabilities s => s -> WD a -> WD a+withCaps tc test = do+ takeSession tc+ sess <- getSession+ test `finally` putSessionId tc (fromJust $ wdSessId sess)
+ hspec-webdriver.cabal view
@@ -0,0 +1,40 @@+name: hspec-webdriver+version: 0.1.0+cabal-version: >= 1.8+build-type: Simple+synopsis: Write end2end web application tests using webdriver and hspec+category: Web+author: John Lenz <lenz@math.uic.edu>+maintainer: John Lenz <lenz@math.uic.edu>+license: MIT+license-file: LICENSE+homepage: https://bitbucket.org/wuzzeb/webdriver-utils+stability: Experimental+description: For end to end testing of web applications from Haskell, the + <https://hackage.haskell.org/package/webdriver webdriver> package is a great tool but just+ contains the code to communicate with the browser. This package integrates webdriver+ with <http://hspec.github.io hspec>.++extra-source-files: README.md++source-repository head+ type: mercurial+ location: https://bitbucket.org/wuzzeb/webdriver-utils++library+ hs-source-dirs: .+ exposed-modules: Test.Hspec.WebDriver+ other-modules: Test.Hspec.WebDriver.Internal+ ghc-options: -Wall -O2++ build-depends: base >= 4 && < 5++ , HUnit >= 1.2 && < 1.3+ , hashable >= 1.2+ , hspec >= 1.8 && < 1.9+ , lifted-base >= 0.2+ , stm >= 2.4+ , text >= 0.11+ , transformers >= 0.3+ , unordered-containers >= 0.2+ , webdriver >= 0.5 && < 0.6