diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 000000000..4c8c397cf
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,43 @@
+sudo: false
+language: scala
+scala:
+ - 2.10.6
+ - 2.11.11
+ - 2.12.2
+jdk:
+ - oraclejdk8
+install:
+ - if [[ "${TRAVIS_SCALA_VERSION}" == "2.10.6" ]]; then TEST_SBT_PLUGIN=true; else TEST_SBT_PLUGIN=false; fi
+ # The default ivy resolution takes way too much time, and times out Travis builds.
+ # We use coursier instead.
+ - mkdir -p $HOME/.sbt/0.13/plugins/
+ - echo 'addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.0.0-RC3")' > $HOME/.sbt/0.13/plugins/coursier.sbt
+ # While there is no published version of Scala.js 1.x, we have to locally build a snapshot.
+ - git clone https://github.com/scala-js/scala-js.git
+ - cd scala-js
+ - git checkout f21e7f5ea652fe4a186f1ecd8c0070fbb80edc80
+ - sbt ++$TRAVIS_SCALA_VERSION ir/publishLocal tools/publishLocal jsEnvs/publishLocal jsEnvsTestKit/publishLocal
+ - |
+ if [[ "${TEST_SBT_PLUGIN}" == "true" ]]; then
+ sbt ++$TRAVIS_SCALA_VERSION testAdapter/publishLocal sbtPlugin/publishLocal &&
+ sbt ++2.11.11 compiler/publishLocal library/publishLocal testInterface/publishLocal
+ fi
+ - cd ..
+script:
+ - sbt ++$TRAVIS_SCALA_VERSION scalajs-phantomjs-env/test scalajs-phantomjs-env/doc
+ - |
+ if [[ "${TEST_SBT_PLUGIN}" == "true" ]]; then
+ sbt scalajs-phantomjs-env/publishLocal sbt-scalajs-env-phantomjs/publishLocal && \
+ cd sbt-plugin-test && \
+ sbt jetty9/run && \
+ cd ..
+ fi
+cache:
+ directories:
+ - $HOME/.ivy2/cache
+ - $HOME/.sbt
+ - $HOME/.coursier/cache
+before_cache:
+ - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print -delete
+ - find $HOME/.sbt -name "*.lock" -print -delete
+ - rm $HOME/.sbt/0.13/plugins/coursier.sbt
diff --git a/build.sbt b/build.sbt
new file mode 100644
index 000000000..c2480ea10
--- /dev/null
+++ b/build.sbt
@@ -0,0 +1,107 @@
+val scalaJSVersion = "1.0.0-SNAPSHOT"
+
+inThisBuild(Seq(
+ version := "0.1.0-SNAPSHOT",
+ organization := "org.scala-js",
+
+ crossScalaVersions := Seq("2.10.6", "2.11.11", "2.12.2"),
+ scalaVersion := "2.10.6",
+ scalacOptions ++= Seq("-deprecation", "-feature", "-Xfatal-warnings"),
+
+ homepage := Some(url("https://www.scala-js.org/")),
+ licenses += ("BSD New",
+ url("https://github.com/scala-js/scala-js-env-phantomjs/blob/master/LICENSE")),
+ scmInfo := Some(ScmInfo(
+ url("https://github.com/scala-js/scala-js-env-phantomjs"),
+ "scm:git:git@github.com:scala-js/scala-js-env-phantomjs.git",
+ Some("scm:git:git@github.com:scala-js/scala-js-env-phantomjs.git")))
+))
+
+val commonSettings = Def.settings(
+ // Scaladoc linking
+ apiURL := {
+ val name = moduleName.value
+ val v = version.value
+ Some(url(s"https://www.scala-js.org/api/$name/$v/"))
+ },
+ autoAPIMappings := true,
+
+ publishMavenStyle := true,
+ publishTo := {
+ val nexus = "https://oss.sonatype.org/"
+ if (isSnapshot.value)
+ Some("snapshots" at nexus + "content/repositories/snapshots")
+ else
+ Some("releases" at nexus + "service/local/staging/deploy/maven2")
+ },
+ pomExtra := (
+
+
+ sjrd
+ Sébastien Doeraene
+ https://github.com/sjrd/
+
+
+ gzm0
+ Tobias Schlatter
+ https://github.com/gzm0/
+
+
+ nicolasstucki
+ Nicolas Stucki
+ https://github.com/nicolasstucki/
+
+
+ ),
+ pomIncludeRepository := { _ => false }
+)
+
+lazy val root: Project = project.in(file(".")).
+ settings(
+ publishArtifact in Compile := false,
+ publish := {},
+ publishLocal := {},
+
+ clean := clean.dependsOn(
+ clean in `scalajs-phantomjs-env`,
+ clean in `sbt-scalajs-env-phantomjs`
+ ).value
+ )
+
+lazy val `scalajs-phantomjs-env`: Project = project.in(file("phantomjs-env")).
+ settings(
+ commonSettings,
+
+ libraryDependencies ++= Seq(
+ "org.scala-js" %% "scalajs-js-envs" % scalaJSVersion,
+ "org.eclipse.jetty" % "jetty-websocket" % "8.1.16.v20140903" % "provided",
+ "org.eclipse.jetty" % "jetty-server" % "8.1.16.v20140903" % "provided",
+
+ "com.novocode" % "junit-interface" % "0.11" % "test",
+ "org.scala-js" %% "scalajs-js-envs-test-kit" % scalaJSVersion % "test"
+ )
+ )
+
+lazy val `sbt-scalajs-env-phantomjs`: Project = project.in(file("phantomjs-sbt-plugin")).
+ settings(
+ commonSettings,
+
+ sbtPlugin := true,
+ scalaBinaryVersion :=
+ CrossVersion.binaryScalaVersion(scalaVersion.value),
+
+ addSbtPlugin("org.scala-js" % "sbt-scalajs" % scalaJSVersion),
+
+ // Add API mappings for sbt (seems they don't export their API URL)
+ apiMappings ++= {
+ val deps = (externalDependencyClasspath in Compile).value
+ val sbtJars = deps filter { attributed =>
+ val p = attributed.data.getPath
+ p.contains("/org.scala-sbt/") && p.endsWith(".jar")
+ }
+ val docUrl =
+ url(s"http://www.scala-sbt.org/${sbtVersion.value}/api/")
+ sbtJars.map(_.data -> docUrl).toMap
+ }
+ ).
+ dependsOn(`scalajs-phantomjs-env`)
diff --git a/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/JettyWebsocketManager.scala b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/JettyWebsocketManager.scala
new file mode 100644
index 000000000..97a9b8c30
--- /dev/null
+++ b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/JettyWebsocketManager.scala
@@ -0,0 +1,135 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs
+
+import javax.servlet.http.HttpServletRequest
+
+import org.eclipse.jetty.server.Server
+import org.eclipse.jetty.server.nio.SelectChannelConnector
+import org.eclipse.jetty.websocket.{WebSocket, WebSocketHandler}
+import org.eclipse.jetty.util.component.{LifeCycle, AbstractLifeCycle}
+import org.eclipse.jetty.util.log
+
+private[phantomjs] final class JettyWebsocketManager(
+ wsListener: WebsocketListener) extends WebsocketManager { thisMgr =>
+
+ private[this] var webSocketConn: WebSocket.Connection = null
+ private[this] var closed = false
+
+ // We can just set the logger here, since we are supposed to be protected by
+ // the private ClassLoader that loads us reflectively.
+ log.Log.setLog(new WSLogger("root"))
+
+ private[this] val connector = new SelectChannelConnector
+
+ connector.setHost("localhost")
+ connector.setPort(0)
+
+ private[this] val server = new Server()
+
+ server.addConnector(connector)
+ server.setHandler(new WebSocketHandler {
+ // Support Hixie 76 for Phantom.js
+ getWebSocketFactory().setMinVersion(-1)
+
+ override def doWebSocketConnect(
+ request: HttpServletRequest, protocol: String): WebSocket =
+ new ComWebSocketListener
+ })
+
+ server.addLifeCycleListener(new AbstractLifeCycle.AbstractLifeCycleListener {
+ override def lifeCycleStarted(event: LifeCycle): Unit = {
+ if (event.isRunning())
+ wsListener.onRunning()
+ }
+ })
+
+ private class ComWebSocketListener extends WebSocket.OnTextMessage {
+ override def onOpen(connection: WebSocket.Connection): Unit = {
+ thisMgr.synchronized {
+ if (isConnected)
+ throw new IllegalStateException("Client connected twice")
+ connection.setMaxIdleTime(Int.MaxValue)
+ webSocketConn = connection
+ }
+ wsListener.onOpen()
+ }
+
+ override def onClose(statusCode: Int, reason: String): Unit = {
+ thisMgr.synchronized {
+ webSocketConn = null
+ closed = true
+ }
+ wsListener.onClose()
+ server.stop()
+
+ if (statusCode != 1000) {
+ throw new Exception("Abnormal closing of connection. " +
+ s"Code: $statusCode, Reason: $reason")
+ }
+ }
+
+ override def onMessage(message: String): Unit =
+ wsListener.onMessage(message)
+ }
+
+ private class WSLogger(fullName: String) extends log.AbstractLogger {
+ private[this] var debugEnabled = false
+
+ def debug(msg: String, args: Object*): Unit =
+ if (debugEnabled) log("DEBUG", msg, args)
+
+ def debug(msg: String, thrown: Throwable): Unit =
+ if (debugEnabled) log("DEBUG", msg, thrown)
+
+ def debug(thrown: Throwable): Unit =
+ if (debugEnabled) log("DEBUG", thrown)
+
+ def getName(): String = fullName
+
+ def ignore(ignored: Throwable): Unit = ()
+
+ def info(msg: String, args: Object*): Unit = log("INFO", msg, args)
+ def info(msg: String, thrown: Throwable): Unit = log("INFO", msg, thrown)
+ def info(thrown: Throwable): Unit = log("INFO", thrown)
+
+ def warn(msg: String, args: Object*): Unit = log("WARN", msg, args)
+ def warn(msg: String, thrown: Throwable): Unit = log("WARN", msg, thrown)
+ def warn(thrown: Throwable): Unit = log("WARN", thrown)
+
+ def isDebugEnabled(): Boolean = debugEnabled
+ def setDebugEnabled(enabled: Boolean): Unit = debugEnabled = enabled
+
+ private def log(lvl: String, msg: String, args: Object*): Unit =
+ wsListener.log(s"$lvl: $msg " + args.mkString(", "))
+
+ private def log(lvl: String, msg: String, thrown: Throwable): Unit =
+ wsListener.log(s"$lvl: $msg $thrown\n{$thrown.getStackStrace}")
+
+ private def log(lvl: String, thrown: Throwable): Unit =
+ wsListener.log(s"$lvl: $thrown\n{$thrown.getStackStrace}")
+
+ protected def newLogger(fullName: String) = new WSLogger(fullName)
+ }
+
+ def start(): Unit = server.start()
+
+ def stop(): Unit = server.stop()
+
+ def isConnected: Boolean = webSocketConn != null && !closed
+ def isClosed: Boolean = closed
+
+ def localPort: Int = connector.getLocalPort()
+
+ def sendMessage(msg: String): Unit = synchronized {
+ if (webSocketConn != null)
+ webSocketConn.sendMessage(msg)
+ }
+
+}
diff --git a/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/PhantomJSEnv.scala b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/PhantomJSEnv.scala
new file mode 100644
index 000000000..b4edeecbf
--- /dev/null
+++ b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/PhantomJSEnv.scala
@@ -0,0 +1,495 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs
+
+import org.scalajs.jsenv._
+import org.scalajs.jsenv.Utils.OptDeadline
+
+import org.scalajs.core.ir.Utils.{escapeJS, fixFileURI}
+
+import org.scalajs.core.tools.io._
+import org.scalajs.core.tools.logging._
+
+import java.io.{ Console => _, _ }
+import java.net._
+
+import scala.io.Source
+import scala.collection.mutable
+import scala.annotation.tailrec
+
+import scala.concurrent.{ExecutionContext, TimeoutException, Future}
+import scala.concurrent.duration.Duration
+
+class PhantomJSEnv(
+ @deprecatedName('phantomjsPath)
+ protected val executable: String = "phantomjs",
+ @deprecatedName('addArgs)
+ args: Seq[String] = Seq.empty,
+ @deprecatedName('addEnv)
+ env: Map[String, String] = Map.empty,
+ val autoExit: Boolean = true,
+ jettyClassLoader: ClassLoader = null
+) extends ExternalJSEnv(args, env) with ComJSEnv {
+
+ import PhantomJSEnv._
+
+ protected def vmName: String = "PhantomJS"
+
+ override def jsRunner(files: Seq[VirtualJSFile]): JSRunner =
+ new PhantomRunner(files)
+
+ override def asyncRunner(files: Seq[VirtualJSFile]): AsyncJSRunner =
+ new AsyncPhantomRunner(files)
+
+ override def comRunner(files: Seq[VirtualJSFile]): ComJSRunner =
+ new ComPhantomRunner(files)
+
+ protected class PhantomRunner(files: Seq[VirtualJSFile])
+ extends ExtRunner(files) with AbstractPhantomRunner
+
+ protected class AsyncPhantomRunner(files: Seq[VirtualJSFile])
+ extends AsyncExtRunner(files) with AbstractPhantomRunner
+
+ protected class ComPhantomRunner(files: Seq[VirtualJSFile])
+ extends AsyncPhantomRunner(files) with ComJSRunner {
+
+ private var mgrIsRunning: Boolean = false
+
+ private object websocketListener extends WebsocketListener { // scalastyle:ignore
+ def onRunning(): Unit = ComPhantomRunner.this.synchronized {
+ mgrIsRunning = true
+ ComPhantomRunner.this.notifyAll()
+ }
+
+ def onOpen(): Unit = ComPhantomRunner.this.synchronized {
+ ComPhantomRunner.this.notifyAll()
+ }
+
+ def onClose(): Unit = ComPhantomRunner.this.synchronized {
+ ComPhantomRunner.this.notifyAll()
+ }
+
+ def onMessage(msg: String): Unit = ComPhantomRunner.this.synchronized {
+ recvBuf.enqueue(msg)
+ ComPhantomRunner.this.notifyAll()
+ }
+
+ def log(msg: String): Unit = logger.debug(s"PhantomJS WS Jetty: $msg")
+ }
+
+ private def loadMgr() = {
+ val loader =
+ if (jettyClassLoader != null) jettyClassLoader
+ else getClass().getClassLoader()
+
+ val clazz = loader.loadClass(
+ "org.scalajs.jsenv.phantomjs.JettyWebsocketManager")
+
+ val ctors = clazz.getConstructors()
+ assert(ctors.length == 1, "JettyWebsocketManager may only have one ctor")
+
+ val mgr = ctors.head.newInstance(websocketListener)
+
+ mgr.asInstanceOf[WebsocketManager]
+ }
+
+ private val mgr: WebsocketManager = loadMgr()
+
+ future.onComplete(_ => synchronized(notifyAll()))(ExecutionContext.global)
+
+ private[this] val recvBuf = mutable.Queue.empty[String]
+ private[this] val fragmentsBuf = new StringBuilder
+
+ private def comSetup = {
+ def maybeExit(code: Int) =
+ if (autoExit)
+ s"window.callPhantom({ action: 'exit', returnValue: $code });"
+ else
+ ""
+
+ /* The WebSocket server starts asynchronously. We must wait for it to
+ * be fully operational before a) retrieving the port it is running on
+ * and b) feeding the connecting JS script to the VM.
+ */
+ synchronized {
+ while (!mgrIsRunning)
+ wait(10000)
+ if (!mgrIsRunning)
+ throw new TimeoutException(
+ "The PhantomJS WebSocket server startup timed out")
+ }
+
+ val serverPort = mgr.localPort
+ assert(serverPort > 0,
+ s"Manager running with a non-positive port number: $serverPort")
+
+ val code = s"""
+ |(function() {
+ | var MaxPayloadSize = $MaxCharPayloadSize;
+ |
+ | // The socket for communication
+ | var websocket = null;
+ |
+ | // Buffer for messages sent before socket is open
+ | var outMsgBuf = null;
+ |
+ | function sendImpl(msg) {
+ | var frags = (msg.length / MaxPayloadSize) | 0;
+ |
+ | for (var i = 0; i < frags; ++i) {
+ | var payload = msg.substring(
+ | i * MaxPayloadSize, (i + 1) * MaxPayloadSize);
+ | websocket.send("1" + payload);
+ | }
+ |
+ | websocket.send("0" + msg.substring(frags * MaxPayloadSize));
+ | }
+ |
+ | function recvImpl(recvCB) {
+ | var recvBuf = "";
+ |
+ | return function(evt) {
+ | var newData = recvBuf + evt.data.substring(1);
+ | if (evt.data.charAt(0) == "0") {
+ | recvBuf = "";
+ | recvCB(newData);
+ | } else if (evt.data.charAt(0) == "1") {
+ | recvBuf = newData;
+ | } else {
+ | throw new Error("Bad fragmentation flag in " + evt.data);
+ | }
+ | };
+ | }
+ |
+ | window.scalajsCom = {
+ | init: function(recvCB) {
+ | if (websocket !== null) throw new Error("Com already open");
+ |
+ | outMsgBuf = [];
+ |
+ | websocket = new WebSocket("ws://localhost:$serverPort");
+ |
+ | websocket.onopen = function(evt) {
+ | for (var i = 0; i < outMsgBuf.length; ++i)
+ | sendImpl(outMsgBuf[i]);
+ | outMsgBuf = null;
+ | };
+ | websocket.onclose = function(evt) {
+ | websocket = null;
+ | if (outMsgBuf !== null)
+ | throw new Error("WebSocket closed before being opened: " + evt);
+ | ${maybeExit(0)}
+ | };
+ | websocket.onmessage = recvImpl(recvCB);
+ | websocket.onerror = function(evt) {
+ | websocket = null;
+ | throw new Error("Websocket failed: " + evt);
+ | };
+ |
+ | // Take over responsibility to auto exit
+ | window.callPhantom({
+ | action: 'setAutoExit',
+ | autoExit: false
+ | });
+ | },
+ | send: function(msg) {
+ | if (websocket === null)
+ | return; // we are closed already. ignore message
+ |
+ | if (outMsgBuf !== null)
+ | outMsgBuf.push(msg);
+ | else
+ | sendImpl(msg);
+ | },
+ | close: function() {
+ | if (websocket === null)
+ | return; // we are closed already. all is well.
+ |
+ | if (outMsgBuf !== null)
+ | // Reschedule ourselves to give onopen a chance to kick in
+ | window.setTimeout(window.scalajsCom.close, 10);
+ | else
+ | websocket.close();
+ | }
+ | }
+ |}).call(this);""".stripMargin
+
+ new MemVirtualJSFile("comSetup.js").withContent(code)
+ }
+
+ override def start(logger: Logger, console: JSConsole): Future[Unit] = {
+ setupLoggerAndConsole(logger, console)
+ mgr.start()
+ startExternalJSEnv()
+ future
+ }
+
+ def send(msg: String): Unit = synchronized {
+ if (awaitConnection()) {
+ val fragParts = msg.length / MaxCharPayloadSize
+
+ for (i <- 0 until fragParts) {
+ val payload = msg.substring(
+ i * MaxCharPayloadSize, (i + 1) * MaxCharPayloadSize)
+ mgr.sendMessage("1" + payload)
+ }
+
+ mgr.sendMessage("0" + msg.substring(fragParts * MaxCharPayloadSize))
+ }
+ }
+
+ def receive(timeout: Duration): String = synchronized {
+ if (recvBuf.isEmpty && !awaitConnection())
+ throw new ComJSEnv.ComClosedException("Phantom.js isn't connected")
+
+ val deadline = OptDeadline(timeout)
+
+ @tailrec
+ def loop(): String = {
+ /* The fragments are accumulated in an instance-wide buffer in case
+ * receiving a non-first fragment times out.
+ */
+ val frag = receiveFrag(deadline)
+ fragmentsBuf ++= frag.substring(1)
+
+ if (frag(0) == '0') {
+ val result = fragmentsBuf.result()
+ fragmentsBuf.clear()
+ result
+ } else if (frag(0) == '1') {
+ loop()
+ } else {
+ throw new AssertionError("Bad fragmentation flag in " + frag)
+ }
+ }
+
+ try {
+ loop()
+ } catch {
+ case e: Throwable if !e.isInstanceOf[TimeoutException] =>
+ fragmentsBuf.clear() // the protocol is broken, so discard the buffer
+ throw e
+ }
+ }
+
+ private def receiveFrag(deadline: OptDeadline): String = {
+ while (recvBuf.isEmpty && !mgr.isClosed && !deadline.isOverdue)
+ wait(deadline.millisLeft)
+
+ if (recvBuf.isEmpty) {
+ if (mgr.isClosed)
+ throw new ComJSEnv.ComClosedException
+ else
+ throw new TimeoutException("Timeout expired")
+ }
+
+ recvBuf.dequeue()
+ }
+
+ def close(): Unit = mgr.stop()
+
+ /** Waits until the JS VM has established a connection, or the VM
+ * terminated. Returns true if a connection was established.
+ */
+ private def awaitConnection(): Boolean = {
+ while (!mgr.isConnected && !mgr.isClosed && isRunning)
+ wait(10000)
+ if (!mgr.isConnected && !mgr.isClosed && isRunning)
+ throw new TimeoutException(
+ "The PhantomJS WebSocket client took too long to connect")
+
+ mgr.isConnected
+ }
+
+ override protected def initFiles(): Seq[VirtualJSFile] =
+ super.initFiles :+ comSetup
+ }
+
+ protected trait AbstractPhantomRunner extends AbstractExtRunner {
+
+ protected[this] val codeCache = new VirtualFileMaterializer
+
+ override protected def getVMArgs() =
+ // Add launcher file to arguments
+ additionalArgs :+ createTmpLauncherFile().getAbsolutePath
+
+ /** In phantom.js, we include JS using HTML */
+ override protected def writeJSFile(file: VirtualJSFile, writer: Writer) = {
+ val realFile = codeCache.materialize(file)
+ val fname = htmlEscape(fixFileURI(realFile.toURI).toASCIIString)
+ writer.write(
+ s"""""" + "\n")
+ }
+
+ /**
+ * PhantomJS doesn't support Function.prototype.bind. We polyfill it.
+ * https://github.com/ariya/phantomjs/issues/10522
+ */
+ override protected def initFiles(): Seq[VirtualJSFile] = Seq(
+ // scalastyle:off line.size.limit
+ new MemVirtualJSFile("bindPolyfill.js").withContent(
+ """
+ |// Polyfill for Function.bind from Facebook react:
+ |// https://github.com/facebook/react/blob/3dc10749080a460e48bee46d769763ec7191ac76/src/test/phantomjs-shims.js
+ |// Originally licensed under Apache 2.0
+ |(function() {
+ |
+ | var Ap = Array.prototype;
+ | var slice = Ap.slice;
+ | var Fp = Function.prototype;
+ |
+ | if (!Fp.bind) {
+ | // PhantomJS doesn't support Function.prototype.bind natively, so
+ | // polyfill it whenever this module is required.
+ | Fp.bind = function(context) {
+ | var func = this;
+ | var args = slice.call(arguments, 1);
+ |
+ | function bound() {
+ | var invokedAsConstructor = func.prototype && (this instanceof func);
+ | return func.apply(
+ | // Ignore the context parameter when invoking the bound function
+ | // as a constructor. Note that this includes not only constructor
+ | // invocations using the new keyword but also calls to base class
+ | // constructors such as BaseClass.call(this, ...) or super(...).
+ | !invokedAsConstructor && context || this,
+ | args.concat(slice.call(arguments))
+ | );
+ | }
+ |
+ | // The bound function must share the .prototype of the unbound
+ | // function so that any object created by one constructor will count
+ | // as an instance of both constructors.
+ | bound.prototype = func.prototype;
+ |
+ | return bound;
+ | };
+ | }
+ |
+ |})();
+ |""".stripMargin
+ ),
+ new MemVirtualJSFile("scalaJSEnvInfo.js").withContent(
+ """
+ |__ScalaJSEnv = {
+ | exitFunction: function(status) {
+ | window.callPhantom({
+ | action: 'exit',
+ | returnValue: status | 0
+ | });
+ | }
+ |};
+ """.stripMargin
+ )
+ // scalastyle:on line.size.limit
+ )
+
+ protected def writeWebpageLauncher(out: Writer): Unit = {
+ out.write(s"""
+ Phantom.js Launcher
+ """)
+ sendJS(getJSFiles(), out)
+ out.write(s"\n\n\n")
+ }
+
+ protected def createTmpLauncherFile(): File = {
+ val webF = createTmpWebpage()
+
+ val launcherTmpF = File.createTempFile("phantomjs-launcher", ".js")
+ launcherTmpF.deleteOnExit()
+
+ val out = new FileWriter(launcherTmpF)
+
+ try {
+ out.write(
+ s"""// Scala.js Phantom.js launcher
+ |var page = require('webpage').create();
+ |var url = "${escapeJS(fixFileURI(webF.toURI).toASCIIString)}";
+ |var autoExit = $autoExit;
+ |page.onConsoleMessage = function(msg) {
+ | console.log(msg);
+ |};
+ |page.onError = function(msg, trace) {
+ | console.error(msg);
+ | if (trace && trace.length) {
+ | console.error('');
+ | trace.forEach(function(t) {
+ | console.error(' ' + t.file + ':' + t.line +
+ | (t.function ? ' (in function "' + t.function +'")' : ''));
+ | });
+ | }
+ |
+ | phantom.exit(2);
+ |};
+ |page.onCallback = function(data) {
+ | if (!data.action) {
+ | console.error('Called callback without action');
+ | phantom.exit(3);
+ | } else if (data.action === 'exit') {
+ | phantom.exit(data.returnValue || 0);
+ | } else if (data.action === 'setAutoExit') {
+ | if (typeof(data.autoExit) === 'boolean')
+ | autoExit = data.autoExit;
+ | else
+ | autoExit = true;
+ | } else {
+ | console.error('Unknown callback action ' + data.action);
+ | phantom.exit(4);
+ | }
+ |};
+ |page.open(url, function (status) {
+ | if (autoExit || status !== 'success')
+ | phantom.exit(status !== 'success');
+ |});
+ |""".stripMargin)
+ } finally {
+ out.close()
+ }
+
+ logger.debug(
+ "PhantomJS using launcher at: " + launcherTmpF.getAbsolutePath())
+
+ launcherTmpF
+ }
+
+ protected def createTmpWebpage(): File = {
+ val webTmpF = File.createTempFile("phantomjs-launcher-webpage", ".html")
+ webTmpF.deleteOnExit()
+
+ val out = new BufferedWriter(
+ new OutputStreamWriter(new FileOutputStream(webTmpF), "UTF-8"))
+
+ try {
+ writeWebpageLauncher(out)
+ } finally {
+ out.close()
+ }
+
+ logger.debug(
+ "PhantomJS using webpage launcher at: " + webTmpF.getAbsolutePath())
+
+ webTmpF
+ }
+ }
+
+ protected def htmlEscape(str: String): String = str.flatMap {
+ case '<' => "<"
+ case '>' => ">"
+ case '"' => """
+ case '&' => "&"
+ case c => c :: Nil
+ }
+
+}
+
+private object PhantomJSEnv {
+ private final val MaxByteMessageSize = 32768 // 32 KB
+ private final val MaxCharMessageSize = MaxByteMessageSize / 2 // 2B per char
+ private final val MaxCharPayloadSize = MaxCharMessageSize - 1 // frag flag
+}
diff --git a/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/PhantomJettyClassLoader.scala b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/PhantomJettyClassLoader.scala
new file mode 100644
index 000000000..ea8f5ba93
--- /dev/null
+++ b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/PhantomJettyClassLoader.scala
@@ -0,0 +1,71 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs
+
+import org.scalajs.core.tools.io.IO
+
+/** A special `java.lang.ClassLoader` to load the Jetty 8 dependency of
+ * [[PhantomJSEnv]] in a private space.
+ *
+ * It loads everything that belongs to `JettyWebsocketManager` itself (while
+ * retrieving the requested class file from its parent.
+ * For all other classes, it first tries to load them from `jettyLoader`,
+ * which should only contain the Jetty 8 classpath.
+ * If this fails, it delegates to its parent.
+ *
+ * The rationale is, that `JettyWebsocketManager` and its dependees can use
+ * the classes on the Jetty 8 classpath, while they remain hidden from the rest
+ * of the Java world. This allows to load another version of Jetty in the same
+ * JVM for the rest of the project.
+ */
+final class PhantomJettyClassLoader(jettyLoader: ClassLoader,
+ parent: ClassLoader) extends ClassLoader(parent) {
+
+ def this(loader: ClassLoader) =
+ this(loader, ClassLoader.getSystemClassLoader())
+
+ /** Classes needed to bridge private jetty classpath and public PhantomJS
+ * Basically everything defined in JettyWebsocketManager.
+ */
+ private val bridgeClasses = Set(
+ "org.scalajs.jsenv.phantomjs.JettyWebsocketManager",
+ "org.scalajs.jsenv.phantomjs.JettyWebsocketManager$WSLogger",
+ "org.scalajs.jsenv.phantomjs.JettyWebsocketManager$ComWebSocketListener",
+ "org.scalajs.jsenv.phantomjs.JettyWebsocketManager$$anon$1",
+ "org.scalajs.jsenv.phantomjs.JettyWebsocketManager$$anon$2"
+ )
+
+ override protected def loadClass(name: String, resolve: Boolean): Class[_] = {
+ if (bridgeClasses.contains(name)) {
+ // Load bridgeClasses manually since they must be associated to this
+ // class loader, rather than the parent class loader in order to find the
+ // jetty classes
+
+ // First check if we have loaded it already
+ Option(findLoadedClass(name)) getOrElse {
+ val wsManager =
+ parent.getResourceAsStream(name.replace('.', '/') + ".class")
+
+ if (wsManager == null) {
+ throw new ClassNotFoundException(name)
+ } else {
+ val buf = IO.readInputStreamToByteArray(wsManager)
+ defineClass(name, buf, 0, buf.length)
+ }
+ }
+ } else {
+ try {
+ jettyLoader.loadClass(name)
+ } catch {
+ case _: ClassNotFoundException =>
+ super.loadClass(name, resolve)
+ }
+ }
+ }
+}
diff --git a/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/RetryingComJSEnv.scala b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/RetryingComJSEnv.scala
new file mode 100644
index 000000000..8da4e3fd0
--- /dev/null
+++ b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/RetryingComJSEnv.scala
@@ -0,0 +1,195 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs
+
+import org.scalajs.core.tools.io._
+import org.scalajs.core.tools.logging.Logger
+
+import org.scalajs.jsenv._
+
+import scala.concurrent.{Future, Promise, ExecutionContext}
+import scala.concurrent.duration.Duration
+import scala.collection.mutable
+import scala.annotation.tailrec
+import scala.util.control.NonFatal
+import scala.util.{Try, Failure, Success}
+
+/** A RetryingComJSEnv allows to automatically retry if a call to the underlying
+ * ComJSRunner fails.
+ *
+ * While it protects the JVM side from observing state that differs inbetween
+ * runs that have been retried, it assumes that the executed JavaScript code
+ * does not have side-effects other than the ones visible through the channel
+ * (e.g. writing to a file). It is the users responsibility to ensure this
+ * property.
+ *
+ * No retrying is performed for synchronous, or normal asynchronous runs.
+ *
+ * Although `RetryingComJSEnv` is agnostic of the underlying JS env, and is
+ * therefore not tied to PhantomJS, it is most often used to compensate for
+ * flakiness effects of PhantomJS.
+ */
+final class RetryingComJSEnv(val baseEnv: ComJSEnv,
+ val maxRetries: Int) extends ComJSEnv {
+
+ def this(baseEnv: ComJSEnv) = this(baseEnv, 5)
+
+ def name: String = s"Retrying ${baseEnv.name}"
+
+ def jsRunner(files: Seq[VirtualJSFile]): JSRunner =
+ baseEnv.jsRunner(files)
+
+ def asyncRunner(files: Seq[VirtualJSFile]): AsyncJSRunner =
+ baseEnv.asyncRunner(files)
+
+ def comRunner(files: Seq[VirtualJSFile]): ComJSRunner =
+ new RetryingComJSRunner(files)
+
+ /** Hack to work around abstract override in ComJSRunner */
+ private trait DummyJSRunner {
+ def stop(): Unit = ()
+ }
+
+ private class RetryingComJSRunner(files: Seq[VirtualJSFile])
+ extends DummyJSRunner with ComJSRunner {
+
+ private[this] val promise = Promise[Unit]
+
+ private[this] var curRunner = baseEnv.comRunner(files)
+
+ private[this] var hasReceived = false
+ private[this] var retryCount = 0
+
+ private[this] val log = mutable.Buffer.empty[LogItem]
+
+ private[this] var _logger: Logger = _
+ private[this] var _console: JSConsole = _
+
+ def future: Future[Unit] = promise.future
+
+ def start(logger: Logger, console: JSConsole): Future[Unit] = {
+ require(log.isEmpty, "start() may only be called once")
+
+ _logger = logger
+ _console = console
+
+ logAndDo(Start)
+ future
+ }
+
+ override def stop(): Unit = {
+ require(log.nonEmpty, "start() must have been called")
+ close()
+ logAndDo(Stop)
+ }
+
+ def send(msg: String): Unit = {
+ require(log.nonEmpty, "start() must have been called")
+ logAndDo(Send(msg))
+ }
+
+ def receive(timeout: Duration): String = {
+ @tailrec
+ def recLoop(): String = {
+ // Need to use Try for tailrec
+ Try {
+ val result = curRunner.receive(timeout)
+ // At this point, we are sending state to the JVM, we cannot retry
+ // after this.
+ hasReceived = true
+ result
+ } match {
+ case Failure(t) =>
+ retry(t)
+ recLoop()
+ case Success(v) => v
+ }
+ }
+
+ recLoop()
+ }
+
+ def close(): Unit = {
+ require(log.nonEmpty, "start() must have been called")
+ logAndDo(Close)
+ }
+
+ @tailrec
+ private final def retry(cause: Throwable): Unit = {
+ retryCount += 1
+
+ // Accesses to promise and swaps in the curRunner must be synchronized
+ synchronized {
+ if (hasReceived || retryCount > maxRetries || promise.isCompleted)
+ throw cause
+
+ _logger.warn("Retrying to launch a " + baseEnv.getClass.getName +
+ " after " + cause.toString)
+
+ val oldRunner = curRunner
+
+ curRunner = try {
+ baseEnv.comRunner(files)
+ } catch {
+ case NonFatal(t) =>
+ _logger.error("Could not retry: creating an new runner failed: " +
+ t.toString)
+ throw cause
+ }
+
+ try oldRunner.stop() // just in case
+ catch {
+ case NonFatal(t) => // ignore
+ }
+ }
+
+ // Replay the whole log
+ // Need to use Try for tailrec
+ Try(log.foreach(executeTask)) match {
+ case Failure(t) => retry(t)
+ case _ =>
+ }
+ }
+
+ private def logAndDo(task: LogItem) = {
+ log += task
+ try executeTask(task)
+ catch {
+ case NonFatal(t) => retry(t)
+ }
+ }
+
+ private def executeTask(task: LogItem) = task match {
+ case Start =>
+ import ExecutionContext.Implicits.global
+ val runner = curRunner
+ runner.start(_logger, _console) onComplete { result =>
+ // access to curRunner and promise must be synchronized
+ synchronized {
+ if (curRunner eq runner)
+ promise.complete(result)
+ }
+ }
+ case Send(msg) =>
+ curRunner.send(msg)
+ case Stop =>
+ curRunner.stop()
+ case Close =>
+ curRunner.close()
+ }
+
+ private sealed trait LogItem
+ private case object Start extends LogItem
+ private case class Send(msg: String) extends LogItem
+ private case object Stop extends LogItem
+ private case object Close extends LogItem
+
+ }
+
+}
diff --git a/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/WebsocketListener.scala b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/WebsocketListener.scala
new file mode 100644
index 000000000..a05f76407
--- /dev/null
+++ b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/WebsocketListener.scala
@@ -0,0 +1,18 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs
+
+private[phantomjs] trait WebsocketListener {
+ def onRunning(): Unit
+ def onOpen(): Unit
+ def onClose(): Unit
+ def onMessage(msg: String): Unit
+
+ def log(msg: String): Unit
+}
diff --git a/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/WebsocketManager.scala b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/WebsocketManager.scala
new file mode 100644
index 000000000..489c4b420
--- /dev/null
+++ b/phantomjs-env/src/main/scala/org/scalajs/jsenv/phantomjs/WebsocketManager.scala
@@ -0,0 +1,18 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs
+
+private[phantomjs] trait WebsocketManager {
+ def start(): Unit
+ def stop(): Unit
+ def sendMessage(msg: String): Unit
+ def localPort: Int
+ def isConnected: Boolean
+ def isClosed: Boolean
+}
diff --git a/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/PhantomJSTest.scala b/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/PhantomJSTest.scala
new file mode 100644
index 000000000..c3894e102
--- /dev/null
+++ b/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/PhantomJSTest.scala
@@ -0,0 +1,7 @@
+package org.scalajs.jsenv.phantomjs
+
+import org.scalajs.jsenv.test._
+
+class PhantomJSTest extends JSEnvTest with ComTests {
+ protected def newJSEnv: PhantomJSEnv = new PhantomJSEnv
+}
diff --git a/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/PhantomJSWithCustomInitFilesTest.scala b/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/PhantomJSWithCustomInitFilesTest.scala
new file mode 100644
index 000000000..17716457a
--- /dev/null
+++ b/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/PhantomJSWithCustomInitFilesTest.scala
@@ -0,0 +1,9 @@
+package org.scalajs.jsenv.phantomjs
+
+import org.scalajs.jsenv.test._
+
+class PhantomJSWithCustomInitFilesTest extends CustomInitFilesTest {
+ protected def newJSEnv: PhantomJSEnv = new PhantomJSEnv {
+ override def customInitFiles() = makeCustomInitFiles()
+ }
+}
diff --git a/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/RetryingComJSEnvTest.scala b/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/RetryingComJSEnvTest.scala
new file mode 100644
index 000000000..2161b6118
--- /dev/null
+++ b/phantomjs-env/src/test/scala/org/scalajs/jsenv/phantomjs/RetryingComJSEnvTest.scala
@@ -0,0 +1,92 @@
+package org.scalajs.jsenv.phantomjs
+
+import org.scalajs.core.tools.io.VirtualJSFile
+import org.scalajs.core.tools.logging._
+
+import org.scalajs.jsenv.nodejs.NodeJSEnv
+import org.scalajs.jsenv.{ComJSRunner, JSConsole, _}
+import org.scalajs.jsenv.test._
+
+import scala.concurrent.Future
+import scala.concurrent.duration.Duration
+
+class RetryingComJSEnvTest extends JSEnvTest with ComTests {
+
+ private final val maxFails = 5
+
+ // Don't log anything here
+ override protected def start(runner: AsyncJSRunner): Future[Unit] = {
+ runner.start(NullLogger, ConsoleJSConsole)
+ }
+
+ protected def newJSEnv: RetryingComJSEnv =
+ new RetryingComJSEnv(new FailingEnv(new NodeJSEnv), maxFails)
+
+ private final class FailingEnv(baseEnv: ComJSEnv) extends ComJSEnv {
+ def name: String = s"FailingJSEnv of ${baseEnv.name}"
+
+ private[this] var fails = 0
+ private[this] var failedReceive = false
+
+ def jsRunner(files: Seq[VirtualJSFile]): JSRunner =
+ baseEnv.jsRunner(files)
+
+ def asyncRunner(files: Seq[VirtualJSFile]): AsyncJSRunner =
+ baseEnv.asyncRunner(files)
+
+ def comRunner(files: Seq[VirtualJSFile]): ComJSRunner =
+ new FailingComJSRunner(baseEnv.comRunner(files))
+
+ /** Hack to work around abstract override in ComJSRunner */
+ private trait DummyJSRunner {
+ def stop(): Unit = ()
+ }
+
+ private class FailingComJSRunner(baseRunner: ComJSRunner)
+ extends DummyJSRunner with ComJSRunner {
+
+ def future: Future[Unit] = baseRunner.future
+
+ def send(msg: String): Unit = {
+ maybeFail()
+ baseRunner.send(msg)
+ }
+
+ def receive(timeout: Duration): String = {
+ if (shouldFail) {
+ failedReceive = true
+ fail()
+ }
+ baseRunner.receive(timeout)
+ }
+
+ def start(logger: Logger, console: JSConsole): Future[Unit] = {
+ maybeFail()
+ baseRunner.start(logger, console)
+ }
+
+ override def stop(): Unit = {
+ maybeFail()
+ baseRunner.stop()
+ }
+
+ def close(): Unit = {
+ maybeFail()
+ baseRunner.close()
+ }
+
+ private def shouldFail = !failedReceive && fails < maxFails
+
+ private def maybeFail() = {
+ if (shouldFail)
+ fail()
+ }
+
+ private def fail() = {
+ fails += 1
+ sys.error("Dummy fail for testing purposes")
+ }
+ }
+ }
+
+}
diff --git a/phantomjs-sbt-plugin/src/main/scala/org/scalajs/jsenv/phantomjs/sbtplugin/PhantomJSEnvPlugin.scala b/phantomjs-sbt-plugin/src/main/scala/org/scalajs/jsenv/phantomjs/sbtplugin/PhantomJSEnvPlugin.scala
new file mode 100644
index 000000000..13b1a54b2
--- /dev/null
+++ b/phantomjs-sbt-plugin/src/main/scala/org/scalajs/jsenv/phantomjs/sbtplugin/PhantomJSEnvPlugin.scala
@@ -0,0 +1,102 @@
+/* __ *\
+** ________ ___ / / ___ __ ____ PhantomJS support for Scala.js **
+** / __/ __// _ | / / / _ | __ / // __/ (c) 2013-2017, LAMP/EPFL **
+** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ https://www.scala-js.org/ **
+** /____/\___/_/ |_/____/_/ | |__/ /____/ **
+** |/____/ **
+\* */
+
+package org.scalajs.jsenv.phantomjs.sbtplugin
+
+import sbt._
+import sbt.Keys._
+
+import java.net.URLClassLoader
+
+import org.scalajs.sbtplugin.ScalaJSPlugin
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._
+
+import org.scalajs.jsenv._
+import org.scalajs.jsenv.phantomjs._
+
+/** An sbt plugin that simplifies the setup of `PhantomJSEnv`s.
+ *
+ * There is no need to use `enablePlugins(PhantomJSEnvPlugin)`, as this plugin
+ * is automatically triggered by Scala.js projects.
+ *
+ * Usually, one only needs to use the
+ * [[PhantomJSEnvPlugin.autoImport.PhantomJSEnv]] method.
+ */
+object PhantomJSEnvPlugin extends AutoPlugin {
+ override def requires: Plugins = ScalaJSPlugin
+ override def trigger: PluginTrigger = allRequirements
+
+ object autoImport {
+ /** Class loader for PhantomJSEnv, used to load jetty8.
+ *
+ * Usually, you should not need to use `scalaJSPhantomJSClassLoader`
+ * directly. Instead, use the `PhantomJSEnv()` function.
+ */
+ val scalaJSPhantomJSClassLoader: TaskKey[ClassLoader] = {
+ TaskKey[ClassLoader](
+ "scalaJSPhantomJSClassLoader",
+ "Private class loader to load jetty8 without polluting the " +
+ "classpath. Only use this as the `jettyClassLoader` argument of " +
+ "a PhantomJSEnv.",
+ KeyRanks.Invisible)
+ }
+
+ /** An [[sbt.Def.Initialize Def.Initialize]] for a [[PhantomJSEnv]].
+ *
+ * Use this to specify in your build that you would like to run and/or
+ * test a project with PhantomJS:
+ *
+ * {{{
+ * jsEnv := PhantomJSEnv().value
+ * }}}
+ *
+ * Note that the resulting [[sbt.Def.Setting Setting]] is not scoped at
+ * all, but must be scoped in a project that has the ScalaJSPlugin enabled
+ * to work properly.
+ * Therefore, either put the upper line in your project settings (common
+ * case) or scope it manually, using
+ * [[sbt.ProjectExtra.inScope[* Project.inScope]].
+ */
+ def PhantomJSEnv(
+ executable: String = "phantomjs",
+ args: Seq[String] = Seq.empty,
+ env: Map[String, String] = Map.empty,
+ autoExit: Boolean = true
+ ): Def.Initialize[Task[PhantomJSEnv]] = Def.task {
+ val loader = scalaJSPhantomJSClassLoader.value
+ new PhantomJSEnv(executable, args, env, autoExit, loader)
+ }
+ }
+
+ import autoImport._
+
+ val phantomJSJettyModules: Seq[ModuleID] = Seq(
+ "org.eclipse.jetty" % "jetty-websocket" % "8.1.16.v20140903",
+ "org.eclipse.jetty" % "jetty-server" % "8.1.16.v20140903"
+ )
+
+ override def projectSettings: Seq[Setting[_]] = Seq(
+ /* Depend on jetty artifacts in a dummy configuration to be able to inject
+ * them into the PhantomJS runner if necessary.
+ * See scalaJSPhantomJSClassLoader.
+ */
+ ivyConfigurations += config("phantom-js-jetty").hide,
+ libraryDependencies ++= phantomJSJettyModules.map(_ % "phantom-js-jetty"),
+
+ scalaJSPhantomJSClassLoader := {
+ val report = update.value
+ val jars = report.select(configurationFilter("phantom-js-jetty"))
+
+ val jettyLoader =
+ new URLClassLoader(jars.map(_.toURI.toURL).toArray, null)
+
+ new PhantomJettyClassLoader(jettyLoader, getClass.getClassLoader)
+ }
+ )
+
+}
diff --git a/project/build.properties b/project/build.properties
new file mode 100644
index 000000000..64317fdae
--- /dev/null
+++ b/project/build.properties
@@ -0,0 +1 @@
+sbt.version=0.13.15
diff --git a/sbt-plugin-test/build.sbt b/sbt-plugin-test/build.sbt
new file mode 100644
index 000000000..efbf8a420
--- /dev/null
+++ b/sbt-plugin-test/build.sbt
@@ -0,0 +1,15 @@
+inThisBuild(Seq(
+ version := "0.1.0-SNAPSHOT",
+ scalaVersion := "2.11.11"
+))
+
+name := "sbt-plugin-test"
+
+lazy val jetty9 = project.
+ enablePlugins(ScalaJSPlugin).
+ settings(
+ name := "Scala.js sbt test with jetty9 on classpath",
+ // Use PhantomJS, allow cross domain requests
+ jsEnv := PhantomJSEnv(args = Seq("--web-security=no")).value,
+ Jetty9Test.runSetting
+ )
diff --git a/sbt-plugin-test/jetty9/src/main/resources/test.txt b/sbt-plugin-test/jetty9/src/main/resources/test.txt
new file mode 100644
index 000000000..68300b856
--- /dev/null
+++ b/sbt-plugin-test/jetty9/src/main/resources/test.txt
@@ -0,0 +1 @@
+It works!
diff --git a/sbt-plugin-test/project/Jetty9Test.scala b/sbt-plugin-test/project/Jetty9Test.scala
new file mode 100644
index 000000000..031bda154
--- /dev/null
+++ b/sbt-plugin-test/project/Jetty9Test.scala
@@ -0,0 +1,85 @@
+import sbt._
+import Keys._
+
+import org.scalajs.sbtplugin._
+import ScalaJSPlugin.autoImport._
+import Implicits._
+
+import org.scalajs.jsenv._
+import org.scalajs.core.tools.io._
+
+import org.eclipse.jetty.server._
+import org.eclipse.jetty.server.handler._
+import org.eclipse.jetty.util.component._
+
+import java.io.File
+
+import scala.concurrent.duration._
+
+object Jetty9Test {
+
+ private val jettyPort = 23548
+
+ val runSetting = run <<= Def.inputTask {
+ val env = (jsEnv in Compile).value.asInstanceOf[ComJSEnv]
+ val files = (jsExecutionFiles in Compile).value
+ val jsConsole = scalaJSConsole.value
+
+ val code = new MemVirtualJSFile("runner.js").withContent(
+ """
+ scalajsCom.init(function(msg) {
+ var xhr = new XMLHttpRequest();
+ xhr.open("GET", msg);
+ xhr.onload = (function() {
+ scalajsCom.send(xhr.responseText.trim());
+ scalajsCom.close();
+ });
+ xhr.onerror = (function() {
+ scalajsCom.send("failed!");
+ scalajsCom.close();
+ });
+ xhr.send();
+ });
+ """
+ )
+
+ val runner = env.comRunner(files :+ code)
+
+ runner.start(streams.value.log, jsConsole)
+
+ val jetty = setupJetty((resourceDirectory in Compile).value)
+
+ jetty.addLifeCycleListener(new AbstractLifeCycle.AbstractLifeCycleListener {
+ override def lifeCycleStarted(event: LifeCycle): Unit = {
+ try {
+ runner.send(s"http://localhost:$jettyPort/test.txt")
+ val msg = runner.receive()
+ val expected = "It works!"
+ if (msg != expected)
+ sys.error(s"""received "$msg" instead of "$expected"""")
+ } finally {
+ runner.close()
+ jetty.stop()
+ }
+ }
+ })
+
+ jetty.start()
+ runner.await(30.seconds)
+ jetty.join()
+ }
+
+ private def setupJetty(dir: File): Server = {
+ val server = new Server(jettyPort)
+
+ val resource_handler = new ResourceHandler()
+ resource_handler.setResourceBase(dir.getAbsolutePath)
+
+ val handlers = new HandlerList()
+ handlers.setHandlers(Array(resource_handler, new DefaultHandler()))
+ server.setHandler(handlers)
+
+ server
+ }
+
+}
diff --git a/sbt-plugin-test/project/build.properties b/sbt-plugin-test/project/build.properties
new file mode 100644
index 000000000..64317fdae
--- /dev/null
+++ b/sbt-plugin-test/project/build.properties
@@ -0,0 +1 @@
+sbt.version=0.13.15
diff --git a/sbt-plugin-test/project/build.sbt b/sbt-plugin-test/project/build.sbt
new file mode 100644
index 000000000..4b1df3f87
--- /dev/null
+++ b/sbt-plugin-test/project/build.sbt
@@ -0,0 +1,3 @@
+addSbtPlugin("org.scala-js" % "sbt-scalajs-env-phantomjs" % "0.1.0-SNAPSHOT")
+
+libraryDependencies += "org.eclipse.jetty" % "jetty-server" % "9.2.3.v20140905"