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)`:
+ *
+ * - If `t` calls [[JSComRun#send]] exactly in the sequence
+ * {{{
+ * send(m_1), ..., send(m_n)
+ * }}}
+ *
+ * and `r` observes `onMessage(m_k)` (k <= n) but not `onMessage(m_{k+1})`,
+ * `r` must observe
+ * {{{
+ * onMessage(m_1), ..., onMessage(m_k)
+ * }}}
+ * exactly in this order.
+ *
- If `t` and `r` keep running indefinitely and `t` sends n messages,
+ * `r` receives n messages.
+ *
+ *
+ * @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:
+ *
+ * - [[future]] is already completed when [[close]] is called.
+ *
- This is a [[JSComRun]] and the event loop inside the VM is empty.
+ *
+ *
+ * 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")