diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6d67c42 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +sudo: false +language: scala +script: + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs/test + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs/doc + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs/mimaReportBinaryIssues + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs/headerCheck scalajs-js-envs/test:headerCheck + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs-test-kit/test + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs-test-kit/doc + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs-test-kit/mimaReportBinaryIssues + - sbt ++$TRAVIS_SCALA_VERSION scalajs-js-envs-test-kit/headerCheck scalajs-js-envs-test-kit/test:headerCheck + - sbt ++$TRAVIS_SCALA_VERSION scalajs-env-nodejs/test + - sbt ++$TRAVIS_SCALA_VERSION scalajs-env-nodejs/doc + - sbt ++$TRAVIS_SCALA_VERSION scalajs-env-nodejs/mimaReportBinaryIssues + - sbt ++$TRAVIS_SCALA_VERSION scalajs-env-nodejs/headerCheck scalajs-env-nodejs/test:headerCheck +scala: + - 2.11.12 + - 2.12.11 + - 2.13.2 +jdk: + - openjdk8 + +cache: + directories: + - "$HOME/.cache/coursier" + - "$HOME/.ivy2/cache" + - "$HOME/.sbt" +before_cache: + - rm -fv $HOME/.ivy2/.sbt.ivy.lock + - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print -delete + - find $HOME/.sbt -name "*.lock" -print -delete diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..e75401b --- /dev/null +++ b/build.sbt @@ -0,0 +1,143 @@ +val previousVersion: Option[String] = Some("1.1.0") +val newScalaBinaryVersionsInThisRelease: Set[String] = Set() + +inThisBuild(Def.settings( + version := "1.1.1-SNAPSHOT", + organization := "org.scala-js", + scalaVersion := "2.12.11", + crossScalaVersions := Seq("2.11.12", "2.12.11", "2.13.2"), + scalacOptions ++= Seq( + "-deprecation", + "-feature", + "-Xfatal-warnings", + "-encoding", "utf-8", + ), + + // Licensing + homepage := Some(url("https://github.com/scala-js/scala-js-js-envs")), + startYear := Some(2013), + licenses += (("Apache-2.0", url("https://www.apache.org/licenses/LICENSE-2.0"))), + scmInfo := Some(ScmInfo( + url("https://github.com/scala-js/scala-js-js-envs"), + "scm:git:git@github.com:scala-js/scala-js-js-envs.git", + Some("scm:git:git@github.com:scala-js/scala-js-js-envs.git"))), + + // Publishing + publishMavenStyle := true, + publishTo := { + val nexus = "https://oss.sonatype.org/" + if (version.value.endsWith("-SNAPSHOT")) + 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 }, +)) + +val commonSettings = Def.settings( + // Links to the JavaDoc do not work + Compile / doc / scalacOptions -= "-Xfatal-warnings", + + // Scaladoc linking + apiURL := { + val name = moduleName.value + val scalaBinVer = scalaBinaryVersion.value + val ver = version.value + Some(url(s"https://javadoc.io/doc/org.scala-js/${name}_$scalaBinVer/$ver/")) + }, + autoAPIMappings := true, + + // sbt-header configuration + headerLicense := Some(HeaderLicense.Custom( + s"""Scala.js JS Envs (${homepage.value.get}) + | + |Copyright EPFL. + | + |Licensed under Apache License 2.0 + |(https://www.apache.org/licenses/LICENSE-2.0). + | + |See the NOTICE file distributed with this work for + |additional information regarding copyright ownership. + |""".stripMargin + )), + + // MiMa auto-configuration + mimaPreviousArtifacts ++= { + val scalaV = scalaVersion.value + val scalaBinaryV = scalaBinaryVersion.value + val thisProjectID = projectID.value + previousVersion match { + case None => + Set.empty + case _ if newScalaBinaryVersionsInThisRelease.contains(scalaBinaryV) => + // New in this release, no binary compatibility to comply to + Set.empty + case Some(prevVersion) => + /* Filter out e:info.apiURL as it expects 1.1.1-SNAPSHOT, whereas the + * artifact we're looking for has 1.1.0 (for example). + */ + val prevExtraAttributes = + thisProjectID.extraAttributes.filterKeys(_ != "e:info.apiURL") + val prevProjectID = + (thisProjectID.organization % thisProjectID.name % prevVersion) + .cross(thisProjectID.crossVersion) + .extra(prevExtraAttributes.toSeq: _*) + Set(prevProjectID) + } + }, +) + +lazy val root = project + .in(file(".")) + +lazy val `scalajs-js-envs` = project + .in(file("js-envs")) + .settings( + commonSettings, + libraryDependencies ++= Seq( + "org.scala-js" %% "scalajs-logging" % "1.1.1", + "com.novocode" % "junit-interface" % "0.11" % "test", + ), + ) + +lazy val `scalajs-js-envs-test-kit` = project + .in(file("js-envs-test-kit")) + .settings( + commonSettings, + libraryDependencies ++= Seq( + "com.google.jimfs" % "jimfs" % "1.1", + "junit" % "junit" % "4.12", + "com.novocode" % "junit-interface" % "0.11" % "test" + ), + ) + .dependsOn(`scalajs-js-envs`) + +lazy val `scalajs-env-nodejs` = project + .in(file("nodejs-env")) + .settings( + commonSettings, + libraryDependencies ++= Seq( + "com.google.jimfs" % "jimfs" % "1.1", + "com.novocode" % "junit-interface" % "0.11" % "test" + ), + ) + .dependsOn(`scalajs-js-envs`, `scalajs-js-envs-test-kit` % "test") diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/ComTests.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/ComTests.scala new file mode 100644 index 0000000..e42f0f7 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/ComTests.scala @@ -0,0 +1,151 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test + +import org.junit.{Before, Test, AssumptionViolatedException} +import org.junit.Assume._ + +import org.scalajs.jsenv._ +import org.scalajs.jsenv.test.kit.TestKit + +private[test] class ComTests(config: JSEnvSuiteConfig) { + private val kit = new TestKit(config.jsEnv, config.awaitTimeout) + + @Before + def before: Unit = { + assumeTrue("JSEnv needs com support", config.supportsCom) + } + + @Test + def basicTest: Unit = { + kit.withComRun(""" + scalajsCom.init(function(msg) { scalajsCom.send("received: " + msg); }); + scalajsCom.send("Hello World"); + """) { run => + + run.expectMsg("Hello World") + + for (i <- 0 to 10) { + run + .send(i.toString) + .expectMsg(s"received: $i") + } + + run.expectNoMsgs() + .closeRun() + } + } + + @Test + def jsExitsOnMessageTest: Unit = { + val exitStat = config.exitJSStatement.getOrElse( + throw new AssumptionViolatedException("JSEnv needs exitJSStatement")) + + kit.withComRun(s""" + scalajsCom.init(function(msg) { $exitStat }); + for (var i = 0; i < 10; ++i) + scalajsCom.send("msg: " + i); + """) { run => + + for (i <- 0 until 10) + run.expectMsg(s"msg: $i") + + run + .send("quit") + .expectNoMsgs() + .succeeds() + } + } + + @Test + def multiEnvTest: Unit = { + val n = 10 + val runs = List.fill(5) { + kit.startWithCom(""" + scalajsCom.init(function(msg) { + scalajsCom.send("pong"); + }); + """) + } + + try { + for (_ <- 0 until n) { + runs.foreach(_.send("ping")) + runs.foreach(_.expectMsg("pong")) + } + + runs.foreach { + _.expectNoMsgs() + .closeRun() + } + } finally { + runs.foreach(_.close()) + } + } + + private def replyTest(msg: String) = { + kit.withComRun("scalajsCom.init(scalajsCom.send);") { + _.send(msg) + .expectMsg(msg) + .expectNoMsgs() + .closeRun() + } + } + + @Test + def largeMessageTest: Unit = { + /* 1MB data. + * (i & 0x7f) limits the input to the ASCII repertoire, which will use + * exactly 1 byte per Char in UTF-8. This restriction also ensures that we + * do not introduce surrogate characters and therefore no invalid UTF-16 + * strings. + */ + replyTest(new String(Array.tabulate(1024 * 1024)(i => (i & 0x7f).toChar))) + } + + @Test + def highCharTest: Unit = { // #1536 + replyTest("\uC421\u8F10\u0112\uFF32") + } + + @Test + def noInitTest: Unit = { + kit.withComRun("") { + _.send("Dummy") + .expectNoMsgs() + .closeRun() + } + } + + @Test + def separateComStdoutTest: Unit = { + // Make sure that com and stdout do not interfere with each other. + kit.withComRun(""" + scalajsCom.init(function (msg) { + console.log("got: " + msg) + }); + console.log("a"); + scalajsCom.send("b"); + scalajsCom.send("c"); + console.log("d"); + """) { + _.expectOut("a\n") + .expectMsg("b") + .expectMsg("c") + .expectOut("d\n") + .send("foo") + .expectOut("got: foo\n") + .closeRun() + } + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/JSEnvSuite.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/JSEnvSuite.scala new file mode 100644 index 0000000..b296a0a --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/JSEnvSuite.scala @@ -0,0 +1,79 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test + +import org.scalajs.jsenv.JSEnv + +import scala.reflect.ClassTag + +import org.junit.runner.Runner +import org.junit.runners.Suite +import org.junit.runners.parameterized.{TestWithParameters, BlockJUnit4ClassRunnerWithParameters} +import org.junit.runners.model.TestClass + +/** Conformance test suite for any [[JSEnv]] implementation. + * + * Use with the [[JSEnvSuiteRunner]]. + * + * Example: + * {{{ + * import org.junit.runner.RunWith + * + * @RunWith(classOf[JSEnvSuiteRunner]) + * class MyJSEnvSuite extends JSEnvSuite(JSEnvSuiteConfig(new MyJSEnv)) + * }}} + * + * @see [[JSEnvSuiteConfig]] for details on the configuration. + */ +abstract class JSEnvSuite(private[test] val config: JSEnvSuiteConfig) + +/** Runner for a [[JSEnvSuite]]. May only be used on subclasses of [[JSEnvSuite]]. */ +final class JSEnvSuiteRunner(root: Class[_], config: JSEnvSuiteConfig) + extends Suite(root, JSEnvSuiteRunner.getRunners(config)) { + + /** Constructor for reflective instantiation via `@RunWith`. */ + def this(suite: Class[_ <: JSEnvSuite]) = + this(suite, suite.getDeclaredConstructor().newInstance().config) + + /** Constructor for instantiation in a user defined Runner. */ + def this(config: JSEnvSuiteConfig) = this(null, config) +} + +private object JSEnvSuiteRunner { + private def r[T](config: JSEnvSuiteConfig, params: (String, AnyRef)*)(implicit t: ClassTag[T]) = { + val name = (("config" -> config.description) +: params) + .map { case (name, value) => s"$name = $value" } + .mkString("[", ", ", "]") + + val paramValues = new java.util.LinkedList[AnyRef] + paramValues.add(config) + for (param <- params) + paramValues.add(param._2) + + new BlockJUnit4ClassRunnerWithParameters( + new TestWithParameters(name, new TestClass(t.runtimeClass), paramValues)) + } + + private def getRunners(config: JSEnvSuiteConfig): java.util.List[Runner] = { + import java.lang.Boolean.{TRUE, FALSE} + + java.util.Arrays.asList( + r[RunTests](config, "withCom" -> FALSE), + r[RunTests](config, "withCom" -> TRUE), + r[TimeoutRunTests](config, "withCom" -> FALSE), + r[TimeoutRunTests](config, "withCom" -> TRUE), + r[ComTests](config), + r[TimeoutComTests](config) + ) + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/JSEnvSuiteConfig.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/JSEnvSuiteConfig.scala new file mode 100644 index 0000000..3a5786d --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/JSEnvSuiteConfig.scala @@ -0,0 +1,84 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test + +import org.scalajs.jsenv.JSEnv + +import scala.concurrent.duration._ + +/** Configuration for a [[JSEnvSuite]]. + * + * @see [[JSEnvSuite]] for usage. + * + * @param jsEnv [[JSEnv]] under test. + * @param terminateVMJSCode A JavaScript expression that terminates the VM. + * If set, proper handling of VM termination is tested. + * @param supportsCom Whether the [[JSEnv]] under test supports + * [[JSEnv#startWithCom]]. + * @param supportsTimeout Whether the [[JSEnv]] under test supports the + * JavaScript timeout methods (as defined in + * [[http://www.scala-js.org/api/scalajs-library/latest/#scala.scalajs.js.timers.RawTimers$ RawTimers]]). + * @param awaitTimeout Amount of time test cases wait for "things". This is + * deliberately not very well specified. Leave this as the default and + * increase it if your tests fail spuriously due to timeouts. + * @param description A human readable description of this configuration; + * defaults to [[JSEnv#name]]. This is only ever used in the parametrized + * JUnit test name. Can be customized if the same [[JSEnv]] is used with + * different configurations (e.g. Selenium with different browsers). + */ +final class JSEnvSuiteConfig private ( + val jsEnv: JSEnv, + val supportsCom: Boolean, + val supportsTimeout: Boolean, + val exitJSStatement: Option[String], + val awaitTimeout: FiniteDuration, + val description: String +) { + private def this(jsEnv: JSEnv) = this( + jsEnv = jsEnv, + supportsCom = true, + supportsTimeout = true, + exitJSStatement = None, + awaitTimeout = 1.minute, + description = jsEnv.name + ) + + def withSupportsCom(supportsCom: Boolean): JSEnvSuiteConfig = + copy(supportsCom = supportsCom) + + def withSupportsTimeout(supportsTimeout: Boolean): JSEnvSuiteConfig = + copy(supportsTimeout = supportsTimeout) + + def withExitJSStatement(code: String): JSEnvSuiteConfig = + copy(exitJSStatement = Some(code)) + + def withAwaitTimeout(awaitTimeout: FiniteDuration): JSEnvSuiteConfig = + copy(awaitTimeout = awaitTimeout) + + def withDescription(description: String): JSEnvSuiteConfig = + copy(description = description) + + private def copy( + supportsCom: Boolean = supportsCom, + supportsTimeout: Boolean = supportsTimeout, + exitJSStatement: Option[String] = exitJSStatement, + awaitTimeout: FiniteDuration = awaitTimeout, + description: String = description) = { + new JSEnvSuiteConfig(jsEnv, supportsCom, supportsTimeout, + exitJSStatement, awaitTimeout, description) + } +} + +object JSEnvSuiteConfig { + def apply(jsEnv: JSEnv): JSEnvSuiteConfig = new JSEnvSuiteConfig(jsEnv) +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/RunTests.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/RunTests.scala new file mode 100644 index 0000000..2abab5b --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/RunTests.scala @@ -0,0 +1,183 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test + +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files + +import com.google.common.jimfs.Jimfs + +import org.junit.Assume._ +import org.junit.{Test, Before, AssumptionViolatedException} + +import org.scalajs.jsenv._ +import org.scalajs.jsenv.test.kit.{TestKit, Run} + +private[test] class RunTests(config: JSEnvSuiteConfig, withCom: Boolean) { + private val kit = new TestKit(config.jsEnv, config.awaitTimeout) + + private def withRun(input: Seq[Input])(body: Run => Unit) = { + if (withCom) kit.withComRun(input)(body) + else kit.withRun(input)(body) + } + + private def withRun(code: String, config: RunConfig = RunConfig())(body: Run => Unit) = { + if (withCom) kit.withComRun(code, config)(body) + else kit.withRun(code, config)(body) + } + + @Test + def failureTest: Unit = { + withRun(""" + var a = {}; + a.foo(); + """) { + _.fails() + } + } + + @Test + def syntaxErrorTest: Unit = { + withRun("{") { + _.fails() + } + } + + @Test + def throwExceptionTest: Unit = { + withRun("throw 1;") { + _.fails() + } + } + + @Test + def catchExceptionTest: Unit = { + withRun(""" + try { + throw "hello world"; + } catch (e) { + console.log(e); + } + """) { + _.expectOut("hello world\n") + .closeRun() + } + } + + @Test // Failed in Phantom - #2053 + def utf8Test: Unit = { + withRun("console.log('\u1234')") { + _.expectOut("\u1234\n") + .closeRun() + } + } + + @Test + def allowScriptTags: Unit = { + withRun("""console.log("");""") { + _.expectOut("\n") + .closeRun() + } + } + + @Test + def jsExitsTest: Unit = { + val exitStat = config.exitJSStatement.getOrElse( + throw new AssumptionViolatedException("JSEnv needs exitJSStatement")) + + withRun(exitStat) { + _.succeeds() + } + } + + // #500 Node.js used to strip double percentage signs even with only 1 argument + @Test + def percentageTest: Unit = { + val strings = (1 to 15).map("%" * _) + val code = strings.map(str => s"""console.log("$str");\n""").mkString("") + val result = strings.mkString("", "\n", "\n") + + withRun(code) { + _.expectOut(result) + .closeRun() + } + } + + @Test + def fastCloseTest: Unit = { + /* This test also tests a failure mode where the ExternalJSRun is still + * piping output while the client calls close. + */ + withRun("") { + _.closeRun() + } + } + + @Test + def multiCloseAfterTerminatedTest: Unit = { + withRun("") { run => + run.closeRun() + + // Should be noops (and not fail). + run.closeRun() + run.closeRun() + run.closeRun() + } + } + + @Test + def noThrowOnBadFileTest: Unit = { + val badFile = Jimfs.newFileSystem().getPath("nonexistent") + + // `start` may not throw but must fail asynchronously + withRun(Input.Script(badFile) :: Nil) { + _.fails() + } + } + + @Test + def defaultFilesystem: Unit = { + // Tests that a JSEnv works with files from the default filesystem. + + val tmpFile = File.createTempFile("sjs-run-test-defaultfile", ".js") + try { + val tmpPath = tmpFile.toPath + Files.write(tmpPath, "console.log(\"test\");".getBytes(StandardCharsets.UTF_8)) + + withRun(Input.Script(tmpPath) :: Nil) { + _.expectOut("test\n") + .closeRun() + } + } finally { + tmpFile.delete() + } + } + + /* This test verifies that a [[JSEnv]] properly validates its [[RunConfig]] + * (through [[RunConfig.Validator]]). + * + * If you get here, because the test suite fails on your [[JSEnv]] you are not + * using [[RunConfig.Validator]] properly (or at all). See its documentation + * on how to use it properly. + * + * This test sets a private option on [[RunConfig]] that is only known + * internally. This ensures that [[JSEnv]]s reject options added in the future + * they cannot support. + */ + @Test(expected = classOf[IllegalArgumentException]) + def ensureValidate: Unit = { + val cfg = RunConfig().withEternallyUnsupportedOption(true) + withRun("", cfg)(identity) + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/TimeoutComTests.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/TimeoutComTests.scala new file mode 100644 index 0000000..e34adf6 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/TimeoutComTests.scala @@ -0,0 +1,130 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test + +import scala.concurrent.duration._ + +import org.junit.{Before, Test} +import org.junit.Assert._ +import org.junit.Assume._ + +import org.scalajs.jsenv._ +import org.scalajs.jsenv.test.kit.TestKit + +private[test] class TimeoutComTests(config: JSEnvSuiteConfig) { + private val kit = new TestKit(config.jsEnv, config.awaitTimeout) + + @Before + def before: Unit = { + assumeTrue("JSEnv needs timeout support", config.supportsTimeout) + assumeTrue("JSEnv needs com support", config.supportsCom) + } + + /** Slack for timeout tests (see #3457) + * + * Empirically we can observe that timing can be off by ~0.1ms. By cutting + * 10ms slack, we definitely account for this without compromising the tests. + */ + private val slack = 10.millis + + @Test + def delayedInitTest: Unit = { + val deadline = (100.millis - slack).fromNow + kit.withComRun(""" + setTimeout(function() { + scalajsCom.init(function(msg) { + scalajsCom.send("Got: " + msg); + }); + }, 100); + """) { run => + run.send("Hello World") + .expectMsg("Got: Hello World") + + assertTrue("Execution took too little time", deadline.isOverdue()) + + run + .expectNoMsgs() + .closeRun() + } + } + + @Test + def delayedReplyTest: Unit = { + kit.withComRun(""" + scalajsCom.init(function(msg) { + setTimeout(scalajsCom.send, 200, "Got: " + msg); + }); + """) { run => + for (i <- 1 to 10) { + val deadline = (200.millis - slack).fromNow + run + .send(s"Hello World: $i") + .expectMsg(s"Got: Hello World: $i") + + assertTrue("Execution took too little time", deadline.isOverdue()) + } + + run + .expectNoMsgs() + .closeRun() + } + } + + @Test + def intervalSendTest: Unit = { + val deadline = (250.millis - slack).fromNow + + kit.withComRun(""" + scalajsCom.init(function(msg) {}); + var sent = 0 + var interval = setInterval(function () { + scalajsCom.send("Hello"); + sent++; + if (sent >= 5) clearInterval(interval); + }, 50); + """) { run => + for (i <- 1 to 5) + run.expectMsg("Hello") + + assertTrue("Execution took too little time", deadline.isOverdue()) + + run + .expectNoMsgs() + .closeRun() + } + } + + @Test + def noMessageTest: Unit = { + kit.withComRun(s""" + // Make sure JVM has already closed when we init + setTimeout(scalajsCom.init, 1000, function(msg) {}); + """) { + _.closeRun() + } + } + + @Test // #3411 + def noImmediateCallbackTest: Unit = { + kit.withComRun(s""" + setTimeout(function() { + var gotCalled = false; + scalajsCom.init(function(msg) { gotCalled = true; }); + if (gotCalled) throw "Buffered messages did not get deferred to the event loop"; + }, 100); + """) { + _.send("Hello World") + .closeRun() + } + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/TimeoutRunTests.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/TimeoutRunTests.scala new file mode 100644 index 0000000..95dcff4 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/TimeoutRunTests.scala @@ -0,0 +1,82 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test + +import scala.concurrent.duration._ + +import org.junit.{Before, Test} +import org.junit.Assert._ +import org.junit.Assume._ + +import org.scalajs.jsenv._ +import org.scalajs.jsenv.test.kit.{TestKit, Run} + +private[test] class TimeoutRunTests(config: JSEnvSuiteConfig, withCom: Boolean) { + private val kit = new TestKit(config.jsEnv, config.awaitTimeout) + + private def withRun(input: String)(body: Run => Unit) = { + if (withCom) kit.withComRun(input)(body) + else kit.withRun(input)(body) + } + + @Before + def before: Unit = { + assumeTrue("JSEnv needs timeout support", config.supportsTimeout) + } + + /** Slack for timeout tests (see #3457) + * + * Empirically we can observe that timing can be off by ~0.1ms. By cutting + * 10ms slack, we definitely account for this without compromising the tests. + */ + private val slack = 10.millis + + @Test + def basicTimeoutTest: Unit = { + + val deadline = (300.millis - slack).fromNow + + withRun(""" + setTimeout(function() { console.log("1"); }, 200); + setTimeout(function() { console.log("2"); }, 100); + setTimeout(function() { console.log("3"); }, 300); + setTimeout(function() { console.log("4"); }, 0); + """) { + _.expectOut("4\n") + .expectOut("2\n") + .expectOut("1\n") + .expectOut("3\n") + .closeRun() + } + + assertTrue("Execution took too little time", deadline.isOverdue()) + } + + @Test + def intervalTest: Unit = { + val deadline = (100.millis - slack).fromNow + + withRun(""" + setInterval(function() { console.log("tick"); }, 20); + """) { + _.expectOut("tick\n") + .expectOut("tick\n") + .expectOut("tick\n") + .expectOut("tick\n") + .expectOut("tick\n") + .closeRun() // Terminate after 5 iterations + } + + assertTrue("Execution took too little time", deadline.isOverdue()) + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/ComRun.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/ComRun.scala new file mode 100644 index 0000000..3b15bbe --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/ComRun.scala @@ -0,0 +1,109 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.concurrent.Await +import scala.concurrent.duration.FiniteDuration + +import org.junit.Assert._ + +import org.scalajs.jsenv._ + +/** A [[JSComRun]] instrumented for testing. + * + * Create an instance of this class through one of the overloads of + * `[[TestKit]].withComRun` or `[[TestKit]].startWithCom`. + */ +class ComRun private[kit] (run: JSComRun, out: IOReader, err: IOReader, + msgs: MsgHandler, timeout: FiniteDuration) + extends Run(run, out, err, timeout) { + private[this] var noMessages = false + + /** Calls [[JSComRun#send]] on the underlying run. */ + final def send(msg: String): this.type = { + requireValidMessage(msg) + run.send(msg) + this + } + + /** Waits until the given message is sent to the JVM. + * + * @throws java.lang.AssertionError if there is another message or the run terminates. + * @throws java.util.concurrent.TimeoutException if there is no message for too long. + */ + final def expectMsg(expected: String): this.type = { + requireValidMessage(expected) + require(!noMessages, "You may not call expectMsg after calling expectNoMsgs") + val actual = msgs.waitOnMessage(timeout.fromNow) + assertEquals("got bad message", expected, actual) + this + } + + private def requireValidMessage(msg: String): Unit = { + val len = msg.length + var i = 0 + while (i < len) { + val c = msg.charAt(i) + + def fail(lowOrHigh: String): Nothing = { + val msgDescription = + if (len > 128) s"Message (of length $len)" + else s"Message '$msg'" + throw new IllegalArgumentException( + s"$msgDescription is not a valid message because it contains an " + + s"unpaired $lowOrHigh surrogate 0x${c.toInt.toHexString} at index $i") + } + + if (Character.isSurrogate(c)) { + if (Character.isLowSurrogate(c)) + fail("low") + else if (i == len - 1 || !Character.isLowSurrogate(msg.charAt(i + 1))) + fail("high") + else + i += 2 + } else { + i += 1 + } + } + } + + /** Marks that no further messages are expected. + * + * This will make the methods [[closeRun]] / [[fails]] / [[succeeds]] fail if + * further messages are received. + * + * @note It is illegal to call [[expectMsg]] after [[expectNoMsgs]] has been + * called. + */ + final def expectNoMsgs(): this.type = { + noMessages = true + this + } + + override protected def postCloseRunWait(): Unit = { + try { + Await.result(run.future, timeout) + } catch { + case t: Throwable => + throw new AssertionError("closing a ComRun failed unexpectedly", t) + } + } + + override protected def postStopChecks(): Unit = { + super.postStopChecks() + if (noMessages) { + val rem = msgs.remainingMessages() + assertTrue(s"unhandled messages: $rem", rem.isEmpty) + } + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/IOReader.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/IOReader.scala new file mode 100644 index 0000000..f5217f0 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/IOReader.scala @@ -0,0 +1,135 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.annotation.tailrec + +import scala.concurrent.Promise +import scala.concurrent.duration.Deadline + +import scala.util.Try + +import java.nio.ByteBuffer +import java.nio.channels.{Channels, ReadableByteChannel} + +import java.io.InputStream + +import java.util.concurrent._ + +private[kit] final class IOReader { + private val executor = Executors.newSingleThreadExecutor() + + private[this] var _closed = false + private[this] var _channel: ReadableByteChannel = _ + private[this] val run = Promise[Unit]() + + def read(len: Int, deadline: Deadline): ByteBuffer = { + val chan = try { + waitOnChannel(deadline) + } catch { + case t: TimeoutException => + throw new TimeoutException("timed out waiting on run to call onOutputStream") + } + + val task = executor.submit( + new Callable[ByteBuffer] { + def call(): ByteBuffer = readLoop(chan, ByteBuffer.allocate(len)) + } + ) + + try { + task.get(millisLeft(deadline), TimeUnit.MILLISECONDS) + } catch { + case e: ExecutionException => + throw e.getCause() + + case e: CancellationException => + throw new AssertionError("unexpected exception while running read task", e) + + case e: InterruptedException => + throw new AssertionError("unexpected exception while running read task", e) + + case e: TimeoutException => + task.cancel(true) + throw new TimeoutException("timed out reading from stream") + } + } + + def onInputStream(in: InputStream): Unit = synchronized { + require(_channel == null, "onInputStream called twice") + + if (_closed) { + in.close() + } else { + _channel = Channels.newChannel(in) + notifyAll() + } + } + + def onRunComplete(t: Try[Unit]): Unit = synchronized { + run.complete(t) + notifyAll() + } + + def close(): Unit = synchronized { + if (_channel != null) + _channel.close() + _closed = true + } + + private def waitOnChannel(deadline: Deadline) = synchronized { + while (_channel == null && !run.isCompleted) + wait(millisLeft(deadline)) + + if (_channel == null) { + throw new AssertionError( + "run completed and did not call onOutputStream", runFailureCause()) + } + + _channel + } + + private def runFailureCause() = { + require(run.isCompleted) + run.future.value.get.failed.getOrElse(null) + } + + @tailrec + private def readLoop(chan: ReadableByteChannel, buf: ByteBuffer): buf.type = { + if (chan.read(buf) == -1) { + // If we have reached the end of the stream, we wait for completion of the + // run so we can report a potential failure as a cause. + synchronized { + while (!run.isCompleted) + wait() + } + + throw new AssertionError("reached end of stream", runFailureCause()) + } else if (buf.hasRemaining()) { + readLoop(chan, buf) + } else { + buf.flip() + buf + } + } + + private def millisLeft(deadline: Deadline): Long = { + val millis = deadline.timeLeft.toMillis + + if (millis <= 0) { + throw new TimeoutException + } + + millis + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/MsgHandler.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/MsgHandler.scala new file mode 100644 index 0000000..9825c76 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/MsgHandler.scala @@ -0,0 +1,69 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.annotation.tailrec + +import scala.collection.immutable + +import scala.concurrent.Promise +import scala.concurrent.duration.Deadline + +import scala.util.Try + +import java.util.concurrent.TimeoutException + +private[kit] final class MsgHandler { + private[this] var msgs: immutable.Queue[String] = + immutable.Queue.empty[String] + private[this] val run = Promise[Unit]() + + def onMessage(msg: String): Unit = synchronized { + if (run.isCompleted) { + throw new IllegalStateException( + "run already completed but still got a message") + } + + msgs = msgs.enqueue(msg) + notifyAll() + } + + def onRunComplete(t: Try[Unit]): Unit = synchronized { + run.complete(t) + notifyAll() + } + + @tailrec + def waitOnMessage(deadline: Deadline): String = synchronized { + if (msgs.nonEmpty) { + val (msg, newMsgs) = msgs.dequeue + msgs = newMsgs + msg + } else if (run.isCompleted) { + val cause = run.future.value.get.failed.getOrElse(null) + throw new AssertionError("no messages left and run has completed", cause) + } else { + val millis = deadline.timeLeft.toMillis + + if (millis <= 0) { + throw new TimeoutException("timed out waiting for next message") + } + + wait(millis) + waitOnMessage(deadline) + } + } + + /** @note may only be called once the run is completed. */ + def remainingMessages(): List[String] = synchronized(msgs.toList) +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/Run.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/Run.scala new file mode 100644 index 0000000..75e23e1 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/Run.scala @@ -0,0 +1,109 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.concurrent.Await +import scala.concurrent.duration.FiniteDuration + +import java.nio.charset.{CodingErrorAction, StandardCharsets} + +import org.junit.Assert._ + +import org.scalajs.jsenv._ + +/** A [[JSRun]] instrumented for testing. + * + * Create an instance of this class through one of the overloads of + * `[[TestKit]].withRun` or `[[TestKit]].start`. + */ +class Run private[kit] (run: JSRun, out: IOReader, err: IOReader, timeout: FiniteDuration) extends AutoCloseable { + private[this] val utf8decoder = { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + } + + /** Waits until the given string is output to stdout (in UTF8). + * + * @throws java.lang.AssertionError if there is some other output on stdout + * or the run terminates. + * @throws java.util.concurrent.TimeoutException if there is not enough output for too long. + */ + final def expectOut(v: String): this.type = expectIO(out, "stdout", v) + + /** Waits until the given string is output to stderr (in UTF8). + * + * @throws java.lang.AssertionError if there is some other output on stderr + * or the run terminates. + * @throws java.util.concurrent.TimeoutException if there is not enough output for too long. + */ + final def expectErr(v: String): this.type = expectIO(err, "stderr", v) + + /** Waits until the underlying [[JSRun]] terminates and asserts it failed. + * + * @throws java.lang.AssertionError if the [[JSRun]] succeeded. + * @throws java.util.concurrent.TimeoutException if the [[JSRun]] did not terminate in time. + */ + final def fails(): Unit = { + Await.ready(run.future, timeout) + assertTrue("run succeeded unexpectedly", run.future.value.get.isFailure) + postStopChecks() + } + + /** Waits until the underlying [[JSRun]] terminates and asserts it succeeded. + * + * @throws java.lang.AssertionError if the [[JSRun]] failed. + * @throws java.util.concurrent.TimeoutException if the [[JSRun]] did not terminate in time. + */ + final def succeeds(): Unit = { + try { + Await.result(run.future, timeout) + } catch { + case t: Throwable => + throw new AssertionError("run failed unexpectedly", t) + } + postStopChecks() + } + + /** Calls [[JSRun#close]] on the underlying [[JSRun]] and awaits termination. + * + * @throws java.lang.AssertionError if the [[JSRun]] behaves unexpectedly. + * @throws java.util.concurrent.TimeoutException if the [[JSRun]] does not terminate in time. + */ + final def closeRun(): Unit = { + run.close() + postCloseRunWait() + postStopChecks() + } + + /** Must be called to free all resources of this [[Run]]. Does not throw. */ + def close(): Unit = { + out.close() + err.close() + run.close() + } + + protected def postCloseRunWait(): Unit = Await.ready(run.future, timeout) + + protected def postStopChecks(): Unit = () + + private def expectIO(reader: IOReader, name: String, v: String): this.type = { + val len = v.getBytes(StandardCharsets.UTF_8).length + val buf = reader.read(len, timeout.fromNow) + val got = utf8decoder.decode(buf).toString + + assertEquals(s"bad output on $name", v, got) + + this + } +} diff --git a/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/TestKit.scala b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/TestKit.scala new file mode 100644 index 0000000..196ab47 --- /dev/null +++ b/js-envs-test-kit/src/main/scala/org/scalajs/jsenv/test/kit/TestKit.scala @@ -0,0 +1,163 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration.FiniteDuration + +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.nio.file._ +import java.util.concurrent.Executors + +import com.google.common.jimfs.Jimfs + +import org.scalajs.jsenv._ + +/** TestKit is a utility class to simplify testing of [[JSEnv]]s. + * + * It is mostly used by Scala.js' provided [[JSEnv]] test suite but it may be + * used for additional tests specific to a particular [[JSEnv]]. + * + * @example + * {{{ + * import scala.concurrent.duration._ + * + * val kit = new TestKit(new MyEnv, 1.second) + * kit.withRun("""console.log("Hello World");""") { + * _.expectOut("Hello World\n") + * .closeRun() + * } + * }}} + * + * @note Methods in [[TestKit]] allow to take a string instead of an [[Input]]. + * The string is converted into an input form supported by the [[JSEnv]] to + * execute the code therein. + * + * @constructor Create a new [[TestKit]] for the given [[JSEnv]] and timeout. + * @param jsEnv The [[JSEnv]] to be tested. + * @param timeout Timeout for all `expect*` methods on [[Run]] / [[ComRun]]. + */ +final class TestKit(jsEnv: JSEnv, timeout: FiniteDuration) { + import TestKit.codeToInput + + /** Starts a [[Run]] for testing. */ + def start(code: String): Run = + start(codeToInput(code)) + + /** Starts a [[Run]] for testing. */ + def start(input: Seq[Input]): Run = + start(input, RunConfig()) + + /** Starts a [[Run]] for testing. */ + def start(code: String, config: RunConfig): Run = + start(codeToInput(code), config) + + /** Starts a [[Run]] for testing. */ + def start(input: Seq[Input], config: RunConfig): Run = { + val (run, out, err) = io(config)(jsEnv.start(input, _)) + new Run(run, out, err, timeout) + } + + /** Starts a [[ComRun]] for testing. */ + def startWithCom(code: String): ComRun = + startWithCom(codeToInput(code)) + + /** Starts a [[ComRun]] for testing. */ + def startWithCom(input: Seq[Input]): ComRun = + startWithCom(input, RunConfig()) + + /** Starts a [[ComRun]] for testing. */ + def startWithCom(code: String, config: RunConfig): ComRun = + startWithCom(codeToInput(code), config) + + /** Starts a [[ComRun]] for testing. */ + def startWithCom(input: Seq[Input], config: RunConfig): ComRun = { + val msg = new MsgHandler + val (run, out, err) = io(config)(jsEnv.startWithCom(input, _, msg.onMessage _)) + run.future.onComplete(msg.onRunComplete _)(TestKit.completer) + + new ComRun(run, out, err, msg, timeout) + } + + /** Convenience method to start a [[Run]] and close it after usage. */ + def withRun[T](code: String)(body: Run => T): T = + withRun(codeToInput(code))(body) + + /** Convenience method to start a [[Run]] and close it after usage. */ + def withRun[T](input: Seq[Input])(body: Run => T): T = + withRun(input, RunConfig())(body) + + /** Convenience method to start a [[Run]] and close it after usage. */ + def withRun[T](code: String, config: RunConfig)(body: Run => T): T = + withRun(codeToInput(code), config)(body) + + /** Convenience method to start a [[Run]] and close it after usage. */ + def withRun[T](input: Seq[Input], config: RunConfig)(body: Run => T): T = { + val run = start(input, config) + try body(run) + finally run.close() + } + + /** Convenience method to start a [[ComRun]] and close it after usage. */ + def withComRun[T](code: String)(body: ComRun => T): T = withComRun(codeToInput(code))(body) + + /** Convenience method to start a [[ComRun]] and close it after usage. */ + def withComRun[T](input: Seq[Input])(body: ComRun => T): T = withComRun(input, RunConfig())(body) + + /** Convenience method to start a [[ComRun]] and close it after usage. */ + def withComRun[T](code: String, config: RunConfig)(body: ComRun => T): T = + withComRun(codeToInput(code), config)(body) + + /** Convenience method to start a [[ComRun]] and close it after usage. */ + def withComRun[T](input: Seq[Input], config: RunConfig)(body: ComRun => T): T = { + val run = startWithCom(input, config) + try body(run) + finally run.close() + } + + private def io[T <: JSRun](config: RunConfig)(start: RunConfig => T): (T, IOReader, IOReader) = { + val out = new IOReader + val err = new IOReader + + def onOutputStream(o: Option[InputStream], e: Option[InputStream]) = { + o.foreach(out.onInputStream _) + e.foreach(err.onInputStream _) + } + + val newConfig = config + .withOnOutputStream(onOutputStream) + .withInheritOut(false) + .withInheritErr(false) + + val run = start(newConfig) + + run.future.onComplete(out.onRunComplete _)(TestKit.completer) + run.future.onComplete(err.onRunComplete _)(TestKit.completer) + + (run, out, err) + } +} + +private object TestKit { + /** Execution context to run completion callbacks from runs under test. */ + private val completer = + ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor()) + + private def codeToInput(code: String): Seq[Input] = { + val p = Files.write( + Jimfs.newFileSystem().getPath("testScript.js"), + code.getBytes(StandardCharsets.UTF_8)) + List(Input.Script(p)) + } +} diff --git a/js-envs-test-kit/src/test/scala/org/scalajs/jsenv/test/kit/TestEnv.scala b/js-envs-test-kit/src/test/scala/org/scalajs/jsenv/test/kit/TestEnv.scala new file mode 100644 index 0000000..819d734 --- /dev/null +++ b/js-envs-test-kit/src/test/scala/org/scalajs/jsenv/test/kit/TestEnv.scala @@ -0,0 +1,98 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.concurrent.{Future, Promise} + +import java.io._ +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicInteger + +import org.scalajs.jsenv._ + +private[kit] class TestEnv private ( + result: Future[Unit], + outerr: Option[() => InputStream], + msgs: List[String]) extends JSEnv { + + // Interface for testing. + + def withSuccess(): TestEnv = copy(result = Future.successful(())) + + def withFailure(t: Throwable): TestEnv = copy(result = Future.failed(t)) + + def withHang(): TestEnv = copy(result = Promise[Unit]().future) + + def withOutErr(s: String): TestEnv = { + val bytes = s.getBytes(StandardCharsets.UTF_8) + copy(outerr = Some(() => new ByteArrayInputStream(bytes))) + } + + def withOutErrHang(): TestEnv = { + def hangStream() = new InputStream { + // read method that hangs indefinitely. + def read(): Int = synchronized { + while (true) wait() + throw new AssertionError("unreachable code") + } + } + + copy(outerr = Some(() => hangStream())) + } + + def withMsgs(msgs: String*): TestEnv = copy(msgs = msgs.toList) + + private def this() = this(Future.successful(()), None, Nil) + + private def copy( + result: Future[Unit] = result, + outerr: Option[() => InputStream] = outerr, + msgs: List[String] = msgs) = new TestEnv(result, outerr, msgs) + + // JSEnv interface + + val name: String = "TestEnv" + + def start(input: Seq[Input], config: RunConfig): JSRun = { + require(msgs.isEmpty) + callOnOutputStream(config) + new TestRun + } + + def startWithCom(input: Seq[Input], config: RunConfig, onMessage: String => Unit): JSComRun = { + callOnOutputStream(config) + msgs.foreach(onMessage) + new TestRun with JSComRun { + def send(msg: String): Unit = () + } + } + + private def callOnOutputStream(config: RunConfig): Unit = { + for { + factory <- outerr + onOutputStream <- config.onOutputStream + } { + def mkStream = Some(factory()) + onOutputStream(mkStream, mkStream) + } + } + + private class TestRun extends JSRun { + val future: Future[Unit] = result + def close(): Unit = () + } +} + +object TestEnv { + def apply(): TestEnv = new TestEnv() +} diff --git a/js-envs-test-kit/src/test/scala/org/scalajs/jsenv/test/kit/TestKitTest.scala b/js-envs-test-kit/src/test/scala/org/scalajs/jsenv/test/kit/TestKitTest.scala new file mode 100644 index 0000000..ca436fd --- /dev/null +++ b/js-envs-test-kit/src/test/scala/org/scalajs/jsenv/test/kit/TestKitTest.scala @@ -0,0 +1,296 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.test.kit + +import scala.concurrent.duration._ + +import java.util.concurrent._ + +import org.junit.Assert._ +import org.junit.Test + +import org.scalajs.jsenv._ + +class TestKitTest { + import TestKit.codeToInput + import TestKitTest._ + + private def noHangTest(env: TestEnv, msg: String)(body: TestKit => Unit) = { + def test(e: JSEnv, cause: Throwable) = { + val timeout = 1.minute + val kit = new TestKit(e, timeout) + val deadline = timeout.fromNow + + expectAssert(msg, cause)(body(kit)) + + assertFalse("faster than timeout", deadline.isOverdue) + } + + test(env.withSuccess(), null) + + val t = new Throwable + test(env.withFailure(t), t) + } + + @Test + def noHangExpectOutNoStream: Unit = { + noHangTest(TestEnv(), "run completed and did not call onOutputStream") { + _.withRun("") { + _.expectOut("a") + .closeRun() + } + } + } + + @Test + def noHangExpectErrNoStream: Unit = { + noHangTest(TestEnv(), "run completed and did not call onOutputStream") { + _.withRun("") { + _.expectErr("a") + .closeRun() + } + } + } + + @Test + def noHangExpectMsgOnFail: Unit = { + noHangTest(TestEnv(), "no messages left and run has completed") { + _.withComRun("") { + _.expectMsg("a") + .closeRun() + } + } + } + + @Test + def noHangExpectOutOnEOF: Unit = { + noHangTest(TestEnv().withOutErr(""), "reached end of stream") { + _.withRun("") { + _.expectOut("a") + .closeRun() + } + } + } + + @Test + def noHangExpectErrOnEOF: Unit = { + noHangTest(TestEnv().withOutErr(""), "reached end of stream") { + _.withRun("") { + _.expectErr("a") + .closeRun() + } + } + } + + @Test + def failOnUnexpectedSuccess: Unit = { + val kit = new TestKit(TestEnv().withSuccess(), 1.second) + expectAssert("run succeeded unexpectedly") { + kit.withRun("")(_.fails()) + } + } + + @Test + def failOnUnexpectedFailure: Unit = { + val t = new Throwable + val kit = new TestKit(TestEnv().withFailure(t), 1.second) + + expectAssert("run failed unexpectedly", t) { + kit.withRun("")(_.succeeds()) + } + } + + @Test + def ignoreRunFailOnClose: Unit = { + val kit = new TestKit(TestEnv().withFailure(new Throwable("dummy for test")), 1.second) + kit.withRun("")(_.closeRun()) + } + + @Test + def enforceSuccessComRunOnClose: Unit = { + val t = new Throwable + val kit = new TestKit(TestEnv().withFailure(t), 1.second) + + expectAssert("closing a ComRun failed unexpectedly", t) { + kit.withComRun("")(_.closeRun()) + } + } + + @Test + def failOnBadOut: Unit = { + val kit = new TestKit(TestEnv().withOutErr("a"), 1.second) + + expectAssert("bad output on stdout expected:<[b]> but was:<[a]>") { + kit.withRun("") { + _.expectOut("b") + .closeRun() + } + } + } + + @Test + def failOnBadErr: Unit = { + val kit = new TestKit(TestEnv().withOutErr("a"), 1.second) + + expectAssert("bad output on stderr expected:<[b]> but was:<[a]>") { + kit.withRun("") { + _.expectErr("b") + .closeRun() + } + } + } + + @Test + def ignoreExcessOut: Unit = { + val kit = new TestKit(TestEnv().withOutErr("abcdefg"), 1.second) + + kit.withRun("") { + _.expectOut("a") + .expectOut("b") + .closeRun() + } + } + + @Test + def ignoreExcessErr: Unit = { + val kit = new TestKit(TestEnv().withOutErr("abcdefg"), 1.second) + + kit.withRun("") { + _.expectErr("a") + .expectErr("b") + .closeRun() + } + } + + @Test + def failOnBadMsgErr: Unit = { + val kit = new TestKit(TestEnv().withMsgs("a"), 1.second) + + expectAssert("got bad message expected:<[b]> but was:<[a]>") { + kit.withComRun("") { + _.expectMsg("b") + .closeRun() + } + } + } + + @Test + def failOnExcessMsgs: Unit = { + val kit = new TestKit(TestEnv().withMsgs("a", "b", "c"), 1.second) + + expectAssert("unhandled messages: List(b, c)") { + kit.withComRun("") { + _.expectMsg("a") + .expectNoMsgs() + .closeRun() + } + } + } + + @Test + def ignoreExcessMsgs: Unit = { + val kit = new TestKit(TestEnv().withMsgs("a", "b", "c"), 1.second) + + kit.withComRun("") { + _.expectMsg("a") + .closeRun() + } + } + + @Test + def timeoutOutOnNoStream: Unit = { + val kit = new TestKit(TestEnv().withHang(), 10.millisecond) + + expectTimeout("timed out waiting on run to call onOutputStream") { + kit.withRun("") { + _.expectOut("b") + .closeRun() + } + } + } + + @Test + def timeoutErrOnNoStream: Unit = { + val kit = new TestKit(TestEnv().withHang(), 10.millisecond) + + expectTimeout("timed out waiting on run to call onOutputStream") { + kit.withRun("") { + _.expectErr("b") + .closeRun() + } + } + } + + @Test + def timeoutExpectMsg: Unit = { + val kit = new TestKit(TestEnv().withHang(), 10.millisecond) + + expectTimeout("timed out waiting for next message") { + kit.withComRun("") { + _.expectMsg("a") + .closeRun() + } + } + } + + @Test + def timeoutExpectOut: Unit = { + val kit = new TestKit(TestEnv().withOutErrHang(), 10.millisecond) + + expectTimeout("timed out reading from stream") { + kit.withRun("") { + _.expectOut("b") + .closeRun() + } + } + } + + @Test + def timeoutExpectErr: Unit = { + val kit = new TestKit(TestEnv().withOutErrHang(), 10.millisecond) + + expectTimeout("timed out reading from stream") { + kit.withRun("") { + _.expectErr("b") + .closeRun() + } + } + } +} + +private object TestKitTest { + def expectAssert(msg: String, cause: Throwable = null)(body: => Unit): Unit = { + val thrown = try { + body + false + } catch { + case e: AssertionError => + assertEquals("bad assertion error message", msg, e.getMessage()) + assertSame("should link cause", cause, e.getCause()) + true + } + + if (!thrown) + throw new AssertionError("expected AssertionError to be thrown") + } + + def expectTimeout(msg: String)(body: => Unit): Unit = { + try { + body + throw new AssertionError("expected TimeoutExeception to be thrown") + } catch { + case e: TimeoutException => + assertEquals("bad timeout error message", msg, e.getMessage()) + } + } +} diff --git a/js-envs/src/main/scala/org/scalajs/jsenv/ExternalJSRun.scala b/js-envs/src/main/scala/org/scalajs/jsenv/ExternalJSRun.scala new file mode 100644 index 0000000..ae40578 --- /dev/null +++ b/js-envs/src/main/scala/org/scalajs/jsenv/ExternalJSRun.scala @@ -0,0 +1,199 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +import java.io.{IOException, OutputStream} + +import scala.concurrent.{Future, Promise} +import scala.util.control.NonFatal + +/** Support for creating a [[JSRun]] via an external process. */ +object ExternalJSRun { + /** Starts a [[JSRun]] in an external process. + * + * [[ExternalJSRun]] redirects the I/O of the external process according to + * [[Config#runConfig]]. + * + * @see [[supports]] for the exact options it currently supports. + * + * @param command Binary to execute including arguments. + * @param config Configuration. + * @param input Function to inform about creation of stdin for the external process. + * `input` should feed the required stdin to the passed + * [[java.io.OutputStream OutputStream]] and close it. + */ + def start(command: List[String], config: Config)( + input: OutputStream => Unit): JSRun = { + require(command.nonEmpty, "command may not be empty") + + try { + val process = startProcess(command, config.env, config.runConfig) + try { + notifyOutputStreams(config.runConfig, process) + + new ExternalJSRun(process, input, config.closingFails) + } catch { + case t: Throwable => + process.destroyForcibly() + throw t + } + } catch { + case NonFatal(t) => JSRun.failed(t) + } + } + + /** Informs the given [[RunConfig.Validator]] about the options an + * [[ExternalJSRun]] supports. + * + * Use this method to automatically benefit from improvements to + * [[ExternalJSRun]] without modifying the client [[JSEnv]]. + * + * Currently, this calls + * - [[RunConfig.Validator#supportsInheritIO supportsInheritIO]] + * - [[RunConfig.Validator#supportsOnOutputStream supportsOnOutputStream]] + * + * Note that in consequence, a [[JSEnv]] ''may not'' handle these options if + * it uses [[ExternalJSRun]]. + */ + def supports(validator: RunConfig.Validator): RunConfig.Validator = { + validator + .supportsInheritIO() + .supportsOnOutputStream() + } + + /** Configuration for a [[ExternalJSRun]] + * + * @param env Additional environment variables. The environment of the host + * JVM is inherited. + * @param runConfig Configuration for the run. See [[ExternalJSRun.supports]] + * for details about the currently supported configuration. + * @param closingFails Whether calling [[JSRun#close]] on a still running + * [[JSRun]] fails the run. While this defaults to true, [[JSEnv]]s that + * do not support automatic termination (and do not expect the JS program + * itself to explicitly terminate) typically want to set this to false + * (at least for non-com runs), since otherwise there is no successful + * way of terminating a [[JSRun]]. + */ + final class Config private ( + val env: Map[String, String], + val runConfig: RunConfig, + val closingFails: Boolean + ) { + private def this() = { + this( + env = Map.empty, + runConfig = RunConfig(), + closingFails = true) + } + + def withEnv(env: Map[String, String]): Config = + copy(env = env) + + def withRunConfig(runConfig: RunConfig): Config = + copy(runConfig = runConfig) + + def withClosingFails(closingFails: Boolean): Config = + copy(closingFails = closingFails) + + private def copy(env: Map[String, String] = env, + runConfig: RunConfig = runConfig, + closingFails: Boolean = closingFails) = { + new Config(env, runConfig, closingFails) + } + } + + object Config { + def apply(): Config = new Config() + } + + private def notifyOutputStreams(config: RunConfig, process: Process) = { + def opt[T](b: Boolean, v: => T) = if (b) Some(v) else None + + val out = opt(!config.inheritOutput, process.getInputStream()) + val err = opt(!config.inheritError, process.getErrorStream()) + + config.onOutputStream.foreach(f => f(out, err)) + } + + private def startProcess(command: List[String], env: Map[String, String], + config: RunConfig) = { + val builder = new ProcessBuilder(command: _*) + + if (config.inheritOutput) + builder.redirectOutput(ProcessBuilder.Redirect.INHERIT) + + if (config.inheritError) + builder.redirectError(ProcessBuilder.Redirect.INHERIT) + + for ((name, value) <- env) + builder.environment().put(name, value) + + config.logger.debug("Starting process: " + command.mkString(" ")) + + builder.start() + } + + final case class NonZeroExitException(retVal: Int) + extends Exception(s"exited with code $retVal") + + final case class ClosedException() + extends Exception("Termination was requested by user") +} + +private final class ExternalJSRun(process: Process, + input: OutputStream => Unit, closingFails: Boolean) extends JSRun { + + private[this] val promise = Promise[Unit]() + + @volatile + private[this] var closing = false + + def future: Future[Unit] = promise.future + + def close(): Unit = { + closing = true + process.destroyForcibly() + } + + private val waiter = new Thread { + setName("ExternalJSRun waiter") + + override def run(): Unit = { + try { + try { + input(process.getOutputStream()) + } catch { + case _: IOException if closing => + // We got closed while writing. Exception is expected. + } + + val retVal = process.waitFor() + if (retVal == 0 || closing && !closingFails) + promise.success(()) + else if (closing) + promise.failure(new ExternalJSRun.ClosedException) + else + promise.failure(new ExternalJSRun.NonZeroExitException(retVal)) + } catch { + case t: Throwable => + process.destroyForcibly() + promise.failure(t) + + if (!NonFatal(t)) + throw t + } + } + } + + waiter.start() +} diff --git a/js-envs/src/main/scala/org/scalajs/jsenv/Input.scala b/js-envs/src/main/scala/org/scalajs/jsenv/Input.scala new file mode 100644 index 0000000..e97ad44 --- /dev/null +++ b/js-envs/src/main/scala/org/scalajs/jsenv/Input.scala @@ -0,0 +1,49 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +import java.nio.file.Path + +/** Input to a [[JSEnv]]. + * + * Implementors of a [[JSEnv]] are expected to pattern match on this input + * type and handle the ones they support. + * + * Note that this type is not sealed, so future versions of Scala.js may add + * additional input types. Older [[JSEnv]]s are expected to fail in this case + * with an [[UnsupportedInputException]]. + */ +abstract class Input private () + +object Input { + /** The file is to be loaded as a script into the global scope. */ + final case class Script(script: Path) extends Input + + /** The file is to be loaded as an ES module. + * + * Some environments may not be able to load several ES modules in a + * deterministic order. If that is the case, they must reject an + * `ESModule` input if it appears with other Inputs such that loading + * in a deterministic order is not possible. + */ + final case class ESModule(module: Path) extends Input + + /** The file is to be loaded as a CommonJS module. */ + final case class CommonJSModule(module: Path) extends Input +} + +class UnsupportedInputException(msg: String, cause: Throwable) + extends IllegalArgumentException(msg, cause) { + def this(msg: String) = this(msg, null) + def this(input: Seq[Input]) = this(s"Unsupported input: $input") +} diff --git a/js-envs/src/main/scala/org/scalajs/jsenv/JSEnv.scala b/js-envs/src/main/scala/org/scalajs/jsenv/JSEnv.scala new file mode 100644 index 0000000..c1b14d0 --- /dev/null +++ b/js-envs/src/main/scala/org/scalajs/jsenv/JSEnv.scala @@ -0,0 +1,90 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +/** A JavaScript execution environment. + * + * This can run and interact with JavaScript code. + * + * Any implementation is expected to be fully thread-safe. + */ +trait JSEnv { + /** Human-readable name for this [[JSEnv]] */ + val name: String + + /** Starts a new (asynchronous) JS run. + * + * This may only throw if value of `input` is unknown or `config` cannot be + * supported. To verify whether a [[RunConfig]] can be supported in a forward + * compatible manner (i.e. when new options are added in later versions) + * implementations of [[JSEnv]]s must use [[RunConfig.Validator]]. + * + * This must not throw if the run cannot be started or there is a problem + * with the input's content (e.g. file does not exist, syntax error, etc.). + * In this case, [[JSRun#future]] should be failed instead. + * + * @throws UnsupportedInputException if the value of `input` cannot be + * supported. + * @throws java.lang.IllegalArgumentException if the value of `config` cannot + * be supported. + */ + def start(input: Seq[Input], config: RunConfig): JSRun + + /** Like [[start]], but initializes a communication channel. + * + * Inside the VM this is to provide a global JavaScript object named + * `scalajsCom` that can be used to interact with the message channel. Its + * operations are: + * {{{ + * // initialize com (with callback). May only be called once. + * scalajsCom.init(function(msg) { console.log("Received: " + msg); }); + * + * // send a message to host system + * scalajsCom.send("my message"); + * }}} + * + * All messages, sent in both directions, must be valid UTF-16 strings, + * i.e., they must not contain any unpaired surrogate character. The + * behavior of a communication channel is unspecified if this requirement is + * not met. + * + * We describe the expected message delivery guarantees by denoting the + * transmitter as `t` and the receiver as `r`. Both the JVM and the JS end + * act once as a transmitter and once as a receiver. These two + * transmitter/receiver pairs (JS/JVM and JVM/JS) are independent. + * + * For a pair `(t,r)`: + * + * + * @param onMessage Callback invoked each time a message is received from the + * JS VM. The implementation may not call this anymore once + * [[JSRun#future]] of the returned [[JSComRun]] is completed. Further, + * [[JSRun#future]] may only complete with no callback in-flight. + */ + def startWithCom(input: Seq[Input], config: RunConfig, + onMessage: String => Unit): JSComRun +} diff --git a/js-envs/src/main/scala/org/scalajs/jsenv/JSRuns.scala b/js-envs/src/main/scala/org/scalajs/jsenv/JSRuns.scala new file mode 100644 index 0000000..2f54576 --- /dev/null +++ b/js-envs/src/main/scala/org/scalajs/jsenv/JSRuns.scala @@ -0,0 +1,83 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +import scala.concurrent.Future + +/** A launched instance of a [[JSEnv]]. + * + * This is the interface to actually running JS code (whether this is in + * process or not depens on the [[JSEnv]] that created the [[JSRun]]). + * + * Any implementation is expected to be fully thread-safe. + */ +trait JSRun extends AutoCloseable { + /** A [[scala.concurrent.Future Future]] that completes if the run completes. + * + * The future is failed if the run fails. + * + * Note that a [[JSRun]] is not required to ever terminate on it's own. That + * means even if all code is executed and the event loop is empty, the run + * may continue to run. As a consequence, it is *not* correct to rely on + * termination of a [[JSRun]] without any external means of stopping it + * (i.e. calling [[close]]). + */ + def future: Future[Unit] + + /** Stops the run and releases all the resources. + * + * This must be called to ensure the run's resources are + * released. + * + * Whether or not this makes the run fail or not is up to the implementation. + * However, in the following cases, calling [[close]] may not fail the run: + * + * + * Idempotent, async, nothrow. + */ + def close(): Unit +} + +object JSRun { + /** Creates a [[JSRun]] that has failed. */ + def failed(cause: Throwable): JSRun = new JSRun { + def close(): Unit = () + val future: Future[Unit] = Future.failed(cause) + } +} + +/** A [[JSRun]] that has a communication channel to the running JS code. */ +trait JSComRun extends JSRun { + /** Sends a message to the JS end. + * + * The `msg` must be a valid UTF-16 string, i.e., it must not contain any + * unpaired surrogate character. The behavior of the communication channel + * is unspecified if this requirement is not met. + * + * Async, nothrow. See [[JSEnv#startWithCom]] for expected message delivery + * guarantees. + */ + def send(msg: String): Unit +} + +object JSComRun { + /** Creates a [[JSComRun]] that has failed. */ + def failed(cause: Throwable): JSComRun = new JSComRun { + def close(): Unit = () + val future: Future[Unit] = Future.failed(cause) + def send(msg: String): Unit = () + } +} diff --git a/js-envs/src/main/scala/org/scalajs/jsenv/JSUtils.scala b/js-envs/src/main/scala/org/scalajs/jsenv/JSUtils.scala new file mode 100644 index 0000000..b140381 --- /dev/null +++ b/js-envs/src/main/scala/org/scalajs/jsenv/JSUtils.scala @@ -0,0 +1,92 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +object JSUtils { + + def escapeJS(str: String): String = { + // scalastyle:off return + val end = str.length + var i = 0 + while (i != end) { + val c = str.charAt(i) + if (c >= 32 && c <= 126 && c != '\\' && c != '"') + i += 1 + else + return createEscapeJSString(str) + } + str + // scalastyle:on return + } + + private def createEscapeJSString(str: String): String = { + val sb = new java.lang.StringBuilder(2 * str.length) + printEscapeJS(str, sb) + sb.toString + } + + /* !!! BEGIN CODE VERY SIMILAR TO ir/.../Utils.scala and + * linker/.../javascript/Utils.scala + */ + + private final val EscapeJSChars = "\\b\\t\\n\\v\\f\\r\\\"\\\\" + + private def printEscapeJS(str: String, out: java.lang.StringBuilder): Unit = { + /* Note that Java and JavaScript happen to use the same encoding for + * Unicode, namely UTF-16, which means that 1 char from Java always equals + * 1 char in JavaScript. */ + val end = str.length() + var i = 0 + /* Loop prints all consecutive ASCII printable characters starting + * from current i and one non ASCII printable character (if it exists). + * The new i is set at the end of the appended characters. + */ + while (i != end) { + val start = i + var c: Int = str.charAt(i) + // Find all consecutive ASCII printable characters from `start` + while (i != end && c >= 32 && c <= 126 && c != 34 && c != 92) { + i += 1 + if (i != end) + c = str.charAt(i) + } + // Print ASCII printable characters from `start` + if (start != i) { + out.append(str, start, i) + } + + // Print next non ASCII printable character + if (i != end) { + def escapeJSEncoded(c: Int): Unit = { + if (7 < c && c < 14) { + val i = 2 * (c - 8) + out.append(EscapeJSChars, i, i + 2) + } else if (c == 34) { + out.append(EscapeJSChars, 12, 14) + } else if (c == 92) { + out.append(EscapeJSChars, 14, 16) + } else { + out.append("\\u%04x".format(c)) + } + } + escapeJSEncoded(c) + i += 1 + } + } + } + + /* !!! END CODE VERY SIMILAR TO ir/.../Utils.scala and + * linker/.../javascript/Utils.scala + */ + +} diff --git a/js-envs/src/main/scala/org/scalajs/jsenv/RunConfig.scala b/js-envs/src/main/scala/org/scalajs/jsenv/RunConfig.scala new file mode 100644 index 0000000..15af4db --- /dev/null +++ b/js-envs/src/main/scala/org/scalajs/jsenv/RunConfig.scala @@ -0,0 +1,162 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +import java.io.InputStream + +import org.scalajs.logging._ + +/** Configuration provided when starting a [[JSEnv]]. + * + * @param onOutputStream Callback once output streams of the JS VM run become available. + * + * The callback receives the output and the error stream of the VM if they + * are available. If [[inheritOutput]] or [[inheritError]] are set to true, the + * respective streams must be `None`, in the invocation of + * [[onOutputStream]]. Note however, that if [[onOutputStream]] is present, + * it must be invoked by the JS VM. + * + * @param inheritOutput Whether the output stream of the VM should be inherited. + * + * The implementation may chose to redirect to the actual output stream of + * the parent JVM or simply [[scala.Console#out]]. + * + * If you set this value to `false` you must set [[onOutputStream]]. + * + * @param inheritError Whether the error stream of the VM should be inherited. + * + * The implementation may chose to redirect to the actual error stream of the + * parent JVM or simply [[scala.Console#err]]. + * + * If you set this value to `false` you must set [[onOutputStream]]. + * + * @param logger The logger to use in the run. A [[JSEnv]] is not required to + * log anything. + */ +final class RunConfig private ( + val onOutputStream: Option[RunConfig.OnOutputStream], + val inheritOutput: Boolean, + val inheritError: Boolean, + val logger: Logger, + /** An option that will never be supported by anything because it is not exposed. + * + * This is used to test that [[JSEnv]]s properly validate their configuration. + */ + private[jsenv] val eternallyUnsupportedOption: Boolean +) { + import RunConfig.OnOutputStream + + private def this() = { + this( + onOutputStream = None, + inheritOutput = true, + inheritError = true, + logger = NullLogger, + eternallyUnsupportedOption = false) + } + + def withOnOutputStream(onOutputStream: OnOutputStream): RunConfig = + copy(onOutputStream = Some(onOutputStream)) + + def withInheritOut(inheritOutput: Boolean): RunConfig = + copy(inheritOutput = inheritOutput) + + def withInheritErr(inheritError: Boolean): RunConfig = + copy(inheritError = inheritError) + + def withLogger(logger: Logger): RunConfig = + copy(logger = logger) + + private[jsenv] def withEternallyUnsupportedOption( + eternallyUnsupportedOption: Boolean): RunConfig = + copy(eternallyUnsupportedOption = eternallyUnsupportedOption) + + private def copy(onOutputStream: Option[OnOutputStream] = onOutputStream, + inheritOutput: Boolean = inheritOutput, + inheritError: Boolean = inheritError, + logger: Logger = logger, + eternallyUnsupportedOption: Boolean = eternallyUnsupportedOption + ): RunConfig = { + new RunConfig(onOutputStream, inheritOutput, inheritError, logger, + eternallyUnsupportedOption) + } + + /** Validates constraints on the config itself. */ + private def validate(): Unit = { + if (onOutputStream.isEmpty && (!inheritOutput || !inheritError)) { + throw new IllegalArgumentException("You may not set inheritOutput or " + + "inheritError to false without setting onOutputStream.") + } + } +} + +final object RunConfig { + type OnOutputStream = (Option[InputStream], Option[InputStream]) => Unit + def apply(): RunConfig = new RunConfig() + + /** Support validator for [[RunConfig]]. + * + * Validators allow us to add options to [[RunConfig]] in a forward + * compatible manner. + * + * Every [[JSEnv]] must + * + * 1. create a [[Validator]] + * 1. inform it of the [[JSEnv]]'s capabilities + * 1. invoke [[validate]] with every received [[RunConfig]] + * + * This ensures that all set config options are supported by the [[JSEnv]]. + */ + final class Validator private ( + inheritIO: Boolean, + onOutputStream: Boolean + ) { + private def this() = this(false, false) + + /** The caller supports [[RunConfig#inheritOutput]] and + * [[RunConfig#inheritError]]. + */ + def supportsInheritIO(): Validator = copy(inheritIO = true) + + /** The caller supports [[RunConfig#onOutputStream]]. */ + def supportsOnOutputStream(): Validator = copy(onOutputStream = true) + + /** Validates that `config` is valid and only sets supported options. + * + * @throws java.lang.IllegalArgumentException if there are unsupported options. + */ + def validate(config: RunConfig): Unit = { + def fail(msg: String) = throw new IllegalArgumentException(msg) + + config.validate() + + if (!inheritIO && (config.inheritOutput || config.inheritError)) + fail("inheritOutput / inheritError are not supported.") + + if (!onOutputStream && config.onOutputStream.isDefined) + fail("onOutputStream is not supported.") + + if (config.eternallyUnsupportedOption) + fail("eternallyUnsupportedOption is not supported.") + } + + private def copy(inheritIO: Boolean = inheritIO, + onOutputStream: Boolean = onOutputStream) = { + new Validator(inheritIO, onOutputStream) + } + } + + object Validator { + def apply(): Validator = new Validator() + } +} diff --git a/js-envs/src/test/scala/org/scalajs/jsenv/RunConfigTest.scala b/js-envs/src/test/scala/org/scalajs/jsenv/RunConfigTest.scala new file mode 100644 index 0000000..b57cc48 --- /dev/null +++ b/js-envs/src/test/scala/org/scalajs/jsenv/RunConfigTest.scala @@ -0,0 +1,100 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv + +import org.junit.Test + +class RunConfigTest { + @Test + def supportedInheritIO: Unit = { + val cfg = RunConfig() + .withInheritOut(true) + .withInheritErr(true) + RunConfig.Validator() + .supportsInheritIO() + .validate(cfg) + } + + @Test(expected = classOf[IllegalArgumentException]) + def unsupportedInheritOut: Unit = { + val cfg = RunConfig() + .withInheritOut(true) + .withInheritErr(false) + .withOnOutputStream((_, _) => ()) + RunConfig.Validator() + .supportsOnOutputStream() + .validate(cfg) + } + + @Test(expected = classOf[IllegalArgumentException]) + def unsupportedInheritErr: Unit = { + val cfg = RunConfig() + .withInheritOut(false) + .withInheritErr(true) + .withOnOutputStream((_, _) => ()) + RunConfig.Validator() + .supportsOnOutputStream() + .validate(cfg) + } + + @Test + def supportedOnOutputStream: Unit = { + val cfg = RunConfig() + .withInheritOut(false) + .withInheritErr(false) + .withOnOutputStream((_, _) => ()) + RunConfig.Validator() + .supportsOnOutputStream() + .validate(cfg) + } + + @Test(expected = classOf[IllegalArgumentException]) + def unsupportedOnOutputStream: Unit = { + val cfg = RunConfig() + .withInheritOut(false) + .withInheritErr(false) + .withOnOutputStream((_, _) => ()) + RunConfig.Validator() + .validate(cfg) + } + + @Test(expected = classOf[IllegalArgumentException]) + def missingOnOutputStreamNoInheritOut: Unit = { + val cfg = RunConfig() + .withInheritOut(false) + .withInheritErr(true) + RunConfig.Validator() + .supportsInheritIO() + .supportsOnOutputStream() + .validate(cfg) + } + + @Test(expected = classOf[IllegalArgumentException]) + def missingOnOutputStreamNoInheritErr: Unit = { + val cfg = RunConfig() + .withInheritOut(true) + .withInheritErr(false) + RunConfig.Validator() + .supportsInheritIO() + .supportsOnOutputStream() + .validate(cfg) + } + + @Test(expected = classOf[IllegalArgumentException]) + def failValidationForTest: Unit = { + val cfg = RunConfig() + .withEternallyUnsupportedOption(true) + RunConfig.Validator() + .validate(cfg) + } +} diff --git a/nodejs-env/src/main/scala/org/scalajs/jsenv/nodejs/ComSupport.scala b/nodejs-env/src/main/scala/org/scalajs/jsenv/nodejs/ComSupport.scala new file mode 100644 index 0000000..31b514f --- /dev/null +++ b/nodejs-env/src/main/scala/org/scalajs/jsenv/nodejs/ComSupport.scala @@ -0,0 +1,311 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.nodejs + +import scala.collection.immutable +import scala.concurrent._ +import scala.util.{Failure, Success} +import scala.util.control.NonFatal + +// TODO Replace this by a better execution context on the RunConfig. +import scala.concurrent.ExecutionContext.Implicits.global + +import java.io._ +import java.net._ +import java.nio.charset.StandardCharsets +import java.nio.file._ + +import com.google.common.jimfs.Jimfs + +import org.scalajs.jsenv._ + +private final class ComRun(run: JSRun, handleMessage: String => Unit, + serverSocket: ServerSocket) extends JSComRun { + import ComRun._ + + /** Promise that completes once the receiver thread is completed. */ + private[this] val promise = Promise[Unit]() + + @volatile + private[this] var state: State = AwaitingConnection(Nil) + + // If the run completes, make sure we also complete. + run.future.onComplete { + case Failure(t) => forceClose(t) + case Success(_) => onJSTerminated() + } + + // TODO replace this with scheduled tasks on the execution context. + private[this] val receiver = new Thread { + setName("ComRun receiver") + + override def run(): Unit = { + try { + try { + /* We need to await the connection unconditionally. Otherwise the JS end + * might try to connect indefinitely. + */ + awaitConnection() + + while (state != Closing) { + state match { + case s: AwaitingConnection => + throw new IllegalStateException(s"Unexpected state: $s") + + case Closing => + /* We can end up here if there is a race between the two read to + * state. Do nothing, loop will terminate. + */ + + case Connected(_, _, js2jvm) => + try { + val len = js2jvm.readInt() + val carr = Array.fill(len)(js2jvm.readChar()) + handleMessage(String.valueOf(carr)) + } catch { + case _: EOFException => + // JS end terminated gracefully. Close. + close() + } + } + } + } catch { + case _: IOException if state == Closing => + // We got interrupted by a graceful close. + // This is OK. + } + + /* Everything got closed. We wait for the run to terminate. + * We need to wait in order to make sure that closing the + * underlying run does not fail it. + */ + ComRun.this.run.future.foreach { _ => + ComRun.this.run.close() + promise.trySuccess(()) + } + } catch { + case t: Throwable => handleThrowable(t) + } + } + } + + receiver.start() + + def future: Future[Unit] = promise.future + + def send(msg: String): Unit = synchronized { + state match { + case AwaitingConnection(msgs) => + state = AwaitingConnection(msg :: msgs) + + case Connected(_, jvm2js, _) => + try { + writeMsg(jvm2js, msg) + jvm2js.flush() + } catch { + case t: Throwable => handleThrowable(t) + } + + case Closing => // ignore msg. + } + } + + def close(): Unit = synchronized { + val oldState = state + + // Signal receiver thread that it is OK if socket read fails. + state = Closing + + oldState match { + case c: Connected => + // Interrupts the receiver thread and signals the VM to terminate. + closeAll(c) + + case Closing | _:AwaitingConnection => + } + } + + private def onJSTerminated() = { + close() + + /* Interrupt receiver if we are still waiting for connection. + * Should only be relevant if we are still awaiting the connection. + * Note: We cannot do this in close(), otherwise if the JVM side closes + * before the JS side connected, the JS VM will fail instead of terminate + * normally. + */ + serverSocket.close() + } + + private def forceClose(cause: Throwable) = { + promise.tryFailure(cause) + close() + run.close() + serverSocket.close() + } + + private def handleThrowable(cause: Throwable) = { + forceClose(cause) + if (!NonFatal(cause)) + throw cause + } + + private def awaitConnection(): Unit = { + var comSocket: Socket = null + var jvm2js: DataOutputStream = null + var js2jvm: DataInputStream = null + + try { + comSocket = serverSocket.accept() + serverSocket.close() // we don't need it anymore. + jvm2js = new DataOutputStream( + new BufferedOutputStream(comSocket.getOutputStream())) + js2jvm = new DataInputStream( + new BufferedInputStream(comSocket.getInputStream())) + + onConnected(Connected(comSocket, jvm2js, js2jvm)) + } catch { + case t: Throwable => + closeAll(comSocket, jvm2js, js2jvm) + throw t + } + } + + private def onConnected(c: Connected): Unit = synchronized { + state match { + case AwaitingConnection(msgs) => + msgs.reverse.foreach(writeMsg(c.jvm2js, _)) + c.jvm2js.flush() + state = c + + case _: Connected => + throw new IllegalStateException(s"Unexpected state: $state") + + case Closing => + closeAll(c) + } + } +} + +object ComRun { + /** Starts a [[JSComRun]] using the provided [[JSRun]] launcher. + * + * @param config Configuration for the run. + * @param onMessage callback upon message reception. + * @param startRun [[JSRun]] launcher. Gets passed a + * [[java.nio.file.Path Path]] that initializes `scalaJSCom` on + * `global`. Requires Node.js libraries. + */ + def start(config: RunConfig, onMessage: String => Unit)(startRun: Path => JSRun): JSComRun = { + try { + val serverSocket = + new ServerSocket(0, 0, InetAddress.getByName(null)) // Loopback address + + val run = startRun(setupFile(serverSocket.getLocalPort)) + + new ComRun(run, onMessage, serverSocket) + } catch { + case NonFatal(t) => JSComRun.failed(t) + } + } + + private def closeAll(c: Closeable*): Unit = + c.withFilter(_ != null).foreach(_.close()) + + private def closeAll(c: Connected): Unit = + closeAll(c.comSocket, c.jvm2js, c.js2jvm) + + private sealed trait State + + private final case class AwaitingConnection( + sendQueue: List[String]) extends State + + private final case class Connected( + comSocket: Socket, + jvm2js: DataOutputStream, + js2jvm: DataInputStream) extends State + + private final case object Closing extends State + + private def writeMsg(s: DataOutputStream, msg: String): Unit = { + s.writeInt(msg.length) + s.writeChars(msg) + } + + private def setupFile(port: Int): Path = { + Files.write( + Jimfs.newFileSystem().getPath("comSetup.js"), + s""" + |(function() { + | // The socket for communication + | var socket = require('net').connect($port); + | + | // Buffers received data + | var inBuffer = Buffer.alloc(0); + | + | // Buffers received messages + | var inMessages = []; + | + | // The callback where received messages go + | var onMessage = null; + | + | socket.on('data', function(data) { + | inBuffer = Buffer.concat([inBuffer, data]); + | + | while (inBuffer.length >= 4) { + | var msgLen = inBuffer.readInt32BE(0); + | var byteLen = 4 + msgLen * 2; + | + | if (inBuffer.length < byteLen) return; + | var res = ""; + | + | for (var i = 0; i < msgLen; ++i) + | res += String.fromCharCode(inBuffer.readInt16BE(4 + i * 2)); + | + | inBuffer = inBuffer.slice(byteLen); + | + | if (inMessages !== null) inMessages.push(res); + | else onMessage(res); + | } + | }); + | + | socket.on('error', function(err) { + | console.error("Scala.js Com failed: " + err); + | process.exit(-1); + | }); + | + | socket.on('close', function() { process.exit(0); }); + | + | global.scalajsCom = { + | init: function(onMsg) { + | if (onMessage !== null) throw new Error("Com already initialized"); + | onMessage = onMsg; + | process.nextTick(function() { + | for (var i = 0; i < inMessages.length; ++i) + | onMessage(inMessages[i]); + | inMessages = null; + | }); + | }, + | send: function(msg) { + | var len = msg.length; + | var buf = Buffer.allocUnsafe(4 + len * 2); + | buf.writeInt32BE(len, 0); + | for (var i = 0; i < len; ++i) + | buf.writeUInt16BE(msg.charCodeAt(i), 4 + i * 2); + | socket.write(buf); + | } + | } + |}).call(this); + """.stripMargin.getBytes(StandardCharsets.UTF_8)) + } +} diff --git a/nodejs-env/src/main/scala/org/scalajs/jsenv/nodejs/NodeJSEnv.scala b/nodejs-env/src/main/scala/org/scalajs/jsenv/nodejs/NodeJSEnv.scala new file mode 100644 index 0000000..dc968b7 --- /dev/null +++ b/nodejs-env/src/main/scala/org/scalajs/jsenv/nodejs/NodeJSEnv.scala @@ -0,0 +1,271 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.nodejs + + +import scala.annotation.tailrec + +import java.io._ +import java.nio.charset.StandardCharsets +import java.nio.file._ + +import org.scalajs.jsenv._ +import org.scalajs.jsenv.JSUtils.escapeJS + +import org.scalajs.logging._ + +import com.google.common.jimfs.Jimfs + +final class NodeJSEnv(config: NodeJSEnv.Config) extends JSEnv { + import NodeJSEnv._ + + def this() = this(NodeJSEnv.Config()) + + val name: String = "Node.js" + + def start(input: Seq[Input], runConfig: RunConfig): JSRun = { + NodeJSEnv.validator.validate(runConfig) + validateInput(input) + internalStart(initFiles ++ input, runConfig) + } + + def startWithCom(input: Seq[Input], runConfig: RunConfig, + onMessage: String => Unit): JSComRun = { + NodeJSEnv.validator.validate(runConfig) + validateInput(input) + ComRun.start(runConfig, onMessage) { comLoader => + internalStart(initFiles ++ (Input.Script(comLoader) +: input), runConfig) + } + } + + private def validateInput(input: Seq[Input]): Unit = input.foreach { + case _:Input.Script | _:Input.ESModule | _:Input.CommonJSModule => + // ok + case _ => + throw new UnsupportedInputException(input) + } + + private def internalStart(input: Seq[Input], runConfig: RunConfig): JSRun = { + val command = config.executable :: config.args + val externalConfig = ExternalJSRun.Config() + .withEnv(env) + .withRunConfig(runConfig) + ExternalJSRun.start(command, externalConfig)(NodeJSEnv.write(input)) + } + + private def initFiles: Seq[Input] = config.sourceMap match { + case SourceMap.Disable => Nil + case SourceMap.EnableIfAvailable => Input.Script(installSourceMapIfAvailable) :: Nil + case SourceMap.Enable => Input.Script(installSourceMap) :: Nil + } + + private def env: Map[String, String] = + Map("NODE_MODULE_CONTEXTS" -> "0") ++ config.env +} + +object NodeJSEnv { + private lazy val fs = Jimfs.newFileSystem() + + private lazy val validator = ExternalJSRun.supports(RunConfig.Validator()) + + private lazy val installSourceMapIfAvailable = { + Files.write( + fs.getPath("optionalSourceMapSupport.js"), + """ + |try { + | require('source-map-support').install(); + |} catch (e) { + |}; + """.stripMargin.getBytes(StandardCharsets.UTF_8)) + } + + private lazy val installSourceMap = { + Files.write( + fs.getPath("sourceMapSupport.js"), + "require('source-map-support').install();".getBytes(StandardCharsets.UTF_8)) + } + + private def write(input: Seq[Input])(out: OutputStream): Unit = { + def runScript(path: Path): String = { + try { + val f = path.toFile + val pathJS = "\"" + escapeJS(f.getAbsolutePath) + "\"" + s""" + require('vm').runInThisContext( + require('fs').readFileSync($pathJS, { encoding: "utf-8" }), + { filename: $pathJS, displayErrors: true } + ) + """ + } catch { + case _: UnsupportedOperationException => + val code = new String(Files.readAllBytes(path), StandardCharsets.UTF_8) + val codeJS = "\"" + escapeJS(code) + "\"" + val pathJS = "\"" + escapeJS(path.toString) + "\"" + s""" + require('vm').runInThisContext( + $codeJS, + { filename: $pathJS, displayErrors: true } + ) + """ + } + } + + def requireCommonJSModule(module: Path): String = + s"""require("${escapeJS(toFile(module).getAbsolutePath)}")""" + + def importESModule(module: Path): String = + s"""import("${escapeJS(toFile(module).toURI.toASCIIString)}")""" + + def execInputExpr(input: Input): String = input match { + case Input.Script(script) => runScript(script) + case Input.CommonJSModule(module) => requireCommonJSModule(module) + case Input.ESModule(module) => importESModule(module) + } + + val p = new PrintStream(out, false, "UTF8") + try { + if (!input.exists(_.isInstanceOf[Input.ESModule])) { + /* If there is no ES module in the input, we can do everything + * synchronously, and directly on the standard input. + */ + for (item <- input) + p.println(execInputExpr(item) + ";") + } else { + /* If there is at least one ES module, we must asynchronous chain things, + * and we must use an actual file to feed code to Node.js (because + * `import()` cannot be used from the standard input). + */ + val importChain = input.foldLeft("Promise.resolve()") { (prev, item) => + s"$prev.\n then(${execInputExpr(item)})" + } + val importerFileContent = { + s""" + |$importChain.catch(e => { + | console.error(e); + | process.exit(1); + |}); + """.stripMargin + } + val f = createTmpFile("importer.js") + Files.write(f.toPath, importerFileContent.getBytes(StandardCharsets.UTF_8)) + p.println(s"""require("${escapeJS(f.getAbsolutePath)}");""") + } + } finally { + p.close() + } + } + + private def toFile(path: Path): File = { + try { + path.toFile + } catch { + case _: UnsupportedOperationException => + val f = createTmpFile(path.toString) + Files.copy(path, f.toPath(), StandardCopyOption.REPLACE_EXISTING) + f + } + } + + // tmpSuffixRE and createTmpFile copied from HTMLRunnerBuilder.scala + + private val tmpSuffixRE = """[a-zA-Z0-9-_.]*$""".r + + private def createTmpFile(path: String): File = { + /* - createTempFile requires a prefix of at least 3 chars + * - we use a safe part of the path as suffix so the extension stays (some + * browsers need that) and there is a clue which file it came from. + */ + val suffix = tmpSuffixRE.findFirstIn(path).orNull + + val f = File.createTempFile("tmp-", suffix) + f.deleteOnExit() + f + } + + /** Requirements for source map support. */ + sealed abstract class SourceMap + + object SourceMap { + /** Disable source maps. */ + case object Disable extends SourceMap + + /** Enable source maps if `source-map-support` is available. */ + case object EnableIfAvailable extends SourceMap + + /** Always enable source maps. + * + * If `source-map-support` is not available, loading the .js code will + * fail. + */ + case object Enable extends SourceMap + } + + final class Config private ( + val executable: String, + val args: List[String], + val env: Map[String, String], + val sourceMap: SourceMap + ) { + private def this() = { + this( + executable = "node", + args = Nil, + env = Map.empty, + sourceMap = SourceMap.EnableIfAvailable + ) + } + + def withExecutable(executable: String): Config = + copy(executable = executable) + + def withArgs(args: List[String]): Config = + copy(args = args) + + def withEnv(env: Map[String, String]): Config = + copy(env = env) + + def withSourceMap(sourceMap: SourceMap): Config = + copy(sourceMap = sourceMap) + + /** Forces enabling (true) or disabling (false) source maps. + * + * `sourceMap = true` maps to [[SourceMap.Enable]]. `sourceMap = false` + * maps to [[SourceMap.Disable]]. [[SourceMap.EnableIfAvailable]] is never + * used by this method. + */ + def withSourceMap(sourceMap: Boolean): Config = + withSourceMap(if (sourceMap) SourceMap.Enable else SourceMap.Disable) + + private def copy( + executable: String = executable, + args: List[String] = args, + env: Map[String, String] = env, + sourceMap: SourceMap = sourceMap + ): Config = { + new Config(executable, args, env, sourceMap) + } + } + + object Config { + /** Returns a default configuration for a [[NodeJSEnv]]. + * + * The defaults are: + * + * - `executable`: `"node"` + * - `args`: `Nil` + * - `env`: `Map.empty` + * - `sourceMap`: [[SourceMap.EnableIfAvailable]] + */ + def apply(): Config = new Config() + } +} diff --git a/nodejs-env/src/test/scala/org/scalajs/jsenv/nodejs/NodeJSSuite.scala b/nodejs-env/src/test/scala/org/scalajs/jsenv/nodejs/NodeJSSuite.scala new file mode 100644 index 0000000..ede89e4 --- /dev/null +++ b/nodejs-env/src/test/scala/org/scalajs/jsenv/nodejs/NodeJSSuite.scala @@ -0,0 +1,23 @@ +/* + * Scala.js JS Envs (https://github.com/scala-js/scala-js-js-envs) + * + * Copyright EPFL. + * + * Licensed under Apache License 2.0 + * (https://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package org.scalajs.jsenv.nodejs + +import org.scalajs.jsenv.test._ + +import org.junit.runner.RunWith + +@RunWith(classOf[JSEnvSuiteRunner]) +class NodeJSSuite extends JSEnvSuite( + JSEnvSuiteConfig(new NodeJSEnv) + .withExitJSStatement("process.exit(0);") +) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..654fe70 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.3.12 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..b20c845 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.0") +addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0")