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"