diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/js/angular-clientsidescripts.js b/js/angular-clientsidescripts.js
new file mode 100644
--- /dev/null
+++ b/js/angular-clientsidescripts.js
@@ -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();
+};
diff --git a/src/Test/WebDriver/Commands/Angular.hs b/src/Test/WebDriver/Commands/Angular.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/WebDriver/Commands/Angular.hs
@@ -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]
diff --git a/src/Test/WebDriver/Commands/Internal.hs b/src/Test/WebDriver/Commands/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/WebDriver/Commands/Internal.hs
@@ -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>"
diff --git a/test/Specs.hs b/test/Specs.hs
new file mode 100644
--- /dev/null
+++ b/test/Specs.hs
@@ -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")
diff --git a/test/main.hs b/test/main.hs
new file mode 100644
--- /dev/null
+++ b/test/main.hs
@@ -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
diff --git a/test/www/index.html b/test/www/index.html
new file mode 100644
--- /dev/null
+++ b/test/www/index.html
@@ -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>
diff --git a/test/www/ng.js b/test/www/ng.js
new file mode 100644
--- /dev/null
+++ b/test/www/ng.js
@@ -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;
+});
diff --git a/webdriver-angular.cabal b/webdriver-angular.cabal
new file mode 100644
--- /dev/null
+++ b/webdriver-angular.cabal
@@ -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
