diff --git a/build.sbt b/build.sbt index 31cdb73..49f6b7e 100755 --- a/build.sbt +++ b/build.sbt @@ -15,6 +15,7 @@ libraryDependencies ++= Seq( "com.typesafe.akka" %% "akka-actor" % "2.2.1", "io.spray" % "spray-client" % "1.2-20130912", "io.spray" %% "spray-json" % "1.2.5", + "net.sourceforge.htmlunit" % "htmlunit" % "2.13", "org.specs2" %% "specs2" % "2.2.2" % "test", "junit" % "junit" % "4.11" % "test", "com.typesafe.akka" %% "akka-testkit" % "2.2.1" % "test" diff --git a/src/main/scala/com/typesafe/webdriver/LocalBrowser.scala b/src/main/scala/com/typesafe/webdriver/LocalBrowser.scala index fa0a9c7..16052db 100755 --- a/src/main/scala/com/typesafe/webdriver/LocalBrowser.scala +++ b/src/main/scala/com/typesafe/webdriver/LocalBrowser.scala @@ -74,4 +74,14 @@ object PhantomJs { val args = Some(Seq("phantomjs", s"--webdriver=$host:$port")) Props(classOf[LocalBrowser], Session.props(wd), args) } +} + +/** + * Used to manage a JVM resident browser via HtmlUnit. + */ +object HtmlUnit { + def props()(implicit system: ActorSystem): Props = { + val wd = new HtmlUnitWebDriverCommands() + Props(classOf[LocalBrowser], Session.props(wd)) + } } \ No newline at end of file diff --git a/src/main/scala/com/typesafe/webdriver/Session.scala b/src/main/scala/com/typesafe/webdriver/Session.scala index eaf3253..56a2823 100644 --- a/src/main/scala/com/typesafe/webdriver/Session.scala +++ b/src/main/scala/com/typesafe/webdriver/Session.scala @@ -60,8 +60,10 @@ class Session(wd: WebDriverCommands, sessionConnectTimeout: FiniteDuration) val origSender = sender someSessionId.foreach { wd.executeJs(_, e.script, e.args).onComplete { - case Success(result) => origSender ! result + case Success(Right(result)) => origSender ! result + case Success(Left(error)) => origSender ! akka.actor.Status.Failure(error.toException) case Failure(t) => + origSender ! akka.actor.Status.Failure(t) log.error("Stopping due to a problem executing commands - {}.", t) stop() } diff --git a/src/main/scala/com/typesafe/webdriver/WebDriverCommands.scala b/src/main/scala/com/typesafe/webdriver/WebDriverCommands.scala index 6ab0506..60100f4 100644 --- a/src/main/scala/com/typesafe/webdriver/WebDriverCommands.scala +++ b/src/main/scala/com/typesafe/webdriver/WebDriverCommands.scala @@ -1,7 +1,130 @@ package com.typesafe.webdriver import scala.concurrent.Future -import spray.json.{JsString, JsArray, JsValue} +import spray.json._ + +object WebDriverCommands { + + object Errors { + /** The command executed successfully. */ + val Success = 0 + + /** A session is either terminated or not started */ + val NoSuchDriver = 6 + + /** An element could not be located on the page using the given search parameters. */ + val NoSuchElement = 7 + + /** A request to switch to a frame could not be satisfied because the frame could not be found. */ + val NoSuchFrame = 8 + + /** The requested resource could not be found, or a request was received using an HTTP method that is not supported + * by the mapped resource. */ + val UnknownCommand = 9 + + /** An element command failed because the referenced element is no longer attached to the DOM. */ + val StaleElementReference = 10 + + /** An element command could not be completed because the element is not visible on the page. */ + val ElementNotVisible = 11 + + /** An element command could not be completed because the element is in an invalid state (e.g. attempting to click + * a disabled element). */ + val InvalidElementState = 12 + + /** An unknown server-side error occurred while processing the command. */ + val UnknownError = 13 + + /** An attempt was made to select an element that cannot be selected. */ + val ElementIsNotSelectable = 15 + + /** An error occurred while executing user supplied JavaScript. */ + val JavaScriptError = 17 + + /** An error occurred while searching for an element by XPath. */ + val XPathLookupError = 19 + + /** An operation did not complete before its timeout expired. */ + val Timeout = 21 + + /** A request to switch to a different window could not be satisfied because the window could not be found. */ + val NoSuchWindow = 23 + + /** An illegal attempt was made to set a cookie under a different domain than the current page. */ + val InvalidCookieDomain = 24 + + /** A request to set a cookie's value could not be satisfied. */ + val UnableToSetCookie = 25 + + /** A modal dialog was open, blocking this operation */ + val UnexpectedAlertOpen = 26 + + /** An attempt was made to operate on a modal dialog when one was not open. */ + val NoAlertOpenError = 27 + + /** A script did not complete before its timeout expired. */ + val ScriptTimeout = 28 + + /** The coordinates provided to an interactions operation are invalid. */ + val InvalidElementCoordinates = 29 + + /** IME was not available. */ + val IMENotAvailable = 30 + + /** An IME engine could not be started. */ + val IMEEngineActivationFailed = 31 + + /** Argument was an invalid selector (e.g. XPath/CSS). */ + val InvalidSelector = 32 + + /** A new session could not be created. */ + val SessionNotCreatedException = 33 + + /** Target provided for a move action is out of bounds. */ + val MoveTargetOutOfBounds = 34 + } + + /** + * An error returned by WebDriver. + * + * @param status The error status code. + * @param details The details of the error. + */ + final case class WebDriverError(status: Int, details: WebDriverErrorDetails) { + def toException = new WebDriverException(this) + } + + /** + * The details of an error returned by WebDriver + * + * @param message A descriptive message for the command failure. + * @param screen If included, a screenshot of the current page as a base64 encoded string. + * @param `class` If included, specifies the fully qualified class name for the exception that was thrown when the + * command failed. + * @param stackTrace If included, specifies an array of JSON objects describing the stack trace for the exception + * that was thrown when the command failed. The zeroeth element of the array represents the top of + * the stack. + */ + final case class WebDriverErrorDetails(message: String, screen: Option[String], `class`: Option[String], + stackTrace: Option[Seq[StackTraceElement]]) + + /** + * A WebDriver exception. + * + * The stack trace of this exception will be the stack trace of the JavaScript code on the remote system. + * + * @param error The WebDriverError triggered by this exception. + */ + final class WebDriverException(val error: WebDriverError) extends RuntimeException(error.details.message) { + error.details.stackTrace match { + case Some(st) => setStackTrace(st.toArray) + case None => setStackTrace(Array.empty) + } + } + +} + +import WebDriverCommands._ /** * Encapsulates all of the request/reply commands that can be sent via the WebDriver protocol. All commands perform @@ -27,7 +150,7 @@ abstract class WebDriverCommands { * @param args a json array declaring the arguments to pass to the script * @return the return value of the script's execution as a json value */ - def executeJs(sessionId: String, script: String, args: JsArray): Future[JsValue] + def executeJs(sessionId: String, script: String, args: JsArray): Future[Either[WebDriverError, JsValue]] } import akka.actor.ActorSystem @@ -43,13 +166,43 @@ class HttpWebDriverCommands(host: String, port: Int)(implicit system: ActorSyste import spray.client.pipelining._ import spray.http._ import spray.http.HttpHeaders._ + import spray.httpx.unmarshalling._ import spray.httpx.SprayJsonSupport._ import spray.json.DefaultJsonProtocol + import spray.httpx.PipelineException private case class CommandResponse(sessionId: String, status: Int, value: JsValue) private object CommandProtocol extends DefaultJsonProtocol { implicit val commandResponse = jsonFormat3(CommandResponse) + + implicit object stackTraceElementFormat extends JsonFormat[StackTraceElement] { + def write(ste: StackTraceElement) = JsObject( + "fileName" -> JsString(ste.getFileName), + "className" -> JsNumber(ste.getClassName), + "methodName" -> JsNumber(ste.getMethodName), + "lineNumber" -> JsNumber(ste.getLineNumber) + ) + + def read(value: JsValue) = { + value.asJsObject.getFields("fileName", "className", "methodName", "lineNumber") match { + case Seq(JsString(fileName), JsString(className), JsString(methodName), JsNumber(lineNumber)) => + new StackTraceElement(className, methodName, fileName, lineNumber.toInt) + case _ => throw new DeserializationException("Color expected") + } + } + } + + implicit val webDriverErrorDetailsFormat = jsonFormat(WebDriverErrorDetails, "message", "screen", "class", + "stackTrace") + } + + def unmarshalIgnoreStatus[T: Unmarshaller]: HttpResponse => T = { + response => + response.entity.as[T] match { + case Right(value) ⇒ value + case Left(error) ⇒ throw new PipelineException(error.toString) + } } import CommandProtocol._ @@ -60,20 +213,77 @@ class HttpWebDriverCommands(host: String, port: Int)(implicit system: ActorSyste Accept(Seq(MediaTypes.`application/json`, MediaTypes.`image/png`)) ) ~> sendReceive - ~> unmarshal[CommandResponse] + ~> unmarshalIgnoreStatus[CommandResponse] ) - def createSession(): Future[String] = { + override def createSession(): Future[String] = { pipeline(Post("/session", """{"desiredCapabilities": {}}""")).withFilter(_.status == 0).map(_.sessionId) } - def destroySession(sessionId: String) { + override def destroySession(sessionId: String) { pipeline(Delete(s"/session/$sessionId/window")) } - def executeJs(sessionId: String, script: String, args: JsArray): Future[JsValue] = { - pipeline(Post(s"/session/$sessionId/execute", s"""{"script":${JsString(script)},"args":$args}""")) - .withFilter(_.status == 0) - .map(_.value) + override def executeJs(sessionId: String, script: String, args: JsArray): Future[Either[WebDriverError, JsValue]] = { + pipeline(Post(s"/session/$sessionId/execute", s"""{"script":${JsString(script)},"args":$args}""")).map { + response => + if (response.status == Errors.Success) { + Right(response.value) + } else { + Left(WebDriverError(response.status, response.value.convertTo[WebDriverErrorDetails])) + } + } } -} \ No newline at end of file +} + +import com.gargoylesoftware.htmlunit.WebClient +import scala.collection.concurrent.TrieMap +import java.util.UUID +import com.gargoylesoftware.htmlunit.html.HtmlPage + +/** + * Runs webdriver command in the context of the JVM ala HtmlUnit. + */ +class HtmlUnitWebDriverCommands() extends WebDriverCommands { + val sessions = TrieMap[String, WebClient]() + + import scala.concurrent.ExecutionContext.Implicits.global + + override def createSession(): Future[String] = { + val webClient = new WebClient() + val sessionId = UUID.randomUUID().toString + sessions.put(sessionId, webClient) + Future.successful(sessionId) + } + + override def destroySession(sessionId: String): Unit = { + sessions.remove(sessionId).foreach(_.closeAllWindows()) + } + + // TODO: Consider errors that can occur and handle with Left(). + override def executeJs(sessionId: String, script: String, args: JsArray): Future[Either[WebDriverError, JsValue]] = { + sessions.get(sessionId).map({ + webClient => + Future { + val page: HtmlPage = webClient.getPage(WebClient.ABOUT_BLANK) + val scriptWithArgs = s"""|var args = JSON.parse('${args.toString().replaceAll("'", "\\'")}'); + |$script + |""".stripMargin + val scriptResult = page.executeJavaScript(scriptWithArgs) + def toJsValue(v: Any): JsValue = { + import scala.collection.JavaConverters._ + v match { + case b: java.lang.Boolean => JsBoolean(b) + case n: Number => JsNumber(n.doubleValue()) + case s: String => JsString(s) + case n if n == null => JsNull + case l: java.util.List[_] => JsArray(l.asScala.toList.map(toJsValue): _*) + case o: java.util.Map[_, _] => JsObject(o.asScala.map(p => p._1.toString -> toJsValue(p._2)).toList) + case x => JsString(x.toString) + } + } + Right(toJsValue(scriptResult.getJavaScriptResult)) + } + }).getOrElse(Future.successful(Right(JsObject()))) + } +} diff --git a/src/test/scala/com/typesafe/webdriver/HtmlUnitWebDriverCommandsSpec.scala b/src/test/scala/com/typesafe/webdriver/HtmlUnitWebDriverCommandsSpec.scala new file mode 100644 index 0000000..b0870a1 --- /dev/null +++ b/src/test/scala/com/typesafe/webdriver/HtmlUnitWebDriverCommandsSpec.scala @@ -0,0 +1,45 @@ +package com.typesafe.webdriver + +import org.junit.runner.RunWith +import org.specs2.runner.JUnitRunner +import spray.json._ +import scala.concurrent.{Await, Future} +import org.specs2.mutable.Specification +import org.specs2.time.NoDurationConversions +import org.specs2.matcher.MatchResult +import com.typesafe.webdriver.WebDriverCommands.WebDriverError + +@RunWith(classOf[JUnitRunner]) +class HtmlUnitWebDriverCommandsSpec extends Specification with NoDurationConversions { + + import scala.concurrent.ExecutionContext.Implicits.global + import scala.concurrent.duration._ + + def withSession(block: (WebDriverCommands, String) => Future[Either[WebDriverError, JsValue]]): Future[Either[WebDriverError, JsValue]] = { + val commands = new HtmlUnitWebDriverCommands() + val maybeSession = commands.createSession().map { + sessionId => + val result = block(commands, sessionId) + commands.destroySession(sessionId) + result + } + maybeSession.flatMap(x => x) + } + + def testFor(v: JsValue): MatchResult[Any] = { + val result = withSession { + (commands, sessionId) => + commands.executeJs(sessionId, "args[0];", JsArray(v)) + } + Await.result(result, Duration(1, SECONDS)) must_== Right(v) + } + + "HtmlUnit" should { + "execute js returning a boolean" in testFor(JsTrue) + "execute js returning a number" in testFor(JsNumber(1)) + "execute js returning a string" in testFor(JsString("hi")) + "execute js returning a null" in testFor(JsNull) + "execute js returning an array" in testFor(JsArray(JsNumber(1))) + "execute js returning an object" in testFor(JsObject("k" -> JsString("v"))) + } +} diff --git a/src/test/scala/com/typesafe/webdriver/LocalBrowserSpec.scala b/src/test/scala/com/typesafe/webdriver/LocalBrowserSpec.scala index 12d0498..2a9729b 100644 --- a/src/test/scala/com/typesafe/webdriver/LocalBrowserSpec.scala +++ b/src/test/scala/com/typesafe/webdriver/LocalBrowserSpec.scala @@ -8,17 +8,19 @@ import akka.actor.ActorRef import java.io.File import scala.concurrent.{Promise, Future} import spray.json.{JsNull, JsValue, JsArray} +import com.typesafe.webdriver.WebDriverCommands.WebDriverError // Note that this test will only run on Unix style environments where the "rm" command is available. @RunWith(classOf[JUnitRunner]) class LocalBrowserSpec extends Specification { object TestWebDriverCommands extends WebDriverCommands { - def createSession(): Future[String] = Promise.successful("123").future + override def createSession(): Future[String] = Promise.successful("123").future - def destroySession(sessionId: String) {} + override def destroySession(sessionId: String) {} - def executeJs(sessionId: String, script: String, args: JsArray): Future[JsValue] = Future.successful(JsNull) + override def executeJs(sessionId: String, script: String, args: JsArray): Future[Either[WebDriverError, JsValue]] = + Future.successful(Right(JsNull)) } "The local browser" should { diff --git a/src/test/scala/com/typesafe/webdriver/SessionSpec.scala b/src/test/scala/com/typesafe/webdriver/SessionSpec.scala index 7970d45..fa7886e 100644 --- a/src/test/scala/com/typesafe/webdriver/SessionSpec.scala +++ b/src/test/scala/com/typesafe/webdriver/SessionSpec.scala @@ -7,6 +7,7 @@ import scala.concurrent.{Await, Promise, Future} import scala.concurrent.duration._ import org.specs2.time.NoTimeConversions import spray.json.{JsString, JsValue, JsArray} +import com.typesafe.webdriver.WebDriverCommands.WebDriverError @RunWith(classOf[JUnitRunner]) class SessionSpec extends Specification with NoTimeConversions { @@ -19,7 +20,8 @@ class SessionSpec extends Specification with NoTimeConversions { def destroySession(sessionId: String) {} - def executeJs(sessionId: String, script: String, args: JsArray): Future[JsValue] = Future.successful(JsString("hi")) + def executeJs(sessionId: String, script: String, args: JsArray): Future[Either[WebDriverError, JsValue]] = + Future.successful(Right(JsString("hi"))) } "A session" should {