webdriver-angular (empty) → 0.1.0
raw patch · 11 files changed
+958/−0 lines, 11 filesdep +HUnitdep +aesondep +basesetup-changed
Dependencies added: HUnit, aeson, base, hspec, language-javascript, lifted-base, template-haskell, text, transformers, unordered-containers, wai-app-static, warp, webdriver, webdriver-angular
Files
- LICENSE +20/−0
- README.md +7/−0
- Setup.hs +2/−0
- js/angular-clientsidescripts.js +377/−0
- src/Test/WebDriver/Commands/Angular.hs +168/−0
- src/Test/WebDriver/Commands/Internal.hs +43/−0
- test/Specs.hs +204/−0
- test/main.hs +26/−0
- test/www/index.html +37/−0
- test/www/ng.js +8/−0
- webdriver-angular.cabal +66/−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,7 @@+For end to end testing of web applications from Haskell, the+[webdriver](https://hackage.haskell.org/package/webdriver) package is a great tool but does not+provide specific commands to make testing a webpage using [AngularJs](http://angularjs.org/) easier.+The [protractor](https://github.com/angular/protractor) project provides Angular-specific webdriver+commands but the test code must be written in javascript. This package fills the gap by reusing+some of the protractor code to allow end to end tests of Angular applications to be written in+Haskell.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ js/angular-clientsidescripts.js view
@@ -0,0 +1,377 @@+/* Copied from https://github.com/angular/protractor without modification */+/* The MIT License++Copyright (c) 2010-2013 Google, Inc.++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.*/++/**+ * All scripts to be run on the client via executeAsyncScript or+ * executeScript should be put here. These scripts are transmitted over+ * the wire using their toString representation, and cannot reference+ * external variables. They can, however use the array passed in to+ * arguments. Instead of params, all functions on clientSideScripts+ * should list the arguments array they expect.+ */+var clientSideScripts = exports;++/**+ * Wait until Angular has finished rendering and has+ * no outstanding $http calls before continuing.+ *+ * Asynchronous.+ * arguments[0] {string} The selector housing an ng-app+ * arguments[1] {function} callback+ */+clientSideScripts.waitForAngular = function() {+ var el = document.querySelector(arguments[0]);+ var callback = arguments[1];+ try {+ angular.element(el).injector().get('$browser').+ notifyWhenNoOutstandingRequests(callback);+ } catch (e) {+ callback(e);+ }+};++/**+ * Find a list of elements in the page by their angular binding.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The binding, e.g. {{cat.name}}.+ *+ * @return {Array.<WebElement>} The elements containing the binding.+ */+clientSideScripts.findBindings = function() {+ var using = arguments[0] || document;+ var binding = arguments[1];+ var bindings = using.getElementsByClassName('ng-binding');+ var matches = [];+ for (var i = 0; i < bindings.length; ++i) {+ var elemData = angular.element(bindings[i]).data();+ if (!elemData || !elemData.$binding) {+ continue;+ }+ var bindingName = elemData.$binding[0].exp || elemData.$binding;+ if (bindingName.indexOf(binding) != -1) {+ matches.push(bindings[i]);+ }+ }+ return matches; // Return the whole array for webdriver.findElements.+};++/**+ * Find an array of elements matching a row within an ng-repeat. Always returns+ * an array of only one element.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The text of the repeater, e.g. 'cat in cats'.+ * arguments[2] {number} The row index.+ *+ * @return {Array.<Element>} An array of a single element, the row of the+ * repeater.+ */+ clientSideScripts.findRepeaterRows = function() {+ var using = arguments[0] || document;+ var repeater = arguments[1];+ var index = arguments[2];++ var rows = [];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var attr = prefixes[p] + 'repeat';+ var repeatElems = using.querySelectorAll('[' + attr + ']');+ attr = attr.replace(/\\/g, '');+ for (var i = 0; i < repeatElems.length; ++i) {+ if (repeatElems[i].getAttribute(attr).indexOf(repeater) != -1) {+ rows.push(repeatElems[i]);+ }+ }+ }+ return [rows[index]];+ };++ /**+ * Find all rows of an ng-repeat.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The text of the repeater, e.g. 'cat in cats'.+ *+ * @return {Array.<Element>} All rows of the repeater.+ */+ clientSideScripts.findAllRepeaterRows = function() {+ var using = arguments[0] || document;+ var repeater = arguments[1];++ var rows = [];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var attr = prefixes[p] + 'repeat';+ var repeatElems = using.querySelectorAll('[' + attr + ']');+ attr = attr.replace(/\\/g, '');+ for (var i = 0; i < repeatElems.length; ++i) {+ if (repeatElems[i].getAttribute(attr).indexOf(repeater) != -1) {+ rows.push(repeatElems[i]);+ }+ }+ }+ return rows;+ };++/**+ * Find an element within an ng-repeat by its row and column.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The text of the repeater, e.g. 'cat in cats'.+ * arguments[2] {number} The row index.+ * arguments[3] {string} The column binding, e.g. '{{cat.name}}'.+ *+ * @return {Array.<Element>} The element in an array.+ */+clientSideScripts.findRepeaterElement = function() {+ var matches = [];+ var using = arguments[0] || document;+ var repeater = arguments[1];+ var index = arguments[2];+ var binding = arguments[3];++ var rows = [];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var attr = prefixes[p] + 'repeat';+ var repeatElems = using.querySelectorAll('[' + attr + ']');+ attr = attr.replace(/\\/g, '');+ for (var i = 0; i < repeatElems.length; ++i) {+ if (repeatElems[i].getAttribute(attr).indexOf(repeater) != -1) {+ rows.push(repeatElems[i]);+ }+ }+ }+ var row = rows[index];+ var bindings = [];+ if (row.className.indexOf('ng-binding') != -1) {+ bindings.push(row);+ }+ var childBindings = row.getElementsByClassName('ng-binding');+ for (var i = 0; i < childBindings.length; ++i) {+ bindings.push(childBindings[i]);+ }+ for (var i = 0; i < bindings.length; ++i) {+ var elemData = angular.element(bindings[i]).data();+ if (!elemData || !elemData.$binding) {+ continue;+ }+ var bindingName = elemData.$binding[0].exp || elemData.$binding;+ if (bindingName.indexOf(binding) != -1) {+ matches.push(bindings[i]);+ }+ }+ return matches;+};++/**+ * Find the elements in a column of an ng-repeat.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The text of the repeater, e.g. 'cat in cats'.+ * arguments[2] {string} The column binding, e.g. '{{cat.name}}'.+ *+ * @return {Array.<Element>} The elements in the column.+ */+clientSideScripts.findRepeaterColumn = function() {+ var matches = [];+ var using = arguments[0] || document;+ var repeater = arguments[1];+ var binding = arguments[2];++ var rows = [];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var attr = prefixes[p] + 'repeat';+ var repeatElems = using.querySelectorAll('[' + attr + ']');+ attr = attr.replace(/\\/g, '');+ for (var i = 0; i < repeatElems.length; ++i) {+ if (repeatElems[i].getAttribute(attr).indexOf(repeater) != -1) {+ rows.push(repeatElems[i]);+ }+ }+ }+ for (var i = 0; i < rows.length; ++i) {+ var bindings = [];+ if (rows[i].className.indexOf('ng-binding') != -1) {+ bindings.push(rows[i]);+ }+ var childBindings = rows[i].getElementsByClassName('ng-binding');+ for (var k = 0; k < childBindings.length; ++k) {+ bindings.push(childBindings[k]);+ }+ for (var j = 0; j < bindings.length; ++j) {+ var elemData = angular.element(bindings[j]).data();+ if (!elemData || !elemData.$binding) {+ continue;+ }+ var bindingName = elemData.$binding[0].exp || elemData.$binding;+ if (bindingName.indexOf(binding) != -1) {+ matches.push(bindings[j]);+ }+ }+ }+ return matches;+};++/**+ * Find an input elements by model name.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The model name.+ *+ * @return {Array.<Element>} The matching input elements.+ */+clientSideScripts.findInputs = function() {+ var using = arguments[0] || document;+ var model = arguments[1];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var selector = 'input[' + prefixes[p] + 'model="' + model + '"]';+ var inputs = using.querySelectorAll(selector);+ if (inputs.length) {+ return inputs;+ }+ }+};++/**+ * Find multiple select elements by model name.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The model name.+ *+ * @return {Array.<Element>} The matching select elements.+ */+clientSideScripts.findSelects = function() {+ var using = arguments[0] || document;+ var model = arguments[1];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var selector = 'select[' + prefixes[p] + 'model="' + model + '"]';+ var inputs = using.querySelectorAll(selector);+ if (inputs.length) {+ return inputs;+ }+ }+};++/**+ * Find selected option elements by model name.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {string} The model name.+ *+ * @return {Array.<Element>} The matching select elements.+*/+clientSideScripts.findSelectedOptions = function() {+ var using = arguments[0] || document;+ var model = arguments[1];+ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var selector = 'select[' + prefixes[p] + 'model="' + model + '"] option:checked';+ var inputs = using.querySelectorAll(selector);+ if (inputs.length) {+ return inputs;+ }+ }+};++/**+ * Find textarea elements by model name.+ *+ * arguments[0] {Element} The scope of the search.+ * arguments[1] {String} The model name.+ *+ * @return {Array.<Element>} An array of matching textarea elements.+*/+clientSideScripts.findTextareas = function() {+ var using = arguments[0] || document;+ var model = arguments[1];++ var prefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-', 'ng\\:'];+ for (var p = 0; p < prefixes.length; ++p) {+ var selector = 'textarea[' + prefixes[p] + 'model="' + model + '"]';+ var textareas = using.querySelectorAll(selector);+ if (textareas.length) {+ return textareas;+ }+ }+};++/**+ * Tests whether the angular global variable is present on a page. Retries+ * in case the page is just loading slowly.+ *+ * Asynchronous.+ * arguments[0] {number} Number of times to retry.+ * arguments[1] {function} callback+ */+clientSideScripts.testForAngular = function() {+ var attempts = arguments[0];+ var callback = arguments[arguments.length - 1];+ var check = function(n) {+ try {+ if (window.angular && window.angular.resumeBootstrap) {+ callback([true, null]);+ } else if (n < 1) {+ if (window.angular) {+ callback([false, 'angular never provided resumeBootstrap']);+ } else {+ callback([false, 'retries looking for angular exceeded']);+ }+ } else {+ window.setTimeout(function() {check(n - 1)}, 1000);+ }+ } catch (e) {+ callback([false, e]);+ }+ };+ check(attempts);+};++/**+ * Evalute an Angular expression in the context of a given element.+ *+ * arguments[0] {Element} The element in whose scope to evaluate.+ * arguments[1] {string} The expression to evaluate.+ *+ * @return {?Object} The result of the evaluation.+ */+clientSideScripts.evaluate = function() {+ var element = arguments[0];+ var expression = arguments[1];++ return angular.element(element).scope().$eval(expression);+};++/**+ * Return the current url using $location.absUrl().+ *+ * arguments[0] {string} The selector housing an ng-app+ */+clientSideScripts.getLocationAbsUrl = function() {+ var el = document.querySelector(arguments[0]);+ return angular.element(el).injector().get('$location').absUrl();+};
+ src/Test/WebDriver/Commands/Angular.hs view
@@ -0,0 +1,168 @@+{-# LANGUAGE TemplateHaskell, OverloadedStrings, DeriveDataTypeable #-}+-- | This module exposes <https://hackage.haskell.org/package/webdriver webdriver> actions that can+-- be used to interact with a page which uses <http://angularjs.org/ AngularJs>. This provides+-- similar functionality as <https://github.com/angular/protractor protractor> and in fact we share+-- some code with protractor.+module Test.WebDriver.Commands.Angular (+ -- * Loading+ waitForAngular++ -- * Searching for elements+ , NgException(..)+ , NgSelector(..)+ , findNg+ , findNgs+ , findNgFrom+ , findNgsFrom+ , NgRepeater(..)+ , findRepeater+ , findRepeaters+ , findRepeaterFrom+ , findRepeatersFrom++ -- * Misc+ , ngEvaluate+ , getLocationAbsUrl+ ) where++import Control.Monad.IO.Class (liftIO, MonadIO)+import Control.Exception (throwIO, Exception)+import Control.Applicative ((<$>))+import Data.Typeable (Typeable)+import Test.WebDriver.Classes+import Test.WebDriver.Commands+import Test.WebDriver.JSON (fromJSON')+import Test.WebDriver.Commands.Internal (clientScripts)+import Language.Haskell.TH (runIO, litE, stringL)+import qualified Data.Aeson as A+import qualified Data.HashMap.Lazy as M+import qualified Data.Text as T++-- | Map of the clientsidescripts for angular+cs :: M.HashMap T.Text T.Text+cs = either (\err -> error $ "Error parsing scripts " ++ err) id mhash+ where+ mhash = clientScripts j+ j = $(runIO (readFile "js/angular-clientsidescripts.js") >>= litE . stringL)++execCS :: (WebDriver wd, A.FromJSON a) => T.Text -> [JSArg] -> wd a+execCS script arg = executeJS arg body+ where+ body = maybe (error $ "Unable to find " ++ T.unpack script) id $ M.lookup script cs++-- | Variant of execCS that fails properly on Null+execElems :: WebDriver wd => T.Text -> [JSArg] -> wd [Element]+execElems script arg = do+ x <- execCS script arg+ case (x, A.fromJSON x) of+ (A.Null, _) -> return []+ (A.Array _, A.Success [A.Null]) -> return []+ _ -> fromJSON' x++asyncCS :: (WebDriver wd, A.FromJSON a) => T.Text -> [JSArg] -> wd (Maybe a)+asyncCS script arg = asyncJS arg body+ where+ body = maybe (error $ "Unable to find " ++ T.unpack script) id $ M.lookup script cs++-- | Wait until Angular has finished rendering before continuing. @False@ indicates the timeout+-- was hit (see 'setScriptTimeout') and we stopped waiting and @True@ means that angular has+-- finished rendering.+waitForAngular :: WebDriver wd => T.Text -- ^ CSS selector to element which has ng-app+ -> wd Bool+waitForAngular sel = do+ a <- asyncCS "waitForAngular" [JSArg sel] :: WebDriver wd => wd (Maybe ())+ return $ maybe False (const True) a++-- | Exceptions of this type will be thrown when an element is unable to be located.+data NgException = NgException String+ deriving (Show, Eq, Typeable)+instance Exception NgException++checkOne :: (Show s, MonadIO wd, WebDriver wd) => s -> [Element] -> wd Element+checkOne _ [e] = return e+checkOne sel es = liftIO $ throwIO err+ where+ err = NgException $ "Selector " ++ show sel ++ " returned " ++ show es++data NgSelector = + ByBinding T.Text -- ^ Argument is the binding, e.g. {{dog.name}}+ | ByModel T.Text -- ^ Argument is the model name. This is just a combination of 'ByInput', 'ByTextarea', and 'BySelect'+ | ByInput T.Text -- ^ Input element with matching model name, e.g. @\<input ng-model=\"name\" ...\>@.+ | ByTextarea T.Text -- ^ Textarea element with matching model name, e.g. @\<textarea ng-model=\"name\" ... \>@+ | BySelect T.Text -- ^ Select element with matching model name, e.g. @\<select ng-model=\"name\" ... \>@+ | BySelectedOption T.Text -- ^ Selected options with a select element matching the modelname. That is,+ -- The @\<option:checked\>@ elements within a @\<select ng-model=\"name\" ... \>@.+ deriving (Show,Eq)++data NgRepeater =+ ByRows T.Text -- ^ All the rows matching the repeater (e.g. 'dog in dogs')+ | ByRow T.Text Int -- ^ A single row specified by the text of the repeater (e.g. 'dog in dogs') and the row index+ | ByColumn T.Text T.Text -- ^ A single column matching the text of the repeater (e.g. 'dog in dogs') and the+ -- column binding (e.g '{{dog.name}}').+ | ByRowAndCol T.Text Int T.Text -- ^ A single row and column, given (repeater, row index, column binding).+ deriving (Show,Eq)++-- | Find a single element from the document matching the given Angular selector. If zero or more+-- than one element is returned, an exception of type 'NgException' is thrown.+findNg :: (MonadIO wd, WebDriver wd) => NgSelector -> wd Element+findNg s = checkOne s =<< findNg' (JSArg A.Null) s++-- | Find elements from the document matching the given Angular selector.+findNgs :: WebDriver wd => NgSelector -> wd [Element]+findNgs = findNg' $ JSArg A.Null++-- | Find a single element from within the given element which matches the given Angular selector. If+-- zero or more than one element is returned, an exception of type 'NgException' is thrown.+findNgFrom :: (MonadIO wd, WebDriver wd) => Element -> NgSelector -> wd Element+findNgFrom e s = checkOne s =<< findNg' (JSArg e) s++-- | Find elements from within the given element which match the given Angular selector.+findNgsFrom :: WebDriver wd => Element -> NgSelector -> wd [Element]+findNgsFrom e = findNg' $ JSArg e++findNg' :: WebDriver wd => JSArg -> NgSelector -> wd [Element]+findNg' e (ByBinding name) = execElems "findBindings" [e, JSArg name]+findNg' e (ByInput name) = execElems "findInputs" [e, JSArg name]+findNg' e (ByTextarea name) = execElems "findTextareas" [e, JSArg name]+findNg' e (BySelect name) = execElems "findSelects" [e, JSArg name]+findNg' e (BySelectedOption name) = execElems "findSelectedOptions" [e, JSArg name]+findNg' e (ByModel name) = concat <$> sequence [ findNg' e $ ByInput name+ , findNg' e $ ByTextarea name+ , findNg' e $ BySelect name+ ]++-- | Find an element from the document which matches the Angular repeater. If zero or more than one+-- element are returned, an exception of type 'NgException' is thrown.+findRepeater :: (MonadIO wd, WebDriver wd) => NgRepeater -> wd Element+findRepeater r = checkOne r =<< findRepeater' (JSArg A.Null) r++-- | Find elements from the document which match the Angular repeater.+findRepeaters :: WebDriver wd => NgRepeater -> wd [Element]+findRepeaters = findRepeater' $ JSArg A.Null++-- | Find an element from the given element which matches the Angular repeater. If zero or more than+-- one are returned, an exception of type 'NgException' is thrown.+findRepeaterFrom :: (MonadIO wd, WebDriver wd) => Element -> NgRepeater -> wd Element+findRepeaterFrom e r = checkOne r =<< findRepeater' (JSArg e) r++-- | Find elements from the given element which match the Angular repeater.+findRepeatersFrom :: WebDriver wd => Element -> NgRepeater -> wd [Element]+findRepeatersFrom e = findRepeater' $ JSArg e++findRepeater' :: WebDriver wd => JSArg -> NgRepeater -> wd [Element]+findRepeater' e (ByRows rep) = execElems "findAllRepeaterRows" [e, JSArg rep]+findRepeater' e (ByRow rep idx) = execElems "findRepeaterRows" [e, JSArg rep, JSArg idx]+findRepeater' e (ByColumn rep idx) = execElems "findRepeaterColumn" [e, JSArg rep, JSArg idx]+findRepeater' e (ByRowAndCol rep row col) = execElems "findRepeaterElement" [e, JSArg rep, JSArg row, JSArg col]++-- | Evaluate an angular expression, using the scope attached to the given element.+ngEvaluate :: (WebDriver wd, A.FromJSON a) + => Element -- ^ element in whose scope to evaluate+ -> T.Text -- ^ expression to evaluate, e.g. \"dog.name | reverse\"+ -> wd a+ngEvaluate e expr = execCS "evaluate" [JSArg e, JSArg expr]++-- | Return the current absolute url according to Angular (using @$location.absUrl()@).+getLocationAbsUrl :: WebDriver wd => T.Text -- ^ CSS selector to element which has ng-app+ -> wd T.Text+getLocationAbsUrl sel = execCS "getLocationAbsUrl" [JSArg sel]
+ src/Test/WebDriver/Commands/Internal.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE OverloadedStrings, DeriveDataTypeable, GeneralizedNewtypeDeriving #-}+-- |Internal functions+module Test.WebDriver.Commands.Internal+ ( clientScripts ) where++import Control.Applicative+import Data.Maybe (catMaybes)+import Language.JavaScript.Parser (JSNode(..), Node(..))+import qualified Data.HashMap.Lazy as M+import qualified Data.Text as T+import qualified Language.JavaScript.Parser as JS+++-- | Parse top level javascript commands+parseClientTop :: JSNode -> [(T.Text,T.Text)]+parseClientTop (NN (JSSourceElementsTop es)) = catMaybes $ map parseClientDef es+parseClientTop _ = []++-- | Parse a call clientSideScripts.somefunction = function() {...}, returning the function name+-- and the body.+parseClientDef :: JSNode -> Maybe (T.Text, T.Text)+parseClientDef (NN (JSExpression+ [NN (JSMemberDot+ [NT (JSIdentifier "clientSideScripts") _ _]+ _ -- literal .+ (NT (JSIdentifier name) _ _))+ , NN (JSOperator (NT (JSLiteral "=") _ _))+ , NN (JSFunctionExpression _ _ _ _ _ (NN (JSBlock _ body _)))+ ]))+ = Just (T.pack name, T.pack $ dropWhile (=='\n') $ JS.renderToString $ NN $ JSSourceElementsTop body)+ -- Use renderToString instead of renderJS so we don't need to depend on builder+parseClientDef _ = Nothing++-- | Parse a javascript file. All toplevel commands which look like+--+-- >clientSideScripts.somefunction = function() {+-- > ...+-- >};+--+-- are loaded. The return value is either an error message or+-- a map with keys the function names and value the body of the function.+clientScripts :: String -> Either String (M.HashMap T.Text T.Text)+clientScripts j = M.fromList . parseClientTop <$> JS.parse j "<client>"
+ test/Specs.hs view
@@ -0,0 +1,204 @@+{-# LANGUAGE OverloadedStrings #-}+module Specs where++import Test.WebDriver.Commands+import Test.WebDriver.Commands.Angular++import Data.Monoid+import Control.Monad.IO.Class (liftIO)+import Control.Exception.Lifted (try, Exception)+import Test.Hspec.Core (Example(..), Result(..))+import Test.HUnit (assertEqual, assertFailure)+import Test.Hspec hiding (shouldReturn, shouldBe, shouldSatisfy, shouldThrow)+import qualified Test.Hspec as H+import qualified Test.WebDriver as W+import qualified Test.WebDriver.Classes as W+import qualified Data.Text as T++data MySession = Firefox+ -- | Chrome++sessionCaps :: MySession -> W.Capabilities+sessionCaps Firefox = W.defaultCaps++matchingCaps :: MySession -> W.Capabilities -> Bool+matchingCaps Firefox (W.Capabilities { W.browser = W.Firefox _ _ _ }) = True+matchingCaps _ _ = False++data WithSession = WithSession MySession (W.WD ())+instance Example WithSession where+ evaluateExample (WithSession stype w) _ action = action (W.runWD session w') >> return Success+ where+ session = W.defaultSession+ w' = do ss <- sessions+ case filter (\(_, caps) -> matchingCaps stype caps) ss of+ ((s, _):_) -> W.putSession $ session { W.wdSessId = Just s }+ _ -> do s <- createSession $ sessionCaps stype+ W.putSession s+ w+ +shouldBe :: (Show a, Eq a) => a -> a -> W.WD ()+x `shouldBe` y = liftIO $ x `H.shouldBe` y++shouldBeTag :: Element -> T.Text -> W.WD ()+e `shouldBeTag` name = do+ t <- tagName e+ liftIO $ assertEqual ("tag of " ++ show e) name t++shouldHaveText :: Element -> T.Text -> W.WD ()+e `shouldHaveText` txt = do+ t <- getText e+ liftIO $ assertEqual ("text of " ++ show e) txt t++shouldHaveAttr :: Element -> (T.Text, T.Text) -> W.WD ()+e `shouldHaveAttr` (a, txt) = do+ t <- attr e a+ liftIO $ assertEqual ("attribute " ++ T.unpack a ++ " of " ++ show e) (Just txt) t++shouldReturn :: (Show a, Eq a) => W.WD a -> a -> W.WD ()+action `shouldReturn` expected = action >>= (\a -> liftIO $ a `H.shouldBe` expected)++shouldThrow :: (Show e, Eq e, Exception e) => W.WD a -> e -> W.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++assertName :: T.Text -> W.WD ()+assertName n = do+ e <- findNg $ ByBinding "{{name}}"+ e `shouldBeTag` "h2"+ e `shouldHaveText` ("The name is " <> n)++specs :: Spec+specs = describe "Angular webdriver commands" $ do+ it "finds elements by binding" $ WithSession Firefox $ do+ openPage "http://localhost:3456/index.html"+ _ <- waitForAngular "body" --`shouldReturn` True++ binding <- findNg $ ByBinding "{{a}}"+ binding `shouldBeTag` "h1"+ binding `shouldHaveText` "Hello A!"++ it "finds input elements" $ WithSession Firefox $ do+ i <- findNg $ ByInput "name"+ i `shouldBeTag` "input"+ sendKeys "John" i+ assertName "John"++ i2 <- findNg $ ByInput "xxx"+ i2 `shouldBeTag` "input"++ findNgs (ByInput "yyy") `shouldReturn` []+ findNg (ByInput "yyy") `shouldThrow` NgException "Selector ByInput \"yyy\" returned []"++ it "finds textarea elements" $ WithSession Firefox $ do+ openPage "http://localhost:3456/index.html"+ _ <- waitForAngular "body" --`shouldReturn` True++ t <- findNg $ ByTextarea "name"+ t `shouldBeTag` "textarea"+ sendKeys "Mark" t+ assertName "Mark"++ t2 <- findNg $ ByTextarea "yyy"+ t2 `shouldBeTag` "textarea"+ sendKeys "Bob" t2+ assertName "Mark"++ findNgs (ByTextarea "xxx") `shouldReturn` []+ findNg (ByTextarea "xxx") `shouldThrow` NgException "Selector ByTextarea \"xxx\" returned []"++ it "finds select elements" $ WithSession Firefox $ do+ s <- findNg $ BySelect "name"+ s `shouldBeTag` "select"+ opt <- findElemFrom s $ ByCSS "option[value=\"Goodbye\"]"+ click opt+ assertName "Goodbye"++ opt' <- findNg $ BySelectedOption "name"+ opt' `shouldBeTag` "option"+ opt' `shouldHaveText` "Goodbye"++ findNgs (BySelect "xxx") `shouldReturn` []++ it "finds model elements of different types" $ WithSession Firefox $ do+ [i, t, s] <- findNgs $ ByModel "name"+ i `shouldBeTag` "input"+ t `shouldBeTag` "textarea"+ s `shouldBeTag` "select"+ i `shouldHaveAttr` ("value", "Goodbye")+ t `shouldHaveAttr` ("value", "Goodbye")++ it "finds a single module element" $ WithSession Firefox $ do+ i <- findNg $ ByModel "xxx"+ i `shouldBeTag` "input"+ i `shouldHaveText` ""++ t <- findNg $ ByModel "yyy"+ t `shouldBeTag` "textarea"++ s <- findNg $ ByModel "zzz"+ s `shouldBeTag` "select"++ it "finds all repeater rows" $ WithSession Firefox $ do+ [r1, r2, r3] <- findRepeaters $ ByRows "dog in dogs"+ mapM_ (`shouldBeTag`"li") [r1, r2, r3]+ r1 `shouldHaveText` "Spot mutt"+ r2 `shouldHaveText` "Spike poodle"+ r3 `shouldHaveText` "Jupiter bulldog"++ findRepeaters (ByRows "cat in cats") `shouldReturn` []+ findRepeater (ByRows "cat in cats") `shouldThrow` NgException "Selector ByRows \"cat in cats\" returned []"++ it "finds a single repeater row" $ WithSession Firefox $ do+ r2 <- findRepeater $ ByRow "dog in dogs" 1+ r2 `shouldBeTag` "li"+ r2 `shouldHaveText` "Spike poodle"++ findRepeaters (ByRow "cat in cats" 1) `shouldReturn` []+ findRepeater (ByRow "cat in cats" 1) `shouldThrow` NgException "Selector ByRow \"cat in cats\" 1 returned []"++ it "finds a repeater column" $ WithSession Firefox $ do+ [c1, c2, c3] <- findRepeaters $ ByColumn "dog in dogs" "{{dog.name}}"+ mapM_ (`shouldBeTag`"span") [c1, c2, c3]++ c1 `shouldHaveText` "Spot"+ c2 `shouldHaveText` "Spike"+ c3 `shouldHaveText` "Jupiter"++ findRepeaters (ByColumn "cat in cats" "{{cat.name}}") `shouldReturn` []+ findRepeater (ByColumn "cat in cats" "{{cat.name}}") `shouldThrow`+ NgException "Selector ByColumn \"cat in cats\" \"{{cat.name}}\" returned []"++ it "finds a repeater by row and column" $ WithSession Firefox $ do+ c2 <- findRepeater $ ByRowAndCol "dog in dogs" 1 "{{dog.breed}}"+ c2 `shouldBeTag` "span"+ c2 `shouldHaveText` "poodle"++ -- These currently cause Javascript errors+ --findRepeaters (ByRowAndCol "cat in cats" 12 "{{cat.name}}") `shouldReturn` []+ --findRepeater (ByRowAndCol "cat in cats" 22 "{{cat.name}}") `shouldThrow`+ -- NgException "Selector ByRowAndCol \"cat in cats\" 22 \"{{cat.name}}\" returned []"++ it "evaluates an angular expression" $ WithSession Firefox $ do+ e <- findNg $ ByBinding "{{a}}"+ ngEvaluate e "cost | number:2" `shouldReturn` ("12.60" :: T.Text)++ it "loads the location url" $ WithSession Firefox $ do+ getLocationAbsUrl "body" `shouldReturn` "http://localhost:3456/index.html"++ it "loads {{cost}} from document" $ WithSession Firefox $ do+ [s1, s2] <- findNgs $ ByBinding "{{cost}}"+ s1 `shouldBeTag` "span"+ s2 `shouldBeTag` "span"+ s1 `shouldHaveAttr` ("id", "span-one")+ s2 `shouldHaveAttr` ("id", "span-two")++ it "loads {{cost}} only from one element" $ WithSession Firefox $ do+ d <- findElem $ ById "one"+ d `shouldBeTag` "div"+ s1 <- findNgFrom d $ ByBinding "{{cost}}"+ s1 `shouldBeTag` "span"+ s1 `shouldHaveAttr` ("id", "span-one")
+ test/main.hs view
@@ -0,0 +1,26 @@+{-# LANGUAGE OverloadedStrings #-}+module Main where++import Control.Concurrent+import Control.Exception (bracket)+import Network.Wai.Application.Static+import Network.Wai.Handler.Warp (run)+import Test.Hspec (hspec)++import Specs++startServer :: IO ThreadId+startServer = forkIO $+ run 3456 $ staticApp $ defaultFileServerSettings "test/www"++{-stop :: ThreadId -> IO ()+stop t = do+ runWD defaultSession $ do+ ss <- sessions+ mapM_ (\(x,_) -> putSession defaultSession {wdSessId = Just x} >> closeSession) ss++ killThread t-}++main :: IO ()+main = bracket startServer killThread $ \_ ->+ hspec specs
+ test/www/index.html view
@@ -0,0 +1,37 @@+<!doctype html>+<html>+ <head>+ <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.5/angular.min.js"></script>+ <script src="ng.js"></script>+ </head>+ <body ng-app="test">+ <div ng-controller="TestCtrl">+ <h1>Hello {{a}}!</h1>+ <h2>The name is {{name}}</h2>+ <input ng-model="name"></input>+ <input ng-model="xxx"></input>+ <textarea ng-model="name"></textarea>+ <textarea ng-model="yyy"></textarea>+ <select ng-model="name">+ <option value="Hello">Hello</option>+ <option value="Goodbye">Goodbye</option>+ </select>+ <select ng-model="zzz">+ <option value="Foo">Foo</option>+ <option value="Bar">Bar</option>+ </select>+ <ul>+ <li ng-repeat="dog in dogs">+ <span>{{dog.name}}</span>+ <span>{{dog.breed}}</span>+ </li>+ </ul>+ <div id="one">+ <span id="span-one">{{cost}}</span>+ </div>+ <div id="two">+ <span id="span-two">{{cost}}</span>+ </div>+ </div>+ </body>+</html>
+ test/www/ng.js view
@@ -0,0 +1,8 @@+angular.module("test", [])+.controller("TestCtrl", function($scope) {+ $scope.a = "A";+ $scope.dogs = [{name:"Spot", breed:"mutt"},+ {name:"Spike", breed:"poodle"},+ {name:"Jupiter", breed:"bulldog"}];+ $scope.cost = 12.6;+});
+ webdriver-angular.cabal view
@@ -0,0 +1,66 @@+name: webdriver-angular+version: 0.1.0+cabal-version: >= 1.8+build-type: Simple+synopsis: Webdriver actions to assist with testing a webpage which uses Angular.Js+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/hs-webdriver-angular+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+ does not provide specific commands to make testing a webpage using + <http://angularjs.org/ AngularJS> easier. The + <https://github.com/angular/protractor protractor> project provides Angular-specific+ webdriver commands but the test code must be written in javascript. This package+ fills the gap by reusing some of the protractor code to allow end to end tests of Angular+ applications to be written in Haskell.++extra-source-files: js/*.js,+ README.md,+ test/*.hs,+ test/www/*.html,+ test/www/*.js++source-repository head+ type: mercurial+ location: https://bitbucket.org/wuzzeb/hs-webdriver-angular++library+ hs-source-dirs: src++ exposed-modules: Test.WebDriver.Commands.Angular++ other-modules: Test.WebDriver.Commands.Internal++ ghc-options: -Wall -O2++ build-depends: base >= 4 && < 5++ , webdriver >= 0.5 && < 0.6+ , aeson >= 0.6+ , language-javascript >= 0.5 && < 0.6+ , template-haskell >= 0.6+ , text >= 0.11 && < 0.12+ , transformers >= 0.3+ , unordered-containers >= 0.2 && < 0.3++test-suite test+ type: exitcode-stdio-1.0+ main-is: main.hs+ hs-source-dirs: test+ ghc-options: -Wall++ build-depends: base >= 4 && < 5+ , hspec >= 1.8+ , HUnit >= 1.2 && < 1.3+ , lifted-base >= 0.2+ , text >= 0.11+ , transformers >= 0.3+ , warp >= 2.0+ , wai-app-static >= 2.0+ , webdriver+ , webdriver-angular