Skip to content

Commit

Permalink
Merge pull request #39 from scalacenter/jsdom-support
Browse files Browse the repository at this point in the history
#38 Add support for jsdom
  • Loading branch information
julienrf committed Nov 29, 2016
2 parents 4c95661 + cfa8fa4 commit 19cec43
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 30 deletions.
@@ -0,0 +1,9 @@
package org.scalajs.sbtplugin

import org.scalajs.core.tools.linker.backend.ModuleKind
import org.scalajs.jsenv.JSEnv

// HACK Because FrameworkDetector is private
class FrameworkDetectorWrapper(jsEnv: JSEnv, moduleKind: ModuleKind, moduleIdentifier: Option[String]) {
val wrapped = new FrameworkDetector(jsEnv, moduleKind, moduleIdentifier)
}
14 changes: 0 additions & 14 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/Commands.scala
Expand Up @@ -4,20 +4,6 @@ import sbt._

object Commands {

private val npm = sys.props("os.name").toLowerCase match {
case os if os.contains("win") "cmd /c npm"
case _ "npm"
}

/**
* Runs the `npm update` command
* @param cwd Working directory of the process
* @param log Logger
*/
def npmUpdate(cwd: File, log: Logger): Unit = {
run(s"$npm update", cwd, log)
}

def run(cmd: String, cwd: File, logger: Logger): Unit = {
val process = Process(cmd, cwd)
val code = process ! logger
Expand Down
107 changes: 107 additions & 0 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/JSDOMNodeJSEnv.scala
@@ -0,0 +1,107 @@
package scalajsbundler

import java.io.OutputStream

import org.scalajs.core.tools.io.{FileVirtualFile, FileVirtualJSFile, VirtualJSFile}
import org.scalajs.core.tools.jsdep.ResolvedJSDependency
import org.scalajs.jsenv.{AsyncJSRunner, ComJSRunner, JSRunner}
import org.scalajs.core.ir.Utils.escapeJS
import org.scalajs.jsenv.nodejs.AbstractNodeJSEnv
import sbt._

// HACK Copy of Scala.js’ JSDOMNodeJSEnv. The only change is the ability to pass the directory in which jsdom has been installed
class JSDOMNodeJSEnv(
jsDomDirectory: File,
nodejsPath: String = "node",
addArgs: Seq[String] = Seq.empty,
addEnv: Map[String, String] = Map.empty
) extends AbstractNodeJSEnv(nodejsPath, addArgs, addEnv, sourceMap = false) {

protected def vmName: String = "Node.js with JSDOM"

override def jsRunner(libs: Seq[ResolvedJSDependency],
code: VirtualJSFile): JSRunner = {
new DOMNodeRunner(libs, code)
}

override def asyncRunner(libs: Seq[ResolvedJSDependency],
code: VirtualJSFile): AsyncJSRunner = {
new AsyncDOMNodeRunner(libs, code)
}

override def comRunner(libs: Seq[ResolvedJSDependency],
code: VirtualJSFile): ComJSRunner = {
new ComDOMNodeRunner(libs, code)
}

protected class DOMNodeRunner(libs: Seq[ResolvedJSDependency], code: VirtualJSFile)
extends ExtRunner(libs, code) with AbstractDOMNodeRunner

protected class AsyncDOMNodeRunner(libs: Seq[ResolvedJSDependency], code: VirtualJSFile)
extends AsyncExtRunner(libs, code) with AbstractDOMNodeRunner

protected class ComDOMNodeRunner(libs: Seq[ResolvedJSDependency], code: VirtualJSFile)
extends AsyncDOMNodeRunner(libs, code) with NodeComJSRunner

protected trait AbstractDOMNodeRunner extends AbstractNodeRunner {

protected def codeWithJSDOMContext(): Seq[VirtualJSFile] = {
val scriptsJSPaths = getLibJSFiles().map {
case file: FileVirtualFile => file.path
case file => libCache.materialize(file).getAbsolutePath
}
val scriptsStringPath = scriptsJSPaths.map('"' + escapeJS(_) + '"')
val jsDOMCode = {
s"""
|(function () {
| const jsdom = require("jsdom");
| var windowKeys = [];
|
| jsdom.env({
| html: "",
| virtualConsole: jsdom.createVirtualConsole().sendTo(console),
| created: function (error, window) {
| if (error == null) {
| window["__ScalaJSEnv"] = __ScalaJSEnv;
| window["scalajsCom"] = global.scalajsCom;
| windowKeys = Object.keys(window);
| } else {
| console.log(error);
| }
| },
| scripts: [${scriptsStringPath.mkString(", ")}],
| onload: function (window) {
| for (var k in window) {
| if (windowKeys.indexOf(k) == -1)
| global[k] = window[k];
| }
|
| ${code.content}
| }
| });
|})();
|""".stripMargin
}
val codeFile = jsDomDirectory / "codeWithJSDOMContext.js"
IO.write(codeFile, jsDOMCode)
Seq(FileVirtualJSFile(codeFile))
}

override protected def getJSFiles(): Seq[VirtualJSFile] =
initFiles() ++ customInitFiles() ++ codeWithJSDOMContext()

/** Libraries are loaded via scripts in Node.js */
override protected def getLibJSFiles(): Seq[VirtualJSFile] =
libs.map(_.lib)

// Send code to Stdin
override protected def sendVMStdin(out: OutputStream): Unit = {
/* Do not factor this method out into AbstractNodeRunner or when mixin in
* the traits it would use AbstractExtRunner.sendVMStdin due to
* linearization order.
*/
sendJS(getJSFiles(), out)
}
}

}
@@ -0,0 +1,30 @@
package scalajsbundler

import sbt._
import JS.syntax._

object JsDomTestEntries {

/**
* Loads the output of Scala.js and exports all its exported properties to the global namespace,
* so that they are found by jsdom.
* @param sjsOutput Scala.js output
* @param loaderFile File to write the loader to
*/
def writeLoader(sjsOutput: File, loaderFile: File): Unit = {
val window = JS.ref("window")
val require = JS.ref("require")
val Object = JS.ref("Object")
val loader =
JS.let(
require.apply(JS.str(sjsOutput.absolutePath))
) { tests =>
((Object `.` "keys").apply(tests) `.` "forEach").apply(JS.fun { key =>
window.bracket(key) := tests.bracket(key) // Export all properties of the Scala.js module to the global namespace
})
}

IO.write(loaderFile, loader.show)
}

}
21 changes: 21 additions & 0 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/Npm.scala
@@ -0,0 +1,21 @@
package scalajsbundler

import sbt._

object Npm {

/**
* Runs the `npm` command
* @param args Command arguments
* @param workingDir Working directory of the process
* @param logger Logger
*/
def run(args: String*)(workingDir: File, logger: Logger): Unit =
Commands.run((npm +: args).mkString(" "), workingDir, logger)

private val npm = sys.props("os.name").toLowerCase match {
case os if os.contains("win") "cmd /c npm"
case _ "npm"
}

}
@@ -1,8 +1,14 @@
package scalajsbundler

import org.scalajs.core.tools.io.{FileVirtualJSFile, VirtualJSFile}
import org.scalajs.sbtplugin.ScalaJSPlugin
import org.scalajs.core.tools.jsdep.ResolvedJSDependency
import org.scalajs.core.tools.linker.backend.ModuleKind
import org.scalajs.jsenv.ComJSEnv
import org.scalajs.sbtplugin.Loggers.sbtLogger2ToolsLogger
import org.scalajs.sbtplugin.{FrameworkDetectorWrapper, ScalaJSPlugin}
import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._
import org.scalajs.sbtplugin.ScalaJSPluginInternal.{scalaJSEnsureUnforked, scalaJSModuleIdentifier, scalaJSRequestsDOM}
import org.scalajs.testadapter.ScalaJSFramework
import sbt.Keys._
import sbt._

Expand Down Expand Up @@ -49,6 +55,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {

webpackConfigFile := None,

// Include the manifest in the produced artifact
(products in Compile) := (products in Compile).dependsOn(scalaJSBundlerManifest).value,

scalaJSBundlerManifest :=
Expand All @@ -70,24 +77,90 @@ object ScalaJSBundlerPlugin extends AutoPlugin {

private lazy val perConfigSettings: Seq[Def.Setting[_]] =
Seq(
loadedJSEnv := loadedJSEnv.dependsOn(npmUpdate in fastOptJS).value,
npmDependencies := Seq.empty,
npmDevDependencies := Seq.empty
) ++
perScalaJSStageSettings(fastOptJS) ++
perScalaJSStageSettings(fullOptJS) ++
Seq(

npmDevDependencies := Seq.empty,

webpack in fullOptJS := webpackTask(fullOptJS).value,

webpack in fastOptJS := Def.taskDyn {
if (enableReloadWorkflow.value) ReloadWorkflowTasks.webpackTask(fastOptJS)
else webpackTask(fastOptJS)
}.value
)
}.value,

// Override Scala.js’ loadedJSEnv to first run `npm update`
loadedJSEnv := loadedJSEnv.dependsOn(npmUpdate in fastOptJS).value
) ++
perScalaJSStageSettings(fastOptJS) ++
perScalaJSStageSettings(fullOptJS)

private lazy val testSettings: Seq[Setting[_]] =
Seq(
npmDependencies ++= (npmDependencies in Compile).value,
npmDevDependencies ++= (npmDevDependencies in Compile).value

npmDevDependencies ++= (npmDevDependencies in Compile).value,

// Override Scala.js setting, which does not support the combination of jsdom and CommonJS module output kind
loadedTestFrameworks := Def.task {
// use assert to prevent warning about pure expr in stat pos
assert(scalaJSEnsureUnforked.value)

val console = scalaJSConsole.value
val toolsLogger = sbtLogger2ToolsLogger(streams.value.log)
val frameworks = testFrameworks.value
val sjsOutput = fastOptJS.value.data

val env =
jsEnv.?.value.map {
case comJSEnv: ComJSEnv => comJSEnv.loadLibs(Seq(ResolvedJSDependency.minimal(FileVirtualJSFile(sjsOutput))))
case other => sys.error(s"You need a ComJSEnv to test (found ${other.name})")
}.getOrElse {
Def.taskDyn[ComJSEnv] {
val sjsOutput = fastOptJS.value.data
// If jsdom is going to be used, then we should bundle the test module into a file that exports the tests to the global namespace
if ((scalaJSRequestsDOM in fastOptJS).value) Def.task {
val logger = streams.value.log
val targetDir = (crossTarget in fastOptJS).value
val sjsOutputName = sjsOutput.name.stripSuffix(".js")
val bundle = targetDir / s"$sjsOutputName-bundle.js"

val writeTestBundleFunction =
FileFunction.cached(
streams.value.cacheDirectory / "test-loader",
inStyle = FilesInfo.hash
) { _ =>
logger.info("Writing and bundling the test loader")
val loader = targetDir / s"$sjsOutputName-loader.js"
JsDomTestEntries.writeLoader(sjsOutput, loader)
Webpack.run(loader.absolutePath, bundle.absolutePath)(targetDir, logger)
Set.empty
}
writeTestBundleFunction(Set(sjsOutput))
val file = FileVirtualJSFile(bundle)

val jsdomDir = installJsdom.value
new JSDOMNodeJSEnv(jsdomDir).loadLibs(Seq(ResolvedJSDependency.minimal(file)))
} else Def.task {
NodeJSEnv().value.loadLibs(Seq(ResolvedJSDependency.minimal(FileVirtualJSFile(sjsOutput))))
}
}.value
}

// Pretend that we are not using a CommonJS module if jsdom is involved, otherwise that
// would be incompatible with the way jsdom loads scripts
val (moduleKind, moduleIdentifier) =
if ((scalaJSRequestsDOM in fastOptJS).value) (ModuleKind.NoModule, None)
else (scalaJSModuleKind.value, scalaJSModuleIdentifier.value)

val detector =
new FrameworkDetectorWrapper(env, moduleKind, moduleIdentifier).wrapped

detector.detect(frameworks, toolsLogger).map { case (tf, frameworkName) =>
val framework =
new ScalaJSFramework(frameworkName, env, moduleKind, moduleIdentifier, toolsLogger, console)
(tf, framework)
}
}.dependsOn(npmUpdate in fastOptJS).value
)

private def perScalaJSStageSettings(stage: TaskKey[Attributed[File]]): Seq[Def.Setting[_]] = Seq(
Expand All @@ -104,7 +177,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
inStyle = FilesInfo.hash
) { _ =>
log.info("Updating NPM dependencies")
Commands.npmUpdate(targetDir, log)
Npm.run("update")(targetDir, log)
jsResources.foreach { resource =>
IO.write(targetDir / resource.relativePath, resource.content)
}
Expand All @@ -124,6 +197,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
Seq(name -> launcherFile)
},

// Override Scala.js’ scalaJSLauncher to add support for CommonJSModule
scalaJSLauncher in stage := {
val launcher = (scalaJSBundlerLauncher in stage).value
Attributed[VirtualJSFile](FileVirtualJSFile(launcher.file))(
Expand All @@ -148,6 +222,7 @@ object ScalaJSBundlerPlugin extends AutoPlugin {

webpackEmitSourceMaps in stage := (emitSourceMaps in stage).value,

// Override Scala.js’ relativeSourceMaps in case we have to emit source maps in the webpack task, because it does not work with absolute source maps
relativeSourceMaps in stage := (webpackEmitSourceMaps in stage).value

)
Expand Down Expand Up @@ -211,4 +286,17 @@ object ScalaJSBundlerPlugin extends AutoPlugin {
entries.map(_._2).to[Set] + stage.value.data).to[Seq] // Note: the entries should be enough, excepted that they currently are launchers, which do not change even if the scalajs stage output changes
}.dependsOn(npmUpdate in stage)

/** @return Installation directory */
lazy val installJsdom: Def.Initialize[Task[File]] =
Def.task {
val installDir = target.value / "scalajs-bundler-jsdom"
val log = streams.value.log
if (!installDir.exists()) {
log.info(s"Installing jsdom in ${installDir.absolutePath}")
IO.createDirectory(installDir)
Npm.run("install", "jsdom")(installDir, log)
}
installDir
}

}
Expand Up @@ -10,7 +10,7 @@ object Webpack {
* Writes the webpack configuration file
*
* @param emitSourceMaps Whether source maps is enabled at all
* @param webpackEntries Module entries
* @param webpackEntries Module entries (name, file.js)
* @param targetDir Directory to write the file into
* @param log Logger
* @return The written file
Expand Down
Expand Up @@ -4,16 +4,19 @@ enablePlugins(ScalaJSBundlerPlugin)

scalaVersion := "2.11.8"

// Adds a dependency on the Scala facade for the DOM API
libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "0.9.1"

// Adds a dependency on the snabbdom npm package
npmDependencies in Compile += "snabbdom" -> "0.5.3"

// Uses a different Webpack configuration file for production
// Use a different Webpack configuration file for production
webpackConfigFile in fullOptJS := Some(baseDirectory.value / "prod.webpack.config.js")

// Checks that a HTML can be loaded (and that its JavaScript can be executed) without errors
libraryDependencies += "org.scalatest" %%% "scalatest" % "3.0.0" % Test

// Execute the tests in browser-like environment
requiresDOM in Test := true

// Check that a HTML can be loaded (and that its JavaScript can be executed) without errors
InputKey[Unit]("html") := {
import complete.DefaultParsers._
val page = (Space ~> StringBasic).parsed
Expand Down

0 comments on commit 19cec43

Please sign in to comment.